From 57cbb2e9dd39bc5faabdb7dd1548ac90ceace9b5 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Fri, 24 Jan 2025 15:33:25 +0000 Subject: [PATCH 01/22] initial commit for declarative-mcp-tools --- python/packages/autogen-ext/pyproject.toml | 5 + .../src/autogen_ext/tools/mcp/__init__.py | 3 + .../src/autogen_ext/tools/mcp/_mcp_tool.py | 174 ++++++++++++++++++ .../database/component_factory.py | 28 ++- .../autogenstudio/datamodel/types.py | 23 ++- python/packages/autogen-studio/pyproject.toml | 6 +- python/uv.lock | 89 ++++++++- 7 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py create mode 100644 python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py diff --git a/python/packages/autogen-ext/pyproject.toml b/python/packages/autogen-ext/pyproject.toml index 20579c99baec..793bdf9a654a 100644 --- a/python/packages/autogen-ext/pyproject.toml +++ b/python/packages/autogen-ext/pyproject.toml @@ -105,6 +105,11 @@ semantic-kernel-all = [ "semantic-kernel[google,hugging_face,mistralai,ollama,onnx,anthropic,usearch,pandas,aws,dapr]>=1.17.1", ] +mcp = [ + "mcp>=1.1.3", + "json-schema-to-pydantic>=0.2.0" +] + [tool.hatch.build.targets.wheel] packages = ["src/autogen_ext"] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py new file mode 100644 index 000000000000..556a1019e68d --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py @@ -0,0 +1,3 @@ +from ._mcp_tool import SseMcpTool, SseMcpToolBuilder, SseServerParameters, StdioMcpTool, StdioMcpToolBuilder + +__all__ = ["StdioMcpTool", "StdioMcpToolBuilder", "SseMcpTool", "SseMcpToolBuilder", "SseServerParameters"] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py new file mode 100644 index 000000000000..6d464d3586f6 --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py @@ -0,0 +1,174 @@ +from abc import ABC, abstractmethod +from typing import Any, Type + +from autogen_core import CancellationToken +from autogen_core.tools import BaseTool +from json_schema_to_pydantic import create_model +from mcp import ClientSession, StdioServerParameters, Tool, stdio_client +from mcp.client.sse import sse_client +from pydantic import BaseModel + + +class StdioMcpTool(BaseTool[BaseModel, Any]): + """Adapter for MCP tools to make them compatible with AutoGen. + + Args: + server_params (StdioServerParameters): Parameters for the MCP server connection + tool (Tool): The MCP tool to wrap + """ + + def __init__(self, server_params: StdioServerParameters, tool: Tool) -> None: + self._tool = tool + self.server_params = server_params + + # Extract name and description + name = tool.name + description = tool.description or "" + + # Validate and extract schema information with detailed errors + if tool.inputSchema is None: + raise ValueError(f"Tool {name} has no input schema defined") + + if not isinstance(tool.inputSchema, dict): + raise ValueError(f"Invalid input schema for tool {name}: expected dictionary, got {type(tool.inputSchema)}") + + # Create the input model from the tool's schema + input_model = create_model(tool.inputSchema) + + # Use Any as return type since MCP tool returns can vary + return_type: Type[Any] = object + + super().__init__(input_model, return_type, name, description) + + async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: + """Execute the MCP tool with the given arguments. + + Args: + args: The validated input arguments + cancellation_token: Token for cancelling the operation + + Returns: + The result from the MCP tool + + Raises: + Exception: If tool execution fails + """ + kwargs = args.model_dump() + + try: + async with stdio_client(self.server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + if cancellation_token.is_cancelled(): + raise Exception("Operation cancelled") + + result = await session.call_tool(self._tool.name, kwargs) + + if result.isError: + raise Exception(f"MCP tool execution failed: {result.content}") + return result.content + except Exception as e: + raise Exception(str(e)) from e + +class StdioMcpToolBuilder: + def __init__(self, server_params: StdioServerParameters, tool_name: str) -> None: + self.server_params: StdioServerParameters = server_params + self.tool_name: str = tool_name + + async def build(self) -> StdioMcpTool: + async with stdio_client(self.server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + tools_list = await session.list_tools() + for tool in tools_list.tools: + if tool.name == self.tool_name: + return StdioMcpTool(self.server_params, tool) + raise ValueError(f"Tool {self.tool_name} not found") + +class SseServerParameters(BaseModel): + url: str + headers: dict[str, Any] + + +class SseMcpTool(BaseTool[BaseModel, Any]): + """Adapter for MCP tools to make them compatible with AutoGen. + + Args: + server_params (SseServerParameters): Parameters for the MCP server connection + tool (Tool): The MCP tool to wrap + """ + + def __init__(self, server_params: SseServerParameters, tool: Tool) -> None: + self._tool = tool + self.server_params = server_params + + # Extract name and description + name = tool.name + description = tool.description or "" + + # Validate and extract schema information with detailed errors + if tool.inputSchema is None: + raise ValueError(f"Tool {name} has no input schema defined") + + if not isinstance(tool.inputSchema, dict): + raise ValueError(f"Invalid input schema for tool {name}: expected dictionary, got {type(tool.inputSchema)}") + + # Create the input model from the tool's schema + input_model = create_model(tool.inputSchema) + + # Use Any as return type since MCP tool returns can vary + return_type: Type[Any] = object + + super().__init__(input_model, return_type, name, description) + + async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: + """Execute the MCP tool with the given arguments. + + Args: + args: The validated input arguments + cancellation_token: Token for cancelling the operation + + Returns: + The result from the MCP tool + + Raises: + Exception: If tool execution fails + """ + kwargs = args.model_dump() + + try: + async with sse_client(self.server_params.url, headers=self.server_params.headers) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + if cancellation_token.is_cancelled(): + raise Exception("Operation cancelled") + + result = await session.call_tool(self._tool.name, kwargs) + + if result.isError: + raise Exception(f"MCP tool execution failed: {result.content}") + return result.content + except Exception as e: + raise Exception(str(e)) from e + + +class SseMcpToolBuilder: + def __init__(self, server_params: SseServerParameters, tool_name: str) -> None: + self.server_params = server_params + self.tool_name = tool_name + + async def build(self) -> SseMcpTool: + async with sse_client(self.server_params.url, headers=self.server_params.headers) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + tools_list = await session.list_tools() + for tool in tools_list.tools: + if tool.name == self.tool_name: + return SseMcpTool(self.server_params, tool) + + raise ValueError(f"Tool {self.tool_name} not found") + pass \ No newline at end of file diff --git a/python/packages/autogen-studio/autogenstudio/database/component_factory.py b/python/packages/autogen-studio/autogenstudio/database/component_factory.py index b954b39c0f4b..782ed94dbd45 100644 --- a/python/packages/autogen-studio/autogenstudio/database/component_factory.py +++ b/python/packages/autogen-studio/autogenstudio/database/component_factory.py @@ -17,11 +17,13 @@ TimeoutTermination, TokenUsageTermination, ) +from mcp import StdioServerParameters from autogen_agentchat.teams import MagenticOneGroupChat, RoundRobinGroupChat, SelectorGroupChat from autogen_core.tools import FunctionTool from autogen_ext.agents.file_surfer import FileSurfer from autogen_ext.agents.magentic_one import MagenticOneCoderAgent from autogen_ext.agents.web_surfer import MultimodalWebSurfer +from autogen_ext.tools.mcp import StdioMcpTool, StdioMcpToolBuilder from autogen_ext.models.openai import AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient from ..datamodel.types import ( @@ -47,6 +49,9 @@ TerminationTypes, TextMentionTerminationConfig, ToolConfig, + PythonFunctionToolConfig, + StdioMcpToolConfig, + SseMcpToolConfig, ToolTypes, UserProxyAgentConfig, ) @@ -57,7 +62,7 @@ TeamComponent = Union[RoundRobinGroupChat, SelectorGroupChat, MagenticOneGroupChat] AgentComponent = Union[AssistantAgent, MultimodalWebSurfer, UserProxyAgent, FileSurfer, MagenticOneCoderAgent] ModelComponent = Union[OpenAIChatCompletionClient, AzureOpenAIChatCompletionClient] -ToolComponent = Union[FunctionTool] # Will grow with more tool types +ToolComponent = Union[FunctionTool, StdioMcpTool] # Will grow with more tool types TerminationComponent = Union[ MaxMessageTermination, StopMessageTermination, @@ -325,7 +330,8 @@ async def load_team(self, config: TeamConfig, input_func: Optional[Callable] = N async def load_agent(self, config: AgentConfig, input_func: Optional[Callable] = None) -> AgentComponent: """Create agent instance from configuration.""" - + logger.error(f"Loading agent {config.name}") + logger.error(f"Config: {config}") model_client = None system_message = None tools = [] @@ -335,6 +341,8 @@ async def load_agent(self, config: AgentConfig, input_func: Optional[Callable] = model_client = await self.load(config.model_client) if hasattr(config, "tools") and config.tools: for tool_config in config.tools: + logger.error(f"Loading tool {tool_config.name}") + logger.error(f"Config: {tool_config}") tool = await self.load(tool_config) tools.append(tool) @@ -424,11 +432,9 @@ async def load_model(self, config: ModelConfig) -> ModelComponent: async def load_tool(self, config: ToolConfig) -> ToolComponent: """Create tool instance from configuration.""" + logger.error(f"Loading tool {config.name}") + logger.error(f"Config: {config}") try: - # Validate required fields - if not all([config.name, config.description, config.content, config.tool_type]): - raise ValueError("Tool configuration missing required fields") - # Check cache first cache_key = str(config.model_dump()) if cache_key in self._tool_cache: @@ -436,11 +442,21 @@ async def load_tool(self, config: ToolConfig) -> ToolComponent: return self._tool_cache[cache_key] if config.tool_type == ToolTypes.PYTHON_FUNCTION: + # Validate required fields + if not all([config.name, config.description, config.content, config.tool_type]): + raise ValueError("Tool configuration missing required fields") tool = FunctionTool( name=config.name, description=config.description, func=self._func_from_string(config.content) ) self._tool_cache[cache_key] = tool return tool + elif config.tool_type == ToolTypes.MCP_STDIO_CLIENT: + cfg = StdioServerParameters( + command=config.command, + args=config.args, + ) + builder = StdioMcpToolBuilder(server_params=cfg, tool_name=config.name) + return await builder.build() else: raise ValueError(f"Unsupported tool type: {config.tool_type}") diff --git a/python/packages/autogen-studio/autogenstudio/datamodel/types.py b/python/packages/autogen-studio/autogenstudio/datamodel/types.py index eb02fb121ebe..682303e368f4 100644 --- a/python/packages/autogen-studio/autogenstudio/datamodel/types.py +++ b/python/packages/autogen-studio/autogenstudio/datamodel/types.py @@ -15,6 +15,8 @@ class ModelTypes(str, Enum): class ToolTypes(str, Enum): PYTHON_FUNCTION = "PythonFunction" + MCP_STDIO_CLIENT = "McpStdioClient" + MCP_SSE_CLIENT = "McpSseClient" class AgentTypes(str, Enum): @@ -84,14 +86,31 @@ class AzureOpenAIModelConfig(BaseModelConfig): ModelConfig = OpenAIModelConfig | AzureOpenAIModelConfig -class ToolConfig(BaseConfig): +class BaseToolConfig(BaseConfig): name: str description: str - content: str tool_type: ToolTypes component_type: ComponentTypes = ComponentTypes.TOOL +class PythonFunctionToolConfig(BaseToolConfig): + content: str + tool_type: ToolTypes = ToolTypes.PYTHON_FUNCTION + +class StdioMcpToolConfig(BaseToolConfig): + command: str + args: List[str] + tool_type: ToolTypes = ToolTypes.MCP_STDIO_CLIENT + + +class SseMcpToolConfig(BaseToolConfig): + url: str + headers: Dict[str, Any] + tool_type: ToolTypes = ToolTypes.MCP_SSE_CLIENT + +ToolConfig = PythonFunctionToolConfig | StdioMcpToolConfig | SseMcpToolConfig + + class BaseAgentConfig(BaseConfig): name: str agent_type: AgentTypes diff --git a/python/packages/autogen-studio/pyproject.toml b/python/packages/autogen-studio/pyproject.toml index a8d6911d3e26..c2d7b63a35c8 100644 --- a/python/packages/autogen-studio/pyproject.toml +++ b/python/packages/autogen-studio/pyproject.toml @@ -36,7 +36,8 @@ dependencies = [ "autogen-core>=0.4.2,<0.5", "autogen-agentchat>=0.4.2,<0.5", "autogen-ext[magentic-one, openai, azure]>=0.4.2,<0.5", - "azure-identity" + "azure-identity", + "mcp", ] optional-dependencies = {web = ["fastapi", "uvicorn"], database = ["psycopg"]} @@ -89,3 +90,6 @@ fmt = "ruff format" format.ref = "fmt" lint = "ruff check" test = "pytest -n 0 --cov=autogenstudio --cov-report=term-missing" + +[tool.uv.sources] +mcp = { path = "../../../../../modelcontextprotocol/python-sdk" } diff --git a/python/uv.lock b/python/uv.lock index e8a07804e93d..a809a3b24096 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -593,6 +593,10 @@ magentic-one = [ { name = "pillow" }, { name = "playwright" }, ] +mcp = [ + { name = "json-schema-to-pydantic" }, + { name = "mcp" }, +] openai = [ { name = "aiofiles" }, { name = "openai" }, @@ -670,10 +674,12 @@ requires-dist = [ { name = "graphrag", marker = "extra == 'graphrag'", specifier = ">=1.0.1" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = "~=1.62.0" }, { name = "ipykernel", marker = "extra == 'jupyter-executor'", specifier = ">=6.29.5" }, + { name = "json-schema-to-pydantic", marker = "extra == 'mcp'", specifier = ">=0.2.0" }, { name = "langchain-core", marker = "extra == 'langchain'", specifier = "~=0.3.3" }, { name = "markitdown", marker = "extra == 'file-surfer'", specifier = ">=0.0.1a2" }, { name = "markitdown", marker = "extra == 'magentic-one'", specifier = ">=0.0.1a2" }, { name = "markitdown", marker = "extra == 'web-surfer'", specifier = ">=0.0.1a2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.1.3" }, { name = "nbclient", marker = "extra == 'jupyter-executor'", specifier = ">=0.10.2" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.52.2" }, { name = "openai-whisper", marker = "extra == 'video-surfer'" }, @@ -803,6 +809,7 @@ dependencies = [ { name = "azure-identity" }, { name = "fastapi" }, { name = "loguru" }, + { name = "mcp" }, { name = "numpy" }, { name = "psycopg" }, { name = "pydantic" }, @@ -835,6 +842,7 @@ requires-dist = [ { name = "fastapi" }, { name = "fastapi", marker = "extra == 'web'" }, { name = "loguru" }, + { name = "mcp", directory = "../../../modelcontextprotocol/python-sdk" }, { name = "numpy", specifier = "<2.0.0" }, { name = "psycopg" }, { name = "psycopg", marker = "extra == 'database'" }, @@ -1110,7 +1118,7 @@ wheels = [ [[package]] name = "chainlit" -version = "2.0.1" +version = "2.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -1136,9 +1144,9 @@ dependencies = [ { name = "uvicorn" }, { name = "watchfiles" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/15/26dc5f957c6344813b2ae8c6f52cc820a7074088509ea947da0cf76ffc5f/chainlit-2.0.1.tar.gz", hash = "sha256:9fb7728aa5704e823c5b5d51f570dcfabafdcc97c23a73e6047f65eb72c938e7", size = 4637433 } +sdist = { url = "https://files.pythonhosted.org/packages/1e/d8/7173caf3ca0d7480b3614e3126da9c592692d353764326fc0e1702b9eddd/chainlit-2.0.5.tar.gz", hash = "sha256:8af7746999d6641c69c33b67e5325e2d018432dd0b3306926d7435b862b0bfe2", size = 4646512 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/99/c63fa2e1d7b949c034b7fc838a0c00de22cd2cec30245e379c9dd15dedfd/chainlit-2.0.1-py3-none-any.whl", hash = "sha256:84982902c6f42a91ac341ea9b6d52e6b1348e53a60ee49b4ffe0e5e5be02f4ba", size = 4703745 }, + { url = "https://files.pythonhosted.org/packages/dc/01/8f02145330355e2802b95f835afb4cf11ea503b779cd6136892d4940abc5/chainlit-2.0.5-py3-none-any.whl", hash = "sha256:30cd2c39a9393de047b4e64b3dcf84ca4f691cb61445d59ae9a29f8e1f1af006", size = 4709971 }, ] [[package]] @@ -2735,6 +2743,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/2d/79a46330c4b97ee90dd403fb0d267da7b25b24d7db604c5294e5c57d5f7c/json_repair-0.30.3-py3-none-any.whl", hash = "sha256:63bb588162b0958ae93d85356ecbe54c06b8c33f8a4834f93fa2719ea669804e", size = 18951 }, ] +[[package]] +name = "json-schema-to-pydantic" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/87/af1870beea329744a759349b972b309f8c95ae21e986e387e19733b85cc9/json_schema_to_pydantic-0.2.2.tar.gz", hash = "sha256:685db8d93aa29ccd257b2803fcd9a956c527e5fb108a523cbfe8cac1239b3785", size = 34158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8d/3c738e4b4b041269c4a506544b90e9ba924bbd800c8d496ed3e5a6da0265/json_schema_to_pydantic-0.2.2-py3-none-any.whl", hash = "sha256:01b82d234f2b482a273e117e29d063b6b86021a250035873d6eec4b85b70e64d", size = 11396 }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -3156,7 +3176,7 @@ wheels = [ [[package]] name = "literalai" -version = "0.0.623" +version = "0.1.103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chevron" }, @@ -3164,7 +3184,7 @@ dependencies = [ { name = "packaging" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/af/07d943e62a1297a7b44777297c0dca8f4bfcd6ae18b9df7d3cd9c1970e29/literalai-0.0.623.tar.gz", hash = "sha256:d65c04dde6b1e99d585e4112a607e5fd574d282b70f600c55a671018340dfb0f", size = 57081 } +sdist = { url = "https://files.pythonhosted.org/packages/fc/fc/628b39e31b368aacbca51721ba7a66a4d140e9be916a0c7396664fdaed7a/literalai-0.1.103.tar.gz", hash = "sha256:060e86e63c0f53041a737b2183354ac092ee8cd9faec817dc95df639bb263a7d", size = 62540 } [[package]] name = "llama-cloud" @@ -3768,6 +3788,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, ] +[[package]] +name = "mcp" +version = "1.3.0.dev0" +source = { directory = "../../../modelcontextprotocol/python-sdk" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "httpx-sse", specifier = ">=0.4" }, + { name = "pydantic", specifier = ">=2.10.1,<3.0.0" }, + { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, + { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, + { name = "sse-starlette", specifier = ">=1.6.1" }, + { name = "starlette", specifier = ">=0.27" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.4" }, + { name = "uvicorn", specifier = ">=0.30" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.391" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-flakefinder", specifier = ">=1.1.0" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "ruff", specifier = ">=0.8.5" }, + { name = "trio", specifier = ">=0.26.2" }, +] + [[package]] name = "mdit-py-plugins" version = "0.4.2" @@ -6662,6 +6722,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/b1/3af5104b716c420e40a6ea1b09886cae3a1b9f4538343875f637755cae5b/sqlmodel-0.0.22-py3-none-any.whl", hash = "sha256:a1ed13e28a1f4057cbf4ff6cdb4fc09e85702621d3259ba17b3c230bfb2f941b", size = 28276 }, ] +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -7365,16 +7438,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.25.0" +version = "0.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/54/0eb4441bf38c70f6ed1886dddb2e29d1650026041d19e49fc373e332fa60/uvicorn-0.25.0.tar.gz", hash = "sha256:6dddbad1d7ee0f5140aba5ec138ddc9612c5109399903828b4874c9937f009c2", size = 40724 } +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/59/fddd9df489fe27f492cc97626e03663fb3b9b6ef7ce8597a7cdc5f2cbbad/uvicorn-0.25.0-py3-none-any.whl", hash = "sha256:ce107f5d9bd02b4636001a77a4e74aab5e1e2b146868ebbad565237145af444c", size = 60303 }, + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, ] [[package]] From 9c4a00b533354e1729734c2771934ce24ea68ada Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 15:57:20 +0000 Subject: [PATCH 02/22] remove MCP and add more http options --- python/packages/autogen-ext/pyproject.toml | 9 +- .../src/autogen_ext/tools/http/_http_tool.py | 28 ++- .../src/autogen_ext/tools/mcp/__init__.py | 3 - .../src/autogen_ext/tools/mcp/_mcp_tool.py | 174 ------------------ python/uv.lock | 12 +- 5 files changed, 36 insertions(+), 190 deletions(-) delete mode 100644 python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py delete mode 100644 python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py diff --git a/python/packages/autogen-ext/pyproject.toml b/python/packages/autogen-ext/pyproject.toml index 4eef314871f7..b914573fba82 100644 --- a/python/packages/autogen-ext/pyproject.toml +++ b/python/packages/autogen-ext/pyproject.toml @@ -106,14 +106,15 @@ semantic-kernel-dapr = [ "semantic-kernel[dapr]>=1.17.1", ] +http = [ + "httpx>=0.27.0", + "json-schema-to-pydantic>=0.2.0" +] + semantic-kernel-all = [ "semantic-kernel[google,hugging_face,mistralai,ollama,onnx,anthropic,usearch,pandas,aws,dapr]>=1.17.1", ] -mcp = [ - "mcp>=1.1.3", - "json-schema-to-pydantic>=0.2.0" -] rich = ["rich>=13.9.4"] [tool.hatch.build.targets.wheel] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 71967063fa0c..7dda63f42521 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -1,5 +1,4 @@ -import json -from typing import Any, Optional, Type +from typing import Any, Literal, Optional, Type import httpx from autogen_core import CancellationToken, Component @@ -21,6 +20,10 @@ class HttpToolConfig(BaseModel): """ The URL to send the request to. """ + method: Optional[Literal["GET", "POST", "PUT", "DELETE", "PATCH"]] = "POST" + """ + The HTTP method to use, will default to POST if not provided. + """ headers: Optional[dict[str, Any]] """ A dictionary of headers to send with the request. @@ -54,6 +57,15 @@ def __init__(self, server_params: HttpToolConfig) -> None: super().__init__(input_model, return_type, name, description) + def _to_config(self) -> HttpToolConfig: + copied_config = self.server_params.copy() + return copied_config + + @classmethod + def _from_config(cls, config: HttpToolConfig): + copied_config = config.model_copy().model_dump(exclude_none=True) + return cls(**copied_config) + async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: """Execute the MCP tool with the given arguments. @@ -69,6 +81,16 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A """ async with httpx.AsyncClient() as client: - response = await client.post(self.server_params.url, json=args.model_dump()) + match self.server_params.method: + case "GET": + response = await client.get(self.server_params.url, params=args.model_dump()) + case "PUT": + response = await client.put(self.server_params.url, json=args.model_dump()) + case "DELETE": + response = await client.delete(self.server_params.url, params=args.model_dump()) + case "PATCH": + response = await client.patch(self.server_params.url, json=args.model_dump()) + case _: # Default case + response = await client.post(self.server_params.url, json=args.model_dump()) return response.json() diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py deleted file mode 100644 index 556a1019e68d..000000000000 --- a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from ._mcp_tool import SseMcpTool, SseMcpToolBuilder, SseServerParameters, StdioMcpTool, StdioMcpToolBuilder - -__all__ = ["StdioMcpTool", "StdioMcpToolBuilder", "SseMcpTool", "SseMcpToolBuilder", "SseServerParameters"] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py deleted file mode 100644 index 6d464d3586f6..000000000000 --- a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py +++ /dev/null @@ -1,174 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Type - -from autogen_core import CancellationToken -from autogen_core.tools import BaseTool -from json_schema_to_pydantic import create_model -from mcp import ClientSession, StdioServerParameters, Tool, stdio_client -from mcp.client.sse import sse_client -from pydantic import BaseModel - - -class StdioMcpTool(BaseTool[BaseModel, Any]): - """Adapter for MCP tools to make them compatible with AutoGen. - - Args: - server_params (StdioServerParameters): Parameters for the MCP server connection - tool (Tool): The MCP tool to wrap - """ - - def __init__(self, server_params: StdioServerParameters, tool: Tool) -> None: - self._tool = tool - self.server_params = server_params - - # Extract name and description - name = tool.name - description = tool.description or "" - - # Validate and extract schema information with detailed errors - if tool.inputSchema is None: - raise ValueError(f"Tool {name} has no input schema defined") - - if not isinstance(tool.inputSchema, dict): - raise ValueError(f"Invalid input schema for tool {name}: expected dictionary, got {type(tool.inputSchema)}") - - # Create the input model from the tool's schema - input_model = create_model(tool.inputSchema) - - # Use Any as return type since MCP tool returns can vary - return_type: Type[Any] = object - - super().__init__(input_model, return_type, name, description) - - async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: - """Execute the MCP tool with the given arguments. - - Args: - args: The validated input arguments - cancellation_token: Token for cancelling the operation - - Returns: - The result from the MCP tool - - Raises: - Exception: If tool execution fails - """ - kwargs = args.model_dump() - - try: - async with stdio_client(self.server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - if cancellation_token.is_cancelled(): - raise Exception("Operation cancelled") - - result = await session.call_tool(self._tool.name, kwargs) - - if result.isError: - raise Exception(f"MCP tool execution failed: {result.content}") - return result.content - except Exception as e: - raise Exception(str(e)) from e - -class StdioMcpToolBuilder: - def __init__(self, server_params: StdioServerParameters, tool_name: str) -> None: - self.server_params: StdioServerParameters = server_params - self.tool_name: str = tool_name - - async def build(self) -> StdioMcpTool: - async with stdio_client(self.server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - tools_list = await session.list_tools() - for tool in tools_list.tools: - if tool.name == self.tool_name: - return StdioMcpTool(self.server_params, tool) - raise ValueError(f"Tool {self.tool_name} not found") - -class SseServerParameters(BaseModel): - url: str - headers: dict[str, Any] - - -class SseMcpTool(BaseTool[BaseModel, Any]): - """Adapter for MCP tools to make them compatible with AutoGen. - - Args: - server_params (SseServerParameters): Parameters for the MCP server connection - tool (Tool): The MCP tool to wrap - """ - - def __init__(self, server_params: SseServerParameters, tool: Tool) -> None: - self._tool = tool - self.server_params = server_params - - # Extract name and description - name = tool.name - description = tool.description or "" - - # Validate and extract schema information with detailed errors - if tool.inputSchema is None: - raise ValueError(f"Tool {name} has no input schema defined") - - if not isinstance(tool.inputSchema, dict): - raise ValueError(f"Invalid input schema for tool {name}: expected dictionary, got {type(tool.inputSchema)}") - - # Create the input model from the tool's schema - input_model = create_model(tool.inputSchema) - - # Use Any as return type since MCP tool returns can vary - return_type: Type[Any] = object - - super().__init__(input_model, return_type, name, description) - - async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: - """Execute the MCP tool with the given arguments. - - Args: - args: The validated input arguments - cancellation_token: Token for cancelling the operation - - Returns: - The result from the MCP tool - - Raises: - Exception: If tool execution fails - """ - kwargs = args.model_dump() - - try: - async with sse_client(self.server_params.url, headers=self.server_params.headers) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - if cancellation_token.is_cancelled(): - raise Exception("Operation cancelled") - - result = await session.call_tool(self._tool.name, kwargs) - - if result.isError: - raise Exception(f"MCP tool execution failed: {result.content}") - return result.content - except Exception as e: - raise Exception(str(e)) from e - - -class SseMcpToolBuilder: - def __init__(self, server_params: SseServerParameters, tool_name: str) -> None: - self.server_params = server_params - self.tool_name = tool_name - - async def build(self) -> SseMcpTool: - async with sse_client(self.server_params.url, headers=self.server_params.headers) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - tools_list = await session.list_tools() - for tool in tools_list.tools: - if tool.name == self.tool_name: - return SseMcpTool(self.server_params, tool) - - raise ValueError(f"Tool {self.tool_name} not found") - pass \ No newline at end of file diff --git a/python/uv.lock b/python/uv.lock index bf987fee5d8a..10714effc692 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -581,6 +581,10 @@ graphrag = [ grpc = [ { name = "grpcio" }, ] +http = [ + { name = "httpx" }, + { name = "json-schema-to-pydantic" }, +] jupyter-executor = [ { name = "ipykernel" }, { name = "nbclient" }, @@ -594,10 +598,6 @@ magentic-one = [ { name = "pillow" }, { name = "playwright" }, ] -mcp = [ - { name = "json-schema-to-pydantic" }, - { name = "mcp" }, -] openai = [ { name = "aiofiles" }, { name = "openai" }, @@ -678,13 +678,13 @@ requires-dist = [ { name = "ffmpeg-python", marker = "extra == 'video-surfer'" }, { name = "graphrag", marker = "extra == 'graphrag'", specifier = ">=1.0.1" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = "~=1.62.0" }, + { name = "httpx", marker = "extra == 'http'", specifier = ">=0.27.0" }, { name = "ipykernel", marker = "extra == 'jupyter-executor'", specifier = ">=6.29.5" }, - { name = "json-schema-to-pydantic", marker = "extra == 'mcp'", specifier = ">=0.2.0" }, + { name = "json-schema-to-pydantic", marker = "extra == 'http'", specifier = ">=0.2.0" }, { name = "langchain-core", marker = "extra == 'langchain'", specifier = "~=0.3.3" }, { name = "markitdown", marker = "extra == 'file-surfer'", specifier = ">=0.0.1a2" }, { name = "markitdown", marker = "extra == 'magentic-one'", specifier = ">=0.0.1a2" }, { name = "markitdown", marker = "extra == 'web-surfer'", specifier = ">=0.0.1a2" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.1.3" }, { name = "nbclient", marker = "extra == 'jupyter-executor'", specifier = ">=0.10.2" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.52.2" }, { name = "openai-whisper", marker = "extra == 'video-surfer'" }, From 95fc65e2979580f7ff661e11a19e9af0ea33c05d Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 22:40:01 +0000 Subject: [PATCH 03/22] unit tests are working --- .../src/autogen_ext/tools/http/__init__.py | 4 +- .../src/autogen_ext/tools/http/_http_tool.py | 95 ++++++++++-- .../autogen-ext/tests/tools/http/conftest.py | 89 +++++++++++ .../tests/tools/http/test_http_tool.py | 144 ++++++++++++++++++ 4 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 python/packages/autogen-ext/tests/tools/http/conftest.py create mode 100644 python/packages/autogen-ext/tests/tools/http/test_http_tool.py diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/__init__.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/__init__.py index 9aa4ee155551..6c276b625e3f 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/__init__.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/__init__.py @@ -1,3 +1,3 @@ -from ._http_tool import HttpTool, HttpToolConfig +from ._http_tool import HttpTool -__all__ = ["HttpTool", "HttpToolConfig"] +__all__ = ["HttpTool"] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 7dda63f42521..f9c690f6aeea 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -35,22 +35,91 @@ class HttpToolConfig(BaseModel): class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): - """Adapter for MCP tools to make them compatible with AutoGen. + """A wrapper for using an HTTP server as a tool. Args: - server_params (StdioServerParameters): Parameters for the MCP server connection - tool (Tool): The MCP tool to wrap + name (str): The name of the tool. + description (str, optional): A description of the tool. + url (str): The URL to send the request to. + method (str, optional): The HTTP method to use, will default to POST if not provided. + Must be one of "GET", "POST", "PUT", "DELETE", "PATCH". + headers (dict[str, Any], optional): A dictionary of headers to send with the request. + json_schema (dict[str, Any]): A JSON Schema object defining the expected parameters for the tool. + + Example: + Simple usage case:: + + import asyncio + from autogen_ext.tools.http import HttpTool + from autogen_agentchat.agents import AssistantAgent + from autogen_ext.models.openai import OpenAIChatCompletionClient + + # Define a JSON schema for a weather API + weather_schema = { + "type": "object", + "properties": { + "city": {"type": "string", "description": "The city to get weather for"}, + "country": {"type": "string", "description": "The country code"} + }, + "required": ["city"] + } + + # Create an HTTP tool for the weather API + weather_tool = HttpTool( + name="get_weather", + description="Get the current weather for a city", + url="https://api.weatherapi.com/v1/current.json", + method="GET", + headers={"key": "your-api-key"}, + json_schema=weather_schema + ) + + async def main(): + # Create an assistant with the weather tool + model = OpenAIChatCompletionClient(model="gpt-4") + assistant = AssistantAgent( + "weather_assistant", + model_client=model, + tools=[weather_tool] + ) + + # The assistant can now use the weather tool to get weather data + response = await assistant.on_messages([ + TextMessage(content="What's the weather like in London?") + ]) + print(response.chat_message.content) + + asyncio.run(main()) """ - def __init__(self, server_params: HttpToolConfig) -> None: - self.server_params = server_params + component_type = "agent" + component_provider_override = "autogen_ext.tools.http.HttpTool" + component_config_schema = HttpToolConfig + + def __init__( + self, + name: str, + url: str, + json_schema: dict[str, Any], + headers: Optional[dict[str, Any]], + description: str = "HTTP tool", + method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] = "POST", + ) -> None: + self.server_params = HttpToolConfig( + name=name, + description=description, + url=url, + method=method, + headers=headers, + json_schema=json_schema, + ) # Extract name and description - name = server_params.name - description = server_params.description or "" + name = self.server_params.name + description = self.server_params.description or "" # Create the input model from the tool's schema - input_model = create_model(server_params.json_schema) + input_model = create_model(self.server_params.json_schema) # Use Any as return type since MCP tool returns can vary return_type: Type[Any] = object @@ -58,23 +127,23 @@ def __init__(self, server_params: HttpToolConfig) -> None: super().__init__(input_model, return_type, name, description) def _to_config(self) -> HttpToolConfig: - copied_config = self.server_params.copy() + copied_config = self.server_params.model_copy() return copied_config @classmethod def _from_config(cls, config: HttpToolConfig): - copied_config = config.model_copy().model_dump(exclude_none=True) + copied_config = config.model_copy().model_dump() return cls(**copied_config) async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: - """Execute the MCP tool with the given arguments. + """Execute the HTTO tool with the given arguments. Args: args: The validated input arguments cancellation_token: Token for cancelling the operation Returns: - The result from the MCP tool + The response body from the HTTP call in JSON format Raises: Exception: If tool execution fails @@ -90,7 +159,7 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A response = await client.delete(self.server_params.url, params=args.model_dump()) case "PATCH": response = await client.patch(self.server_params.url, json=args.model_dump()) - case _: # Default case + case _: # Default case response = await client.post(self.server_params.url, json=args.model_dump()) return response.json() diff --git a/python/packages/autogen-ext/tests/tools/http/conftest.py b/python/packages/autogen-ext/tests/tools/http/conftest.py new file mode 100644 index 000000000000..43708b712541 --- /dev/null +++ b/python/packages/autogen-ext/tests/tools/http/conftest.py @@ -0,0 +1,89 @@ +import asyncio +from typing import AsyncGenerator + +import pytest +import pytest_asyncio +import uvicorn +from autogen_core import CancellationToken, ComponentModel +from autogen_ext.tools.http import HttpTool +from fastapi import Body, FastAPI +from pydantic import BaseModel, Field + + +class TestArgs(BaseModel): + query: str = Field(description="The test query") + value: int = Field(description="A test value") + + +class TestResponse(BaseModel): + result: str = Field(description="The test result") + + +# Create a test FastAPI app +app = FastAPI() + + +@app.post("/test") +async def test_endpoint(body: TestArgs = Body(...)) -> TestResponse: + return TestResponse(result=f"Received: {body.query} with value {body.value}") + + +@app.get("/test") +async def test_get_endpoint(query: str, value: int) -> TestResponse: + return TestResponse(result=f"Received: {query} with value {value}") + + +@app.put("/test") +async def test_put_endpoint(body: TestArgs = Body(...)) -> TestResponse: + return TestResponse(result=f"Received: {body.query} with value {body.value}") + + +@app.delete("/test") +async def test_delete_endpoint(query: str, value: int) -> TestResponse: + return TestResponse(result=f"Received: {query} with value {value}") + + +@app.patch("/test") +async def test_patch_endpoint(body: TestArgs = Body(...)) -> TestResponse: + return TestResponse(result=f"Received: {body.query} with value {body.value}") + + +@pytest.fixture +def test_config() -> ComponentModel: + return ComponentModel( + provider="autogen_ext.tools.http.HttpTool", + config={ + "name": "TestHttpTool", + "description": "A test HTTP tool", + "url": "http://localhost:8000/test", + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "json_schema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The test query"}, + "value": {"type": "integer", "description": "A test value"}, + }, + "required": ["query", "value"], + }, + }, + ) + + +@pytest_asyncio.fixture +async def test_server() -> AsyncGenerator[None, None]: + # Start the test server + config = uvicorn.Config(app, host="127.0.0.1", port=8000, log_level="error") + server = uvicorn.Server(config) + + # Create a task for the server + server_task = asyncio.create_task(server.serve()) + + # Wait a bit for server to start + await asyncio.sleep(0.5) # Increased sleep time to ensure server is ready + + yield + + # Cleanup + server.should_exit = True + await server_task diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py new file mode 100644 index 000000000000..70e189f8c590 --- /dev/null +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -0,0 +1,144 @@ +import pytest +import httpx +from pydantic import ValidationError +from autogen_core import CancellationToken +from autogen_ext.tools.http import HttpTool +from autogen_core import Component, ComponentModel + + +def test_tool_schema_generation(test_config: ComponentModel) -> None: + tool = HttpTool.load_component(test_config) + schema = tool.schema + + assert schema["name"] == "TestHttpTool" + assert "description" in schema + assert schema["description"] == "A test HTTP tool" + assert "parameters" in schema + assert schema["parameters"]["type"] == "object" + assert "properties" in schema["parameters"] + assert schema["parameters"]["properties"]["query"]["description"] == "The test query" + assert schema["parameters"]["properties"]["query"]["type"] == "string" + assert schema["parameters"]["properties"]["value"]["description"] == "A test value" + assert schema["parameters"]["properties"]["value"]["type"] == "integer" + assert "required" in schema["parameters"] + assert set(schema["parameters"]["required"]) == {"query", "value"} + + +def test_tool_properties(test_config: ComponentModel) -> None: + tool = HttpTool.load_component(test_config) + + assert tool.name == "TestHttpTool" + assert tool.description == "A test HTTP tool" + assert tool.server_params.url == "http://localhost:8000/test" + assert tool.server_params.method == "POST" + + +def test_component_base_class(test_config: ComponentModel) -> None: + tool = HttpTool.load_component(test_config) + assert tool.dump_component() is not None + assert HttpTool.load_component(tool.dump_component(), HttpTool) is not None + assert isinstance(tool, Component) + + +@pytest.mark.asyncio +async def test_post_request(test_config: ComponentModel, test_server: None) -> None: + tool = HttpTool.load_component(test_config) + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + + +@pytest.mark.asyncio +async def test_get_request(test_config: ComponentModel, test_server: None) -> None: + # Modify config for GET request + config = test_config.model_copy() + config.config["method"] = "GET" + tool = HttpTool.load_component(config) + + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + + +@pytest.mark.asyncio +async def test_put_request(test_config: ComponentModel, test_server: None) -> None: + # Modify config for PUT request + config = test_config.model_copy() + config.config["method"] = "PUT" + tool = HttpTool.load_component(config) + + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + + +@pytest.mark.asyncio +async def test_delete_request(test_config: ComponentModel, test_server: None) -> None: + # Modify config for DELETE request + config = test_config.model_copy() + config.config["method"] = "DELETE" + tool = HttpTool.load_component(config) + + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + + +@pytest.mark.asyncio +async def test_patch_request(test_config: ComponentModel, test_server: None) -> None: + # Modify config for PATCH request + config = test_config.model_copy() + config.config["method"] = "PATCH" + tool = HttpTool.load_component(config) + + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + + +@pytest.mark.asyncio +async def test_invalid_schema(test_config: ComponentModel, test_server: None) -> None: + # Create an invalid schema missing required properties + config: ComponentModel = test_config.model_copy() + config.config["url"] = True # Incorrect type + + with pytest.raises(ValidationError): + # Should fail when trying to create model from invalid schema + HttpTool.load_component(config) + + +@pytest.mark.asyncio +async def test_invalid_request(test_config: ComponentModel, test_server: None) -> None: + # Use an invalid URL + config = test_config.model_copy() + config.config["url"] = "http://fake:8000/nonexistent" + tool = HttpTool.load_component(config) + + with pytest.raises(httpx.ConnectError): + await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + +def test_config_serialization(test_config: ComponentModel) -> None: + tool = HttpTool.load_component(test_config) + config = tool._to_config() + + assert config.name == test_config.config["name"] + assert config.description == test_config.config["description"] + assert config.url == test_config.config["url"] + assert config.method == test_config.config["method"] + assert config.headers == test_config.config["headers"] + + +def test_config_deserialization(test_config: ComponentModel) -> None: + tool = HttpTool.load_component(test_config) + + assert tool.name == test_config.config["name"] + assert tool.description == test_config.config["description"] + assert tool.server_params.url == test_config.config["url"] + assert tool.server_params.method == test_config.config["method"] + assert tool.server_params.headers == test_config.config["headers"] From 7b644e2c3a98b14964e8b1e347d3c1eaefdb3655 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 23:27:00 +0000 Subject: [PATCH 04/22] works again with path params stuff --- .../src/autogen_ext/tools/http/_http_tool.py | 76 ++++++++++++++----- .../autogen-ext/tests/tools/http/conftest.py | 5 +- .../tests/tools/http/test_http_tool.py | 19 +++-- 3 files changed, 75 insertions(+), 25 deletions(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index f9c690f6aeea..0d19a8413437 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -1,10 +1,12 @@ +import re from typing import Any, Literal, Optional, Type +import urllib.parse import httpx from autogen_core import CancellationToken, Component from autogen_core.tools import BaseTool from json_schema_to_pydantic import create_model -from pydantic import BaseModel +from pydantic import BaseModel, Field class HttpToolConfig(BaseModel): @@ -16,10 +18,24 @@ class HttpToolConfig(BaseModel): """ A description of the tool. """ - url: str + scheme: Literal["http", "https"] = "http" + """ + The scheme to use for the request. + """ + host: str """ The URL to send the request to. """ + port: int + """ + The port to send the request to. + """ + path: str = Field(default="/") + """ + The path to send the request to. defaults to "/" + The path can accept parameters, e.g. "/{param1}/{param2}". + These parameters will be templated from the inputs args, any additional parameters will be added as query parameters or the body of the request. + """ method: Optional[Literal["GET", "POST", "PUT", "DELETE", "PATCH"]] = "POST" """ The HTTP method to use, will default to POST if not provided. @@ -31,6 +47,7 @@ class HttpToolConfig(BaseModel): json_schema: dict[str, Any] """ A JSON Schema object defining the expected parameters for the tool. + Path parameters MUST also be included in the json_schema. They must also MUST be set to string """ @@ -47,7 +64,7 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): json_schema (dict[str, Any]): A JSON Schema object defining the expected parameters for the tool. Example: - Simple usage case:: + Simple use case:: import asyncio from autogen_ext.tools.http import HttpTool @@ -70,7 +87,7 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): description="Get the current weather for a city", url="https://api.weatherapi.com/v1/current.json", method="GET", - headers={"key": "your-api-key"}, + headers={"key": "your-api-key"}, # Replace with your API key json_schema=weather_schema ) @@ -99,29 +116,35 @@ async def main(): def __init__( self, name: str, - url: str, + host: str, + port: int, json_schema: dict[str, Any], headers: Optional[dict[str, Any]], description: str = "HTTP tool", + path: str = "/", + scheme: Literal["http", "https"] = "http", method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] = "POST", ) -> None: self.server_params = HttpToolConfig( name=name, description=description, - url=url, + host=host, + port=port, + path=path, + scheme=scheme, method=method, headers=headers, json_schema=json_schema, ) - # Extract name and description - name = self.server_params.name - description = self.server_params.description or "" + # Use regex to find all path parameters, we will need those later to template the path + path_params = {match.group(1) for match in re.finditer(r"{([^}]*)}", path)} + self._path_params = path_params - # Create the input model from the tool's schema - input_model = create_model(self.server_params.json_schema) + # Create the input model from the modified schema + input_model = create_model(json_schema) - # Use Any as return type since MCP tool returns can vary + # Use Any as return type since HTTP responses can vary return_type: Type[Any] = object super().__init__(input_model, return_type, name, description) @@ -136,7 +159,7 @@ def _from_config(cls, config: HttpToolConfig): return cls(**copied_config) async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: - """Execute the HTTO tool with the given arguments. + """Execute the HTTP tool with the given arguments. Args: args: The validated input arguments @@ -149,17 +172,32 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A Exception: If tool execution fails """ + + model_dump = args.model_dump() + path_params = {k: v for k, v in model_dump.items() if k in self._path_params} + # Remove path params from the model dump + for k in self._path_params: + model_dump.pop(k) + + path = self.server_params.path.format(**path_params) + + url = httpx.URL( + scheme=self.server_params.scheme, + host=self.server_params.host, + port=self.server_params.port, + path=path, + ) async with httpx.AsyncClient() as client: match self.server_params.method: case "GET": - response = await client.get(self.server_params.url, params=args.model_dump()) + response = await client.get(url, params=model_dump) case "PUT": - response = await client.put(self.server_params.url, json=args.model_dump()) + response = await client.put(url, json=model_dump) case "DELETE": - response = await client.delete(self.server_params.url, params=args.model_dump()) + response = await client.delete(url, params=model_dump) case "PATCH": - response = await client.patch(self.server_params.url, json=args.model_dump()) - case _: # Default case - response = await client.post(self.server_params.url, json=args.model_dump()) + response = await client.patch(url, json=model_dump) + case _: # Default case POST + response = await client.post(url, json=model_dump) return response.json() diff --git a/python/packages/autogen-ext/tests/tools/http/conftest.py b/python/packages/autogen-ext/tests/tools/http/conftest.py index 43708b712541..895e3f5dd817 100644 --- a/python/packages/autogen-ext/tests/tools/http/conftest.py +++ b/python/packages/autogen-ext/tests/tools/http/conftest.py @@ -55,7 +55,10 @@ def test_config() -> ComponentModel: config={ "name": "TestHttpTool", "description": "A test HTTP tool", - "url": "http://localhost:8000/test", + "scheme": "http", + "path": "/test", + "host": "localhost", + "port": 8000, "method": "POST", "headers": {"Content-Type": "application/json"}, "json_schema": { diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py index 70e189f8c590..0e6c9736b0f7 100644 --- a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -29,7 +29,10 @@ def test_tool_properties(test_config: ComponentModel) -> None: assert tool.name == "TestHttpTool" assert tool.description == "A test HTTP tool" - assert tool.server_params.url == "http://localhost:8000/test" + assert tool.server_params.host == "localhost" + assert tool.server_params.port == 8000 + assert tool.server_params.path == "/test" + assert tool.server_params.scheme == "http" assert tool.server_params.method == "POST" @@ -105,7 +108,7 @@ async def test_patch_request(test_config: ComponentModel, test_server: None) -> async def test_invalid_schema(test_config: ComponentModel, test_server: None) -> None: # Create an invalid schema missing required properties config: ComponentModel = test_config.model_copy() - config.config["url"] = True # Incorrect type + config.config["host"] = True # Incorrect type with pytest.raises(ValidationError): # Should fail when trying to create model from invalid schema @@ -116,7 +119,7 @@ async def test_invalid_schema(test_config: ComponentModel, test_server: None) -> async def test_invalid_request(test_config: ComponentModel, test_server: None) -> None: # Use an invalid URL config = test_config.model_copy() - config.config["url"] = "http://fake:8000/nonexistent" + config.config["host"] = "fake" tool = HttpTool.load_component(config) with pytest.raises(httpx.ConnectError): @@ -129,7 +132,10 @@ def test_config_serialization(test_config: ComponentModel) -> None: assert config.name == test_config.config["name"] assert config.description == test_config.config["description"] - assert config.url == test_config.config["url"] + assert config.host == test_config.config["host"] + assert config.port == test_config.config["port"] + assert config.path == test_config.config["path"] + assert config.scheme == test_config.config["scheme"] assert config.method == test_config.config["method"] assert config.headers == test_config.config["headers"] @@ -139,6 +145,9 @@ def test_config_deserialization(test_config: ComponentModel) -> None: assert tool.name == test_config.config["name"] assert tool.description == test_config.config["description"] - assert tool.server_params.url == test_config.config["url"] + assert tool.server_params.host == test_config.config["host"] + assert tool.server_params.port == test_config.config["port"] + assert tool.server_params.path == test_config.config["path"] + assert tool.server_params.scheme == test_config.config["scheme"] assert tool.server_params.method == test_config.config["method"] assert tool.server_params.headers == test_config.config["headers"] From 873ac5c6bb4b8b069090c67c43c0056642bcb30a Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 23:33:06 +0000 Subject: [PATCH 05/22] add unit tests for the new path params stuff --- .../autogen-ext/tests/tools/http/conftest.py | 11 ++++++ .../tests/tools/http/test_http_tool.py | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/python/packages/autogen-ext/tests/tools/http/conftest.py b/python/packages/autogen-ext/tests/tools/http/conftest.py index 895e3f5dd817..fd3c5e4a537d 100644 --- a/python/packages/autogen-ext/tests/tools/http/conftest.py +++ b/python/packages/autogen-ext/tests/tools/http/conftest.py @@ -27,6 +27,17 @@ class TestResponse(BaseModel): async def test_endpoint(body: TestArgs = Body(...)) -> TestResponse: return TestResponse(result=f"Received: {body.query} with value {body.value}") +@app.post("/test/{query}/{value}") +async def test_path_params_endpoint(query: str, value: int) -> TestResponse: + return TestResponse(result=f"Received: {query} with value {value}") + +@app.put("/test/{query}/{value}") +async def test_path_params_and_body_endpoint( + query: str, + value: int, + body: dict = Body(...) +) -> TestResponse: + return TestResponse(result=f"Received: {query} with value {value} and extra {body.get("extra")}") @app.get("/test") async def test_get_endpoint(query: str, value: int) -> TestResponse: diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py index 0e6c9736b0f7..a64bb09fd067 100644 --- a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -77,6 +77,45 @@ async def test_put_request(test_config: ComponentModel, test_server: None) -> No assert isinstance(result, dict) assert result["result"] == "Received: test query with value 42" +@pytest.mark.asyncio +async def test_path_params(test_config: ComponentModel, test_server: None) -> None: + # Modify config to use path parameters + config = test_config.model_copy() + config.config["path"] = "/test/{query}/{value}" + tool = HttpTool.load_component(config) + + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + +@pytest.mark.asyncio +async def test_path_params_and_body(test_config: ComponentModel, test_server: None) -> None: + # Modify config to use path parameters and include body parameters + config = test_config.model_copy() + config.config["method"] = "PUT" + config.config["path"] = "/test/{query}/{value}" + config.config["json_schema"] = { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The test query"}, + "value": {"type": "integer", "description": "A test value"}, + "extra": {"type": "string", "description": "Extra body parameter"} + }, + "required": ["query", "value", "extra"] + } + tool = HttpTool.load_component(config) + + result = await tool.run_json({ + "query": "test query", + "value": 42, + "extra": "extra data" + }, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42 and extra extra data" + + @pytest.mark.asyncio async def test_delete_request(test_config: ComponentModel, test_server: None) -> None: From dfb9b50a760f161cc7e8d9319e0a4287d0498a3f Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 23:51:58 +0000 Subject: [PATCH 06/22] unit tests now fully passing with params, example works with httpbin base64 decode --- .../src/autogen_ext/tools/http/_http_tool.py | 98 ++++++++++--------- .../tests/tools/http/test_http_tool.py | 30 +++--- 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 0d19a8413437..9c2fe21c1301 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -1,4 +1,5 @@ import re +import json from typing import Any, Literal, Optional, Type import urllib.parse @@ -66,50 +67,54 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): Example: Simple use case:: - import asyncio - from autogen_ext.tools.http import HttpTool - from autogen_agentchat.agents import AssistantAgent - from autogen_ext.models.openai import OpenAIChatCompletionClient - - # Define a JSON schema for a weather API - weather_schema = { - "type": "object", - "properties": { - "city": {"type": "string", "description": "The city to get weather for"}, - "country": {"type": "string", "description": "The country code"} - }, - "required": ["city"] - } - - # Create an HTTP tool for the weather API - weather_tool = HttpTool( - name="get_weather", - description="Get the current weather for a city", - url="https://api.weatherapi.com/v1/current.json", - method="GET", - headers={"key": "your-api-key"}, # Replace with your API key - json_schema=weather_schema - ) - - async def main(): - # Create an assistant with the weather tool - model = OpenAIChatCompletionClient(model="gpt-4") - assistant = AssistantAgent( - "weather_assistant", - model_client=model, - tools=[weather_tool] - ) - - # The assistant can now use the weather tool to get weather data - response = await assistant.on_messages([ - TextMessage(content="What's the weather like in London?") - ]) - print(response.chat_message.content) - - asyncio.run(main()) - """ - - component_type = "agent" + import asyncio + + from autogen_agentchat.agents import AssistantAgent + from autogen_agentchat.messages import TextMessage + from autogen_core import CancellationToken + from autogen_ext.models.openai import OpenAIChatCompletionClient + from autogen_ext.tools.http import HttpTool + + # Define a JSON schema for a base64 decode tool + base64_schema = { + "type": "object", + "properties": { + "value": {"type": "string", "description": "The base64 value to decode"}, + }, + "required": ["value"] + } + + # Create an HTTP tool for the weather API + base64_tool = HttpTool( + name="base64_decode", + description="base64 decode a value", + scheme="https", + host="httpbin.org", + port=443, + path="/base64/{value}", + method="GET", + json_schema=base64_schema + ) + + async def main(): + # Create an assistant with the base64 tool + model = OpenAIChatCompletionClient(model="gpt-4") + assistant = AssistantAgent( + "base64_assistant", + model_client=model, + tools=[base64_tool] + ) + + # The assistant can now use the base64 tool to decode the string + response = await assistant.on_messages([ + TextMessage(content="Can you base64 decode the value 'YWJjZGU=', please?", source="user") + ], CancellationToken()) + print(response.chat_message.content) + + asyncio.run(main()) + """ + + component_type = "tool" component_provider_override = "autogen_ext.tools.http.HttpTool" component_config_schema = HttpToolConfig @@ -119,7 +124,7 @@ def __init__( host: str, port: int, json_schema: dict[str, Any], - headers: Optional[dict[str, Any]], + headers: Optional[dict[str, Any]] = None, description: str = "HTTP tool", path: str = "/", scheme: Literal["http", "https"] = "http", @@ -200,4 +205,5 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A case _: # Default case POST response = await client.post(url, json=model_dump) - return response.json() + # TODO: (EItanya): Think about adding the ability to parse the response as JSON, or check a schema + return response.text diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py index a64bb09fd067..0742b6c87bde 100644 --- a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -1,3 +1,4 @@ +import json import pytest import httpx from pydantic import ValidationError @@ -48,8 +49,8 @@ async def test_post_request(test_config: ComponentModel, test_server: None) -> N tool = HttpTool.load_component(test_config) result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio @@ -61,8 +62,8 @@ async def test_get_request(test_config: ComponentModel, test_server: None) -> No result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio @@ -74,8 +75,8 @@ async def test_put_request(test_config: ComponentModel, test_server: None) -> No result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio async def test_path_params(test_config: ComponentModel, test_server: None) -> None: @@ -86,8 +87,8 @@ async def test_path_params(test_config: ComponentModel, test_server: None) -> No result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio async def test_path_params_and_body(test_config: ComponentModel, test_server: None) -> None: @@ -112,8 +113,9 @@ async def test_path_params_and_body(test_config: ComponentModel, test_server: No "extra": "extra data" }, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42 and extra extra data" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42 and extra extra data" + @@ -126,8 +128,8 @@ async def test_delete_request(test_config: ComponentModel, test_server: None) -> result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio @@ -139,8 +141,8 @@ async def test_patch_request(test_config: ComponentModel, test_server: None) -> result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio From 7395c0a351ce6541ada736a1214fb76c00c887dd Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 23:53:28 +0000 Subject: [PATCH 07/22] update docs for args --- .../autogen-ext/src/autogen_ext/tools/http/_http_tool.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 9c2fe21c1301..a9ccada8521a 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -58,11 +58,16 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): Args: name (str): The name of the tool. description (str, optional): A description of the tool. - url (str): The URL to send the request to. + scheme (str): The scheme to use for the request. Must be either "http" or "https". + host (str): The host to send the request to. + port (int): The port to send the request to. + path (str, optional): The path to send the request to. Defaults to "/". + Can include path parameters like "/{param1}/{param2}" which will be templated from input args. method (str, optional): The HTTP method to use, will default to POST if not provided. Must be one of "GET", "POST", "PUT", "DELETE", "PATCH". headers (dict[str, Any], optional): A dictionary of headers to send with the request. json_schema (dict[str, Any]): A JSON Schema object defining the expected parameters for the tool. + Path parameters must also be included in the schema and must be strings. Example: Simple use case:: From 98b05190f80542c1a52a7903fedc566bc7accf4e Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 23:54:45 +0000 Subject: [PATCH 08/22] revert autogen studio --- .../database/component_factory.py | 28 ++++--------------- .../autogenstudio/datamodel/types.py | 23 ++------------- .../autogen-studio/notebooks/team.json | 15 ++++------ python/packages/autogen-studio/pyproject.toml | 3 +- 4 files changed, 14 insertions(+), 55 deletions(-) diff --git a/python/packages/autogen-studio/autogenstudio/database/component_factory.py b/python/packages/autogen-studio/autogenstudio/database/component_factory.py index 782ed94dbd45..b954b39c0f4b 100644 --- a/python/packages/autogen-studio/autogenstudio/database/component_factory.py +++ b/python/packages/autogen-studio/autogenstudio/database/component_factory.py @@ -17,13 +17,11 @@ TimeoutTermination, TokenUsageTermination, ) -from mcp import StdioServerParameters from autogen_agentchat.teams import MagenticOneGroupChat, RoundRobinGroupChat, SelectorGroupChat from autogen_core.tools import FunctionTool from autogen_ext.agents.file_surfer import FileSurfer from autogen_ext.agents.magentic_one import MagenticOneCoderAgent from autogen_ext.agents.web_surfer import MultimodalWebSurfer -from autogen_ext.tools.mcp import StdioMcpTool, StdioMcpToolBuilder from autogen_ext.models.openai import AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient from ..datamodel.types import ( @@ -49,9 +47,6 @@ TerminationTypes, TextMentionTerminationConfig, ToolConfig, - PythonFunctionToolConfig, - StdioMcpToolConfig, - SseMcpToolConfig, ToolTypes, UserProxyAgentConfig, ) @@ -62,7 +57,7 @@ TeamComponent = Union[RoundRobinGroupChat, SelectorGroupChat, MagenticOneGroupChat] AgentComponent = Union[AssistantAgent, MultimodalWebSurfer, UserProxyAgent, FileSurfer, MagenticOneCoderAgent] ModelComponent = Union[OpenAIChatCompletionClient, AzureOpenAIChatCompletionClient] -ToolComponent = Union[FunctionTool, StdioMcpTool] # Will grow with more tool types +ToolComponent = Union[FunctionTool] # Will grow with more tool types TerminationComponent = Union[ MaxMessageTermination, StopMessageTermination, @@ -330,8 +325,7 @@ async def load_team(self, config: TeamConfig, input_func: Optional[Callable] = N async def load_agent(self, config: AgentConfig, input_func: Optional[Callable] = None) -> AgentComponent: """Create agent instance from configuration.""" - logger.error(f"Loading agent {config.name}") - logger.error(f"Config: {config}") + model_client = None system_message = None tools = [] @@ -341,8 +335,6 @@ async def load_agent(self, config: AgentConfig, input_func: Optional[Callable] = model_client = await self.load(config.model_client) if hasattr(config, "tools") and config.tools: for tool_config in config.tools: - logger.error(f"Loading tool {tool_config.name}") - logger.error(f"Config: {tool_config}") tool = await self.load(tool_config) tools.append(tool) @@ -432,9 +424,11 @@ async def load_model(self, config: ModelConfig) -> ModelComponent: async def load_tool(self, config: ToolConfig) -> ToolComponent: """Create tool instance from configuration.""" - logger.error(f"Loading tool {config.name}") - logger.error(f"Config: {config}") try: + # Validate required fields + if not all([config.name, config.description, config.content, config.tool_type]): + raise ValueError("Tool configuration missing required fields") + # Check cache first cache_key = str(config.model_dump()) if cache_key in self._tool_cache: @@ -442,21 +436,11 @@ async def load_tool(self, config: ToolConfig) -> ToolComponent: return self._tool_cache[cache_key] if config.tool_type == ToolTypes.PYTHON_FUNCTION: - # Validate required fields - if not all([config.name, config.description, config.content, config.tool_type]): - raise ValueError("Tool configuration missing required fields") tool = FunctionTool( name=config.name, description=config.description, func=self._func_from_string(config.content) ) self._tool_cache[cache_key] = tool return tool - elif config.tool_type == ToolTypes.MCP_STDIO_CLIENT: - cfg = StdioServerParameters( - command=config.command, - args=config.args, - ) - builder = StdioMcpToolBuilder(server_params=cfg, tool_name=config.name) - return await builder.build() else: raise ValueError(f"Unsupported tool type: {config.tool_type}") diff --git a/python/packages/autogen-studio/autogenstudio/datamodel/types.py b/python/packages/autogen-studio/autogenstudio/datamodel/types.py index 682303e368f4..eb02fb121ebe 100644 --- a/python/packages/autogen-studio/autogenstudio/datamodel/types.py +++ b/python/packages/autogen-studio/autogenstudio/datamodel/types.py @@ -15,8 +15,6 @@ class ModelTypes(str, Enum): class ToolTypes(str, Enum): PYTHON_FUNCTION = "PythonFunction" - MCP_STDIO_CLIENT = "McpStdioClient" - MCP_SSE_CLIENT = "McpSseClient" class AgentTypes(str, Enum): @@ -86,31 +84,14 @@ class AzureOpenAIModelConfig(BaseModelConfig): ModelConfig = OpenAIModelConfig | AzureOpenAIModelConfig -class BaseToolConfig(BaseConfig): +class ToolConfig(BaseConfig): name: str description: str + content: str tool_type: ToolTypes component_type: ComponentTypes = ComponentTypes.TOOL -class PythonFunctionToolConfig(BaseToolConfig): - content: str - tool_type: ToolTypes = ToolTypes.PYTHON_FUNCTION - -class StdioMcpToolConfig(BaseToolConfig): - command: str - args: List[str] - tool_type: ToolTypes = ToolTypes.MCP_STDIO_CLIENT - - -class SseMcpToolConfig(BaseToolConfig): - url: str - headers: Dict[str, Any] - tool_type: ToolTypes = ToolTypes.MCP_SSE_CLIENT - -ToolConfig = PythonFunctionToolConfig | StdioMcpToolConfig | SseMcpToolConfig - - class BaseAgentConfig(BaseConfig): name: str agent_type: AgentTypes diff --git a/python/packages/autogen-studio/notebooks/team.json b/python/packages/autogen-studio/notebooks/team.json index cdbf22f87806..5fb43ea26cb2 100644 --- a/python/packages/autogen-studio/notebooks/team.json +++ b/python/packages/autogen-studio/notebooks/team.json @@ -1,9 +1,9 @@ { - "name": "filesystem_team", + "name": "weather_team", "component_type": "team", "participants": [ { - "name": "filesystem_agent", + "name": "writing_agent", "component_type": "agent", "model_client": { "model": "gpt-4o-2024-08-06", @@ -14,14 +14,9 @@ { "name": "get_weather", "description": "Get the weather for a city", - "tool_type": "McpStdioClient", - "component_type": "tool", - "command": "npx", - "args":[ - "-y", - "@modelcontextprotocol/server-filesystem", - "/home/eitanyarmush" - ] + "content": "async def get_weather(city: str) -> str:\n return f\"The weather in {city} is 73 degrees and Sunny.\"", + "tool_type": "PythonFunction", + "component_type": "tool" } ], "agent_type": "AssistantAgent" diff --git a/python/packages/autogen-studio/pyproject.toml b/python/packages/autogen-studio/pyproject.toml index 267ce6ff0c9f..a8d6911d3e26 100644 --- a/python/packages/autogen-studio/pyproject.toml +++ b/python/packages/autogen-studio/pyproject.toml @@ -36,8 +36,7 @@ dependencies = [ "autogen-core>=0.4.2,<0.5", "autogen-agentchat>=0.4.2,<0.5", "autogen-ext[magentic-one, openai, azure]>=0.4.2,<0.5", - "azure-identity", - "mcp", + "azure-identity" ] optional-dependencies = {web = ["fastapi", "uvicorn"], database = ["psycopg"]} From 4644bc819bad823a17654eb279d234487a27c273 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sun, 26 Jan 2025 02:54:10 +0000 Subject: [PATCH 09/22] formatting --- .../autogen-ext/src/autogen_ext/tools/http/_http_tool.py | 2 -- .../autogen-ext/tests/tools/http/test_http_tool.py | 8 ++++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index a9ccada8521a..8aa1d224d94e 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -1,7 +1,5 @@ import re -import json from typing import Any, Literal, Optional, Type -import urllib.parse import httpx from autogen_core import CancellationToken, Component diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py index 0742b6c87bde..e8055b2bfbf1 100644 --- a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -1,10 +1,10 @@ import json -import pytest + import httpx -from pydantic import ValidationError -from autogen_core import CancellationToken +import pytest +from autogen_core import CancellationToken, Component, ComponentModel from autogen_ext.tools.http import HttpTool -from autogen_core import Component, ComponentModel +from pydantic import ValidationError def test_tool_schema_generation(test_config: ComponentModel) -> None: From 4f9901ed1f8224b18963afd20b2717847ce256d6 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 1 Feb 2025 13:49:35 +0000 Subject: [PATCH 10/22] update deps --- python/uv.lock | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 10714effc692..91b6242725b6 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -815,7 +815,6 @@ dependencies = [ { name = "azure-identity" }, { name = "fastapi" }, { name = "loguru" }, - { name = "mcp" }, { name = "numpy" }, { name = "psycopg" }, { name = "pydantic" }, @@ -848,7 +847,6 @@ requires-dist = [ { name = "fastapi" }, { name = "fastapi", marker = "extra == 'web'" }, { name = "loguru" }, - { name = "mcp" }, { name = "numpy", specifier = "<2.0.0" }, { name = "psycopg" }, { name = "psycopg", marker = "extra == 'database'" }, @@ -3808,25 +3806,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, ] -[[package]] -name = "mcp" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "uvicorn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/a5/b08dc846ebedae9f17ced878e6975826e90e448cd4592f532f6a88a925a7/mcp-1.2.0.tar.gz", hash = "sha256:2b06c7ece98d6ea9e6379caa38d74b432385c338fb530cb82e2c70ea7add94f5", size = 102973 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/84/fca78f19ac8ce6c53ba416247c71baa53a9e791e98d3c81edbc20a77d6d1/mcp-1.2.0-py3-none-any.whl", hash = "sha256:1d0e77d8c14955a5aea1f5aa1f444c8e531c09355c829b20e42f7a142bc0755f", size = 66468 }, -] - [[package]] name = "mdit-py-plugins" version = "0.4.2" @@ -6721,19 +6700,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/b1/3af5104b716c420e40a6ea1b09886cae3a1b9f4538343875f637755cae5b/sqlmodel-0.0.22-py3-none-any.whl", hash = "sha256:a1ed13e28a1f4057cbf4ff6cdb4fc09e85702621d3259ba17b3c230bfb2f941b", size = 28276 }, ] -[[package]] -name = "sse-starlette" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, -] - [[package]] name = "stack-data" version = "0.6.3" From e6b14df35182af59305f4751fc34cd576c02d85d Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 1 Feb 2025 16:00:17 +0000 Subject: [PATCH 11/22] add respose_type and tests --- .../src/autogen_ext/tools/http/_http_tool.py | 17 +++++++++++++++-- .../tests/tools/http/test_http_tool.py | 12 ++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 8aa1d224d94e..b4bdf891a6ae 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -48,6 +48,10 @@ class HttpToolConfig(BaseModel): A JSON Schema object defining the expected parameters for the tool. Path parameters MUST also be included in the json_schema. They must also MUST be set to string """ + return_type: Optional[Literal["text", "json"]] = "text" + """ + The type of response to return from the tool. + """ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): @@ -66,6 +70,8 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): headers (dict[str, Any], optional): A dictionary of headers to send with the request. json_schema (dict[str, Any]): A JSON Schema object defining the expected parameters for the tool. Path parameters must also be included in the schema and must be strings. + return_type (Literal["text", "json"], optional): The type of response to return from the tool. + Defaults to "text". Example: Simple use case:: @@ -132,6 +138,7 @@ def __init__( path: str = "/", scheme: Literal["http", "https"] = "http", method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] = "POST", + return_type: Literal["text", "json"] = "text", ) -> None: self.server_params = HttpToolConfig( name=name, @@ -143,6 +150,7 @@ def __init__( method=method, headers=headers, json_schema=json_schema, + return_type=return_type, ) # Use regex to find all path parameters, we will need those later to template the path @@ -208,5 +216,10 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A case _: # Default case POST response = await client.post(url, json=model_dump) - # TODO: (EItanya): Think about adding the ability to parse the response as JSON, or check a schema - return response.text + match self.server_params.return_type: + case "text": + return response.text + case "json": + return response.json() + case _: + raise ValueError(f"Invalid return type: {self.server_params.return_type}") diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py index e8055b2bfbf1..defdc9134e2c 100644 --- a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -53,6 +53,18 @@ async def test_post_request(test_config: ComponentModel, test_server: None) -> N assert json.loads(result)["result"] == "Received: test query with value 42" +@pytest.mark.asyncio +async def test_post_request_json_return(test_config: ComponentModel, test_server: None) -> None: + # Modify config to use json return type + config = test_config.model_copy() + config.config["return_type"] = "json" + tool = HttpTool.load_component(config) + result = await tool.run_json({"query": "test query", "value": 45}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 45" + + @pytest.mark.asyncio async def test_get_request(test_config: ComponentModel, test_server: None) -> None: # Modify config for GET request From 09f653fc4805e263fbf104759c066efaad31b69e Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 1 Feb 2025 17:40:04 +0000 Subject: [PATCH 12/22] run check --- .../src/autogen_ext/tools/http/_http_tool.py | 24 ++++++------ .../autogen-ext/tests/tools/http/conftest.py | 20 +++++----- .../tests/tools/http/test_http_tool.py | 38 +++++++++---------- 3 files changed, 37 insertions(+), 45 deletions(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index b4bdf891a6ae..7c9f847e9d43 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -90,7 +90,7 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): "properties": { "value": {"type": "string", "description": "The base64 value to decode"}, }, - "required": ["value"] + "required": ["value"], } # Create an HTTP tool for the weather API @@ -102,24 +102,23 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): port=443, path="/base64/{value}", method="GET", - json_schema=base64_schema + json_schema=base64_schema, ) + async def main(): # Create an assistant with the base64 tool model = OpenAIChatCompletionClient(model="gpt-4") - assistant = AssistantAgent( - "base64_assistant", - model_client=model, - tools=[base64_tool] - ) + assistant = AssistantAgent("base64_assistant", model_client=model, tools=[base64_tool]) # The assistant can now use the base64 tool to decode the string - response = await assistant.on_messages([ - TextMessage(content="Can you base64 decode the value 'YWJjZGU=', please?", source="user") - ], CancellationToken()) + response = await assistant.on_messages( + [TextMessage(content="Can you base64 decode the value 'YWJjZGU=', please?", source="user")], + CancellationToken(), + ) print(response.chat_message.content) + asyncio.run(main()) """ @@ -161,9 +160,9 @@ def __init__( input_model = create_model(json_schema) # Use Any as return type since HTTP responses can vary - return_type: Type[Any] = object + base_return_type: Type[Any] = object - super().__init__(input_model, return_type, name, description) + super().__init__(input_model, base_return_type, name, description) def _to_config(self) -> HttpToolConfig: copied_config = self.server_params.model_copy() @@ -188,7 +187,6 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A Exception: If tool execution fails """ - model_dump = args.model_dump() path_params = {k: v for k, v in model_dump.items() if k in self._path_params} # Remove path params from the model dump diff --git a/python/packages/autogen-ext/tests/tools/http/conftest.py b/python/packages/autogen-ext/tests/tools/http/conftest.py index fd3c5e4a537d..1f9485b6f7e7 100644 --- a/python/packages/autogen-ext/tests/tools/http/conftest.py +++ b/python/packages/autogen-ext/tests/tools/http/conftest.py @@ -4,8 +4,7 @@ import pytest import pytest_asyncio import uvicorn -from autogen_core import CancellationToken, ComponentModel -from autogen_ext.tools.http import HttpTool +from autogen_core import ComponentModel from fastapi import Body, FastAPI from pydantic import BaseModel, Field @@ -24,20 +23,19 @@ class TestResponse(BaseModel): @app.post("/test") -async def test_endpoint(body: TestArgs = Body(...)) -> TestResponse: +async def test_endpoint(body: TestArgs) -> TestResponse: return TestResponse(result=f"Received: {body.query} with value {body.value}") + @app.post("/test/{query}/{value}") async def test_path_params_endpoint(query: str, value: int) -> TestResponse: return TestResponse(result=f"Received: {query} with value {value}") + @app.put("/test/{query}/{value}") -async def test_path_params_and_body_endpoint( - query: str, - value: int, - body: dict = Body(...) -) -> TestResponse: - return TestResponse(result=f"Received: {query} with value {value} and extra {body.get("extra")}") +async def test_path_params_and_body_endpoint(query: str, value: int, body: dict) -> TestResponse: + return TestResponse(result=f"Received: {query} with value {value} and extra {body.get('extra')}") + @app.get("/test") async def test_get_endpoint(query: str, value: int) -> TestResponse: @@ -45,7 +43,7 @@ async def test_get_endpoint(query: str, value: int) -> TestResponse: @app.put("/test") -async def test_put_endpoint(body: TestArgs = Body(...)) -> TestResponse: +async def test_put_endpoint(body: TestArgs) -> TestResponse: return TestResponse(result=f"Received: {body.query} with value {body.value}") @@ -55,7 +53,7 @@ async def test_delete_endpoint(query: str, value: int) -> TestResponse: @app.patch("/test") -async def test_patch_endpoint(body: TestArgs = Body(...)) -> TestResponse: +async def test_patch_endpoint(body: TestArgs) -> TestResponse: return TestResponse(result=f"Received: {body.query} with value {body.value}") diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py index defdc9134e2c..3756c48474d0 100644 --- a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -90,6 +90,7 @@ async def test_put_request(test_config: ComponentModel, test_server: None) -> No assert isinstance(result, str) assert json.loads(result)["result"] == "Received: test query with value 42" + @pytest.mark.asyncio async def test_path_params(test_config: ComponentModel, test_server: None) -> None: # Modify config to use path parameters @@ -102,6 +103,7 @@ async def test_path_params(test_config: ComponentModel, test_server: None) -> No assert isinstance(result, str) assert json.loads(result)["result"] == "Received: test query with value 42" + @pytest.mark.asyncio async def test_path_params_and_body(test_config: ComponentModel, test_server: None) -> None: # Modify config to use path parameters and include body parameters @@ -109,28 +111,22 @@ async def test_path_params_and_body(test_config: ComponentModel, test_server: No config.config["method"] = "PUT" config.config["path"] = "/test/{query}/{value}" config.config["json_schema"] = { - "type": "object", + "type": "object", "properties": { "query": {"type": "string", "description": "The test query"}, "value": {"type": "integer", "description": "A test value"}, - "extra": {"type": "string", "description": "Extra body parameter"} + "extra": {"type": "string", "description": "Extra body parameter"}, }, - "required": ["query", "value", "extra"] + "required": ["query", "value", "extra"], } tool = HttpTool.load_component(config) - result = await tool.run_json({ - "query": "test query", - "value": 42, - "extra": "extra data" - }, CancellationToken()) + result = await tool.run_json({"query": "test query", "value": 42, "extra": "extra data"}, CancellationToken()) assert isinstance(result, str) assert json.loads(result)["result"] == "Received: test query with value 42 and extra extra data" - - @pytest.mark.asyncio async def test_delete_request(test_config: ComponentModel, test_server: None) -> None: # Modify config for DELETE request @@ -161,7 +157,7 @@ async def test_patch_request(test_config: ComponentModel, test_server: None) -> async def test_invalid_schema(test_config: ComponentModel, test_server: None) -> None: # Create an invalid schema missing required properties config: ComponentModel = test_config.model_copy() - config.config["host"] = True # Incorrect type + config.config["host"] = True # Incorrect type with pytest.raises(ValidationError): # Should fail when trying to create model from invalid schema @@ -181,16 +177,16 @@ async def test_invalid_request(test_config: ComponentModel, test_server: None) - def test_config_serialization(test_config: ComponentModel) -> None: tool = HttpTool.load_component(test_config) - config = tool._to_config() - - assert config.name == test_config.config["name"] - assert config.description == test_config.config["description"] - assert config.host == test_config.config["host"] - assert config.port == test_config.config["port"] - assert config.path == test_config.config["path"] - assert config.scheme == test_config.config["scheme"] - assert config.method == test_config.config["method"] - assert config.headers == test_config.config["headers"] + config = tool.dump_component() + + assert config.config["name"] == test_config.config["name"] + assert config.config["description"] == test_config.config["description"] + assert config.config["host"] == test_config.config["host"] + assert config.config["port"] == test_config.config["port"] + assert config.config["path"] == test_config.config["path"] + assert config.config["scheme"] == test_config.config["scheme"] + assert config.config["method"] == test_config.config["method"] + assert config.config["headers"] == test_config.config["headers"] def test_config_deserialization(test_config: ComponentModel) -> None: From f5726962f7160851fe5754447fe5fc4a8c7fd53f Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 1 Feb 2025 17:50:16 +0000 Subject: [PATCH 13/22] fix pyright checks --- .../packages/autogen-ext/tests/tools/http/conftest.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/packages/autogen-ext/tests/tools/http/conftest.py b/python/packages/autogen-ext/tests/tools/http/conftest.py index 1f9485b6f7e7..66ffa908983d 100644 --- a/python/packages/autogen-ext/tests/tools/http/conftest.py +++ b/python/packages/autogen-ext/tests/tools/http/conftest.py @@ -1,11 +1,11 @@ import asyncio -from typing import AsyncGenerator +from typing import Any, AsyncGenerator, Dict import pytest import pytest_asyncio import uvicorn from autogen_core import ComponentModel -from fastapi import Body, FastAPI +from fastapi import FastAPI from pydantic import BaseModel, Field @@ -33,8 +33,8 @@ async def test_path_params_endpoint(query: str, value: int) -> TestResponse: @app.put("/test/{query}/{value}") -async def test_path_params_and_body_endpoint(query: str, value: int, body: dict) -> TestResponse: - return TestResponse(result=f"Received: {query} with value {value} and extra {body.get('extra')}") +async def test_path_params_and_body_endpoint(query: str, value: int, body: Dict[str, Any]) -> TestResponse: + return TestResponse(result=f"Received: {query} with value {value} and extra {body.get('extra')}") # type: ignore @app.get("/test") @@ -82,7 +82,7 @@ def test_config() -> ComponentModel: ) -@pytest_asyncio.fixture +@pytest_asyncio.fixture # type: ignore async def test_server() -> AsyncGenerator[None, None]: # Start the test server config = uvicorn.Config(app, host="127.0.0.1", port=8000, log_level="error") From c6a1c38180770b3b27444aaaefc99c99ccab3463 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 1 Feb 2025 18:01:49 +0000 Subject: [PATCH 14/22] make mypy happy --- .../autogen-ext/src/autogen_ext/tools/http/_http_tool.py | 3 ++- python/packages/autogen-ext/tests/tools/http/__init__.py | 0 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 python/packages/autogen-ext/tests/tools/http/__init__.py diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 7c9f847e9d43..009a780ea47f 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -6,6 +6,7 @@ from autogen_core.tools import BaseTool from json_schema_to_pydantic import create_model from pydantic import BaseModel, Field +from typing_extensions import Self class HttpToolConfig(BaseModel): @@ -169,7 +170,7 @@ def _to_config(self) -> HttpToolConfig: return copied_config @classmethod - def _from_config(cls, config: HttpToolConfig): + def _from_config(cls, config: HttpToolConfig) -> Self: copied_config = config.model_copy().model_dump() return cls(**copied_config) diff --git a/python/packages/autogen-ext/tests/tools/http/__init__.py b/python/packages/autogen-ext/tests/tools/http/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 From 2e611c154bd9cb43d740181c842d939a184a91a7 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 3 Feb 2025 12:14:09 -0500 Subject: [PATCH 15/22] move the graphrag tests into a subdir to not leak conftest.py --- .../packages/autogen-ext/tests/tools/{ => graphrag}/conftest.py | 0 .../autogen-ext/tests/tools/{ => graphrag}/test_graphrag_tools.py | 0 python/packages/autogen-ext/tests/tools/http/__init__.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename python/packages/autogen-ext/tests/tools/{ => graphrag}/conftest.py (100%) rename python/packages/autogen-ext/tests/tools/{ => graphrag}/test_graphrag_tools.py (100%) delete mode 100644 python/packages/autogen-ext/tests/tools/http/__init__.py diff --git a/python/packages/autogen-ext/tests/tools/conftest.py b/python/packages/autogen-ext/tests/tools/graphrag/conftest.py similarity index 100% rename from python/packages/autogen-ext/tests/tools/conftest.py rename to python/packages/autogen-ext/tests/tools/graphrag/conftest.py diff --git a/python/packages/autogen-ext/tests/tools/test_graphrag_tools.py b/python/packages/autogen-ext/tests/tools/graphrag/test_graphrag_tools.py similarity index 100% rename from python/packages/autogen-ext/tests/tools/test_graphrag_tools.py rename to python/packages/autogen-ext/tests/tools/graphrag/test_graphrag_tools.py diff --git a/python/packages/autogen-ext/tests/tools/http/__init__.py b/python/packages/autogen-ext/tests/tools/http/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 From d6e176ad275a8ddc3b0be809d43cc68801006f9e Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 4 Feb 2025 10:47:02 +0000 Subject: [PATCH 16/22] PR comments --- python/packages/autogen-ext/pyproject.toml | 2 +- .../src/autogen_ext/tools/http/_http_tool.py | 11 ++++++++++- .../packages/autogen-ext/tests/tools/http/conftest.py | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/python/packages/autogen-ext/pyproject.toml b/python/packages/autogen-ext/pyproject.toml index 97774652b011..0cb792eb277d 100644 --- a/python/packages/autogen-ext/pyproject.toml +++ b/python/packages/autogen-ext/pyproject.toml @@ -106,7 +106,7 @@ semantic-kernel-dapr = [ "semantic-kernel[dapr]>=1.17.1", ] -http = [ +http-tool = [ "httpx>=0.27.0", "json-schema-to-pydantic>=0.2.0" ] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 009a780ea47f..b66327ffa2c9 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -74,6 +74,15 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): return_type (Literal["text", "json"], optional): The type of response to return from the tool. Defaults to "text". + .. note:: + This tool requires the :code:`http-tool` extra for the :code:`autogen-ext` package. + + To install: + + .. code-block:: bash + + pip install -U "autogen-agentchat" "autogen-ext[http-tool]" + Example: Simple use case:: @@ -94,7 +103,7 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): "required": ["value"], } - # Create an HTTP tool for the weather API + # Create an HTTP tool for the httpbin API base64_tool = HttpTool( name="base64_decode", description="base64 decode a value", diff --git a/python/packages/autogen-ext/tests/tools/http/conftest.py b/python/packages/autogen-ext/tests/tools/http/conftest.py index 66ffa908983d..74ea64a91465 100644 --- a/python/packages/autogen-ext/tests/tools/http/conftest.py +++ b/python/packages/autogen-ext/tests/tools/http/conftest.py @@ -82,7 +82,7 @@ def test_config() -> ComponentModel: ) -@pytest_asyncio.fixture # type: ignore +@pytest_asyncio.fixture(scope="function") # type: ignore async def test_server() -> AsyncGenerator[None, None]: # Start the test server config = uvicorn.Config(app, host="127.0.0.1", port=8000, log_level="error") From d25e119d4ec2fd6b6c6d4bcd2ca4a330a72a98e9 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 4 Feb 2025 16:11:02 +0000 Subject: [PATCH 17/22] udpate uv.lock --- python/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 0da7d8f252a4..ed687da4501c 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -598,7 +598,7 @@ graphrag = [ grpc = [ { name = "grpcio" }, ] -http = [ +http-tool = [ { name = "httpx" }, { name = "json-schema-to-pydantic" }, ] @@ -696,10 +696,10 @@ requires-dist = [ { name = "docker", marker = "extra == 'docker'", specifier = "~=7.0" }, { name = "ffmpeg-python", marker = "extra == 'video-surfer'" }, { name = "graphrag", marker = "extra == 'graphrag'", specifier = ">=1.0.1" }, - { name = "httpx", marker = "extra == 'http'", specifier = ">=0.27.0" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = "~=1.70.0" }, + { name = "httpx", marker = "extra == 'http-tool'", specifier = ">=0.27.0" }, { name = "ipykernel", marker = "extra == 'jupyter-executor'", specifier = ">=6.29.5" }, - { name = "json-schema-to-pydantic", marker = "extra == 'http'", specifier = ">=0.2.0" }, + { name = "json-schema-to-pydantic", marker = "extra == 'http-tool'", specifier = ">=0.2.0" }, { name = "langchain-core", marker = "extra == 'langchain'", specifier = "~=0.3.3" }, { name = "markitdown", marker = "extra == 'file-surfer'", specifier = ">=0.0.1a2" }, { name = "markitdown", marker = "extra == 'magentic-one'", specifier = ">=0.0.1a2" }, From 286a62893599295591aa4671076e3acc5aae760e Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Thu, 6 Feb 2025 19:53:37 +0000 Subject: [PATCH 18/22] mypy finally passing --- python/packages/autogen-ext/tests/__init__.py | 0 python/packages/autogen-ext/tests/tools/__init__.py | 0 python/packages/autogen-ext/tests/tools/graphrag/__init__.py | 0 python/packages/autogen-ext/tests/tools/http/__init__.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 python/packages/autogen-ext/tests/__init__.py create mode 100644 python/packages/autogen-ext/tests/tools/__init__.py create mode 100644 python/packages/autogen-ext/tests/tools/graphrag/__init__.py create mode 100644 python/packages/autogen-ext/tests/tools/http/__init__.py diff --git a/python/packages/autogen-ext/tests/__init__.py b/python/packages/autogen-ext/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/packages/autogen-ext/tests/tools/__init__.py b/python/packages/autogen-ext/tests/tools/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/packages/autogen-ext/tests/tools/graphrag/__init__.py b/python/packages/autogen-ext/tests/tools/graphrag/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/packages/autogen-ext/tests/tools/http/__init__.py b/python/packages/autogen-ext/tests/tools/http/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 From 6ccbcf17116e812c10a5553d4a40b261b193605c Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sun, 9 Feb 2025 21:37:35 +0000 Subject: [PATCH 19/22] ignore excluded mypy error --- python/packages/autogen-ext/tests/test_worker_runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/autogen-ext/tests/test_worker_runtime.py b/python/packages/autogen-ext/tests/test_worker_runtime.py index 77101d1344d3..71e4eacbbd22 100644 --- a/python/packages/autogen-ext/tests/test_worker_runtime.py +++ b/python/packages/autogen-ext/tests/test_worker_runtime.py @@ -423,7 +423,7 @@ def __init__(self) -> None: self.received_messages: list[Any] = [] @event - async def on_new_message(self, message: ProtoMessage, ctx: MessageContext) -> None: + async def on_new_message(self, message: ProtoMessage, ctx: MessageContext) -> None: # type: ignore self.num_calls += 1 self.received_messages.append(message) From 405f3f6066007f7acbcd289abf93a18ede3ad531 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sun, 9 Feb 2025 21:38:56 +0000 Subject: [PATCH 20/22] fix import --- python/packages/autogen-ext/tests/test_worker_runtime.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/packages/autogen-ext/tests/test_worker_runtime.py b/python/packages/autogen-ext/tests/test_worker_runtime.py index 71e4eacbbd22..4fb19b366a14 100644 --- a/python/packages/autogen-ext/tests/test_worker_runtime.py +++ b/python/packages/autogen-ext/tests/test_worker_runtime.py @@ -29,7 +29,8 @@ MessageType, NoopAgent, ) -from protos.serialization_test_pb2 import ProtoMessage + +from .protos.serialization_test_pb2 import ProtoMessage @pytest.mark.grpc From 03b14f9cf33ffbbb4d3770faa0b80b7b1fa10a24 Mon Sep 17 00:00:00 2001 From: Eric Zhu Date: Mon, 10 Feb 2025 11:43:38 -0800 Subject: [PATCH 21/22] format --- python/packages/autogen-ext/tests/test_worker_runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/autogen-ext/tests/test_worker_runtime.py b/python/packages/autogen-ext/tests/test_worker_runtime.py index 4fb19b366a14..88c9d1e0bf02 100644 --- a/python/packages/autogen-ext/tests/test_worker_runtime.py +++ b/python/packages/autogen-ext/tests/test_worker_runtime.py @@ -424,7 +424,7 @@ def __init__(self) -> None: self.received_messages: list[Any] = [] @event - async def on_new_message(self, message: ProtoMessage, ctx: MessageContext) -> None: # type: ignore + async def on_new_message(self, message: ProtoMessage, ctx: MessageContext) -> None: # type: ignore self.num_calls += 1 self.received_messages.append(message) From 0dea967fcd78a58687b701627cceaf7b79a960ed Mon Sep 17 00:00:00 2001 From: Eric Zhu Date: Mon, 10 Feb 2025 12:22:22 -0800 Subject: [PATCH 22/22] doc: update API reference and add documentation for http tool --- python/packages/autogen-core/docs/src/reference/index.md | 7 ++++--- .../docs/src/reference/python/autogen_ext.tools.http.rst | 8 ++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 python/packages/autogen-core/docs/src/reference/python/autogen_ext.tools.http.rst diff --git a/python/packages/autogen-core/docs/src/reference/index.md b/python/packages/autogen-core/docs/src/reference/index.md index 762e9f07ba35..f9dc5c1a19b1 100644 --- a/python/packages/autogen-core/docs/src/reference/index.md +++ b/python/packages/autogen-core/docs/src/reference/index.md @@ -46,17 +46,17 @@ python/autogen_ext.agents.web_surfer python/autogen_ext.agents.file_surfer python/autogen_ext.agents.video_surfer python/autogen_ext.agents.video_surfer.tools -python/autogen_ext.auth.azure python/autogen_ext.teams.magentic_one python/autogen_ext.models.cache python/autogen_ext.models.openai python/autogen_ext.models.replay python/autogen_ext.models.azure python/autogen_ext.models.semantic_kernel +python/autogen_ext.tools.code_execution +python/autogen_ext.tools.graphrag +python/autogen_ext.tools.http python/autogen_ext.tools.langchain python/autogen_ext.tools.mcp -python/autogen_ext.tools.graphrag -python/autogen_ext.tools.code_execution python/autogen_ext.tools.semantic_kernel python/autogen_ext.code_executors.local python/autogen_ext.code_executors.docker @@ -65,4 +65,5 @@ python/autogen_ext.code_executors.azure python/autogen_ext.cache_store.diskcache python/autogen_ext.cache_store.redis python/autogen_ext.runtimes.grpc +python/autogen_ext.auth.azure ``` diff --git a/python/packages/autogen-core/docs/src/reference/python/autogen_ext.tools.http.rst b/python/packages/autogen-core/docs/src/reference/python/autogen_ext.tools.http.rst new file mode 100644 index 000000000000..7450fb9bfe08 --- /dev/null +++ b/python/packages/autogen-core/docs/src/reference/python/autogen_ext.tools.http.rst @@ -0,0 +1,8 @@ +autogen\_ext.tools.http +======================= + + +.. automodule:: autogen_ext.tools.http + :members: + :undoc-members: + :show-inheritance: