| | """
|
| | Tool system for the agent
|
| | Provides ToolSpec and ToolRouter for managing both built-in and MCP tools
|
| | """
|
| |
|
| | import logging
|
| | import warnings
|
| | from dataclasses import dataclass
|
| | from typing import Any, Awaitable, Callable, Optional
|
| |
|
| | logger = logging.getLogger(__name__)
|
| |
|
| | from fastmcp import Client
|
| | from fastmcp.exceptions import ToolError
|
| | from lmnr import observe
|
| | from mcp.types import EmbeddedResource, ImageContent, TextContent
|
| |
|
| | from agent.config import MCPServerConfig
|
| | from agent.tools.dataset_tools import (
|
| | HF_INSPECT_DATASET_TOOL_SPEC,
|
| | hf_inspect_dataset_handler,
|
| | )
|
| | from agent.tools.docs_tools import (
|
| | EXPLORE_HF_DOCS_TOOL_SPEC,
|
| | HF_DOCS_FETCH_TOOL_SPEC,
|
| | explore_hf_docs_handler,
|
| | hf_docs_fetch_handler,
|
| | )
|
| | from agent.tools.github_find_examples import (
|
| | GITHUB_FIND_EXAMPLES_TOOL_SPEC,
|
| | github_find_examples_handler,
|
| | )
|
| | from agent.tools.github_list_repos import (
|
| | GITHUB_LIST_REPOS_TOOL_SPEC,
|
| | github_list_repos_handler,
|
| | )
|
| | from agent.tools.github_read_file import (
|
| | GITHUB_READ_FILE_TOOL_SPEC,
|
| | github_read_file_handler,
|
| | )
|
| | from agent.tools.hf_repo_files_tool import (
|
| | HF_REPO_FILES_TOOL_SPEC,
|
| | hf_repo_files_handler,
|
| | )
|
| | from agent.tools.hf_repo_git_tool import (
|
| | HF_REPO_GIT_TOOL_SPEC,
|
| | hf_repo_git_handler,
|
| | )
|
| | from agent.tools.jobs_tool import HF_JOBS_TOOL_SPEC, hf_jobs_handler
|
| | from agent.tools.plan_tool import PLAN_TOOL_SPEC, plan_tool_handler
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | warnings.filterwarnings(
|
| | "ignore", category=DeprecationWarning, module="aiohttp.connector"
|
| | )
|
| |
|
| | NOT_ALLOWED_TOOL_NAMES = ["hf_jobs", "hf_doc_search", "hf_doc_fetch", "hf_whoami"]
|
| |
|
| |
|
| | def convert_mcp_content_to_string(content: list) -> str:
|
| | """
|
| | Convert MCP content blocks to a string format compatible with LLM messages.
|
| |
|
| | Based on FastMCP documentation, content can be:
|
| | - TextContent: has .text field
|
| | - ImageContent: has .data and .mimeType fields
|
| | - EmbeddedResource: has .resource field with .text or .blob
|
| |
|
| | Args:
|
| | content: List of MCP content blocks
|
| |
|
| | Returns:
|
| | String representation of the content suitable for LLM consumption
|
| | """
|
| | if not content:
|
| | return ""
|
| |
|
| | parts = []
|
| | for item in content:
|
| | if isinstance(item, TextContent):
|
| |
|
| | parts.append(item.text)
|
| | elif isinstance(item, ImageContent):
|
| |
|
| |
|
| | parts.append(f"[Image: {item.mimeType}]")
|
| | elif isinstance(item, EmbeddedResource):
|
| |
|
| |
|
| | resource = item.resource
|
| | if hasattr(resource, "text") and resource.text:
|
| | parts.append(resource.text)
|
| | elif hasattr(resource, "blob") and resource.blob:
|
| | parts.append(
|
| | f"[Binary data: {resource.mimeType if hasattr(resource, 'mimeType') else 'unknown'}]"
|
| | )
|
| | else:
|
| | parts.append(
|
| | f"[Resource: {resource.uri if hasattr(resource, 'uri') else 'unknown'}]"
|
| | )
|
| | else:
|
| |
|
| | parts.append(str(item))
|
| |
|
| | return "\n".join(parts)
|
| |
|
| |
|
| | @dataclass
|
| | class ToolSpec:
|
| | """Tool specification for LLM"""
|
| |
|
| | name: str
|
| | description: str
|
| | parameters: dict[str, Any]
|
| | handler: Optional[Callable[[dict[str, Any]], Awaitable[tuple[str, bool]]]] = None
|
| |
|
| |
|
| | class ToolRouter:
|
| | """
|
| | Routes tool calls to appropriate handlers.
|
| | Based on codex-rs/core/src/tools/router.rs
|
| | """
|
| |
|
| | def __init__(self, mcp_servers: dict[str, MCPServerConfig]):
|
| | self.tools: dict[str, ToolSpec] = {}
|
| | self.mcp_servers: dict[str, dict[str, Any]] = {}
|
| |
|
| | for tool in create_builtin_tools():
|
| | self.register_tool(tool)
|
| |
|
| | self.mcp_client: Client | None = None
|
| | if mcp_servers:
|
| | mcp_servers_payload = {}
|
| | for name, server in mcp_servers.items():
|
| | mcp_servers_payload[name] = server.model_dump()
|
| | self.mcp_client = Client({"mcpServers": mcp_servers_payload})
|
| | self._mcp_initialized = False
|
| |
|
| | def register_tool(self, tool: ToolSpec) -> None:
|
| | self.tools[tool.name] = tool
|
| |
|
| | async def register_mcp_tools(self) -> None:
|
| | tools = await self.mcp_client.list_tools()
|
| | registered_names = []
|
| | skipped_count = 0
|
| | for tool in tools:
|
| | if tool.name in NOT_ALLOWED_TOOL_NAMES:
|
| | skipped_count += 1
|
| | continue
|
| | registered_names.append(tool.name)
|
| | self.register_tool(
|
| | ToolSpec(
|
| | name=tool.name,
|
| | description=tool.description,
|
| | parameters=tool.inputSchema,
|
| | handler=None,
|
| | )
|
| | )
|
| | logger.info(
|
| | f"Loaded {len(registered_names)} MCP tools: {', '.join(registered_names)} ({skipped_count} disabled)"
|
| | )
|
| |
|
| | async def register_openapi_tool(self) -> None:
|
| | """Register the OpenAPI search tool (requires async initialization)"""
|
| | from agent.tools.docs_tools import (
|
| | _get_api_search_tool_spec,
|
| | search_openapi_handler,
|
| | )
|
| |
|
| |
|
| | openapi_spec = await _get_api_search_tool_spec()
|
| | self.register_tool(
|
| | ToolSpec(
|
| | name=openapi_spec["name"],
|
| | description=openapi_spec["description"],
|
| | parameters=openapi_spec["parameters"],
|
| | handler=search_openapi_handler,
|
| | )
|
| | )
|
| | logger.info(f"Loaded OpenAPI search tool: {openapi_spec['name']}")
|
| |
|
| | def get_tool_specs_for_llm(self) -> list[dict[str, Any]]:
|
| | """Get tool specifications in OpenAI format"""
|
| | specs = []
|
| | for tool in self.tools.values():
|
| | specs.append(
|
| | {
|
| | "type": "function",
|
| | "function": {
|
| | "name": tool.name,
|
| | "description": tool.description,
|
| | "parameters": tool.parameters,
|
| | },
|
| | }
|
| | )
|
| | return specs
|
| |
|
| | async def __aenter__(self) -> "ToolRouter":
|
| | if self.mcp_client is not None:
|
| | await self.mcp_client.__aenter__()
|
| | await self.mcp_client.initialize()
|
| | await self.register_mcp_tools()
|
| | self._mcp_initialized = True
|
| |
|
| |
|
| | await self.register_openapi_tool()
|
| |
|
| | total_tools = len(self.tools)
|
| | logger.info(f"Agent ready with {total_tools} tools total")
|
| |
|
| | return self
|
| |
|
| | async def __aexit__(self, exc_type, exc, tb) -> None:
|
| | if self.mcp_client is not None:
|
| | await self.mcp_client.__aexit__(exc_type, exc, tb)
|
| | self._mcp_initialized = False
|
| |
|
| | @observe(name="call_tool")
|
| | async def call_tool(
|
| | self, tool_name: str, arguments: dict[str, Any], session: Any = None
|
| | ) -> tuple[str, bool]:
|
| | """
|
| | Call a tool and return (output_string, success_bool).
|
| |
|
| | For MCP tools, converts the CallToolResult content blocks to a string.
|
| | For built-in tools, calls their handler directly.
|
| | """
|
| |
|
| | tool = self.tools.get(tool_name)
|
| | if tool and tool.handler:
|
| | import inspect
|
| |
|
| |
|
| | sig = inspect.signature(tool.handler)
|
| | if "session" in sig.parameters:
|
| | return await tool.handler(arguments, session=session)
|
| | return await tool.handler(arguments)
|
| |
|
| |
|
| | if self._mcp_initialized:
|
| | try:
|
| | result = await self.mcp_client.call_tool(tool_name, arguments)
|
| | output = convert_mcp_content_to_string(result.content)
|
| | return output, not result.is_error
|
| | except ToolError as e:
|
| |
|
| | error_msg = f"Tool error: {str(e)}"
|
| | return error_msg, False
|
| |
|
| | return "MCP client not initialized", False
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | def create_builtin_tools() -> list[ToolSpec]:
|
| | """Create built-in tool specifications"""
|
| |
|
| | tools = [
|
| |
|
| | ToolSpec(
|
| | name=EXPLORE_HF_DOCS_TOOL_SPEC["name"],
|
| | description=EXPLORE_HF_DOCS_TOOL_SPEC["description"],
|
| | parameters=EXPLORE_HF_DOCS_TOOL_SPEC["parameters"],
|
| | handler=explore_hf_docs_handler,
|
| | ),
|
| | ToolSpec(
|
| | name=HF_DOCS_FETCH_TOOL_SPEC["name"],
|
| | description=HF_DOCS_FETCH_TOOL_SPEC["description"],
|
| | parameters=HF_DOCS_FETCH_TOOL_SPEC["parameters"],
|
| | handler=hf_docs_fetch_handler,
|
| | ),
|
| |
|
| | ToolSpec(
|
| | name=HF_INSPECT_DATASET_TOOL_SPEC["name"],
|
| | description=HF_INSPECT_DATASET_TOOL_SPEC["description"],
|
| | parameters=HF_INSPECT_DATASET_TOOL_SPEC["parameters"],
|
| | handler=hf_inspect_dataset_handler,
|
| | ),
|
| |
|
| | ToolSpec(
|
| | name=PLAN_TOOL_SPEC["name"],
|
| | description=PLAN_TOOL_SPEC["description"],
|
| | parameters=PLAN_TOOL_SPEC["parameters"],
|
| | handler=plan_tool_handler,
|
| | ),
|
| | ToolSpec(
|
| | name=HF_JOBS_TOOL_SPEC["name"],
|
| | description=HF_JOBS_TOOL_SPEC["description"],
|
| | parameters=HF_JOBS_TOOL_SPEC["parameters"],
|
| | handler=hf_jobs_handler,
|
| | ),
|
| |
|
| | ToolSpec(
|
| | name=HF_REPO_FILES_TOOL_SPEC["name"],
|
| | description=HF_REPO_FILES_TOOL_SPEC["description"],
|
| | parameters=HF_REPO_FILES_TOOL_SPEC["parameters"],
|
| | handler=hf_repo_files_handler,
|
| | ),
|
| | ToolSpec(
|
| | name=HF_REPO_GIT_TOOL_SPEC["name"],
|
| | description=HF_REPO_GIT_TOOL_SPEC["description"],
|
| | parameters=HF_REPO_GIT_TOOL_SPEC["parameters"],
|
| | handler=hf_repo_git_handler,
|
| | ),
|
| | ToolSpec(
|
| | name=GITHUB_FIND_EXAMPLES_TOOL_SPEC["name"],
|
| | description=GITHUB_FIND_EXAMPLES_TOOL_SPEC["description"],
|
| | parameters=GITHUB_FIND_EXAMPLES_TOOL_SPEC["parameters"],
|
| | handler=github_find_examples_handler,
|
| | ),
|
| | ToolSpec(
|
| | name=GITHUB_LIST_REPOS_TOOL_SPEC["name"],
|
| | description=GITHUB_LIST_REPOS_TOOL_SPEC["description"],
|
| | parameters=GITHUB_LIST_REPOS_TOOL_SPEC["parameters"],
|
| | handler=github_list_repos_handler,
|
| | ),
|
| | ToolSpec(
|
| | name=GITHUB_READ_FILE_TOOL_SPEC["name"],
|
| | description=GITHUB_READ_FILE_TOOL_SPEC["description"],
|
| | parameters=GITHUB_READ_FILE_TOOL_SPEC["parameters"],
|
| | handler=github_read_file_handler,
|
| | ),
|
| | ]
|
| |
|
| | tool_names = ", ".join([t.name for t in tools])
|
| | logger.info(f"Loaded {len(tools)} built-in tools: {tool_names}")
|
| |
|
| | return tools
|
| |
|