-
Notifications
You must be signed in to change notification settings - Fork 5.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adding declarative HTTP tools to autogen ext #5181
Open
EItanya
wants to merge
15
commits into
microsoft:main
Choose a base branch
from
EItanya:declarative-mcp-tools
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+577
−8
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
57cbb2e
initial commit for declarative-mcp-tools
EItanya dd00059
update without local deps
EItanya db763d5
Merge branch 'main' of https://github.com/microsoft/autogen into decl…
EItanya 9c4a00b
remove MCP and add more http options
EItanya 95fc65e
unit tests are working
EItanya 3c1a8ce
Merge branch 'main' into declarative-mcp-tools
EItanya 7b644e2
works again with path params stuff
EItanya 873ac5c
add unit tests for the new path params stuff
EItanya dfb9b50
unit tests now fully passing with params, example works with httpbin …
EItanya 7395c0a
update docs for args
EItanya 98b0519
revert autogen studio
EItanya 4644bc8
formatting
EItanya 1773520
Merge branch 'main' into declarative-mcp-tools
EItanya 64c08a6
Merge branch 'main' into declarative-mcp-tools
EItanya 65b7c72
Merge branch 'main' into declarative-mcp-tools
EItanya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
3 changes: 3 additions & 0 deletions
3
python/packages/autogen-ext/src/autogen_ext/tools/http/__init__.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
from ._http_tool import HttpTool | ||
|
||
__all__ = ["HttpTool"] |
212 changes: 212 additions & 0 deletions
212
python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,212 @@ | ||
import re | ||
from typing import Any, Literal, Optional, Type | ||
|
||
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, Field | ||
|
||
|
||
class HttpToolConfig(BaseModel): | ||
name: str | ||
""" | ||
The name of the tool. | ||
""" | ||
description: Optional[str] | ||
""" | ||
A description of the tool. | ||
""" | ||
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. | ||
""" | ||
headers: Optional[dict[str, Any]] | ||
""" | ||
A dictionary of headers to send with the request. | ||
""" | ||
json_schema: dict[str, Any] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks good. def __init__(
self,
# ... existing parameters ...
response_type: Literal["json", "text"] = "json"
) -> None:
self.server_params = HttpToolConfig(
# ... existing params ...
response_type=response_type,
) async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Union[dict, str]:
# ... existing request code ...
if self.server_params.response_type == "json":
try:
return response.json()
except Exception as e:
raise ValueError(f"Failed to parse response as JSON: {str(e)}") from e
return response.text |
||
""" | ||
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 | ||
""" | ||
|
||
|
||
class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): | ||
"""A wrapper for using an HTTP server as a tool. | ||
|
||
Args: | ||
name (str): The name of the tool. | ||
description (str, optional): A description of the tool. | ||
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:: | ||
|
||
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 | ||
|
||
def __init__( | ||
self, | ||
name: str, | ||
host: str, | ||
port: int, | ||
json_schema: dict[str, Any], | ||
headers: Optional[dict[str, Any]] = None, | ||
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, | ||
host=host, | ||
port=port, | ||
path=path, | ||
scheme=scheme, | ||
method=method, | ||
headers=headers, | ||
json_schema=json_schema, | ||
) | ||
|
||
# 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 modified schema | ||
input_model = create_model(json_schema) | ||
|
||
# Use Any as return type since HTTP responses can vary | ||
return_type: Type[Any] = object | ||
|
||
super().__init__(input_model, return_type, name, description) | ||
|
||
def _to_config(self) -> HttpToolConfig: | ||
copied_config = self.server_params.model_copy() | ||
return copied_config | ||
|
||
@classmethod | ||
def _from_config(cls, config: HttpToolConfig): | ||
copied_config = config.model_copy().model_dump() | ||
return cls(**copied_config) | ||
|
||
async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: | ||
"""Execute the HTTP tool with the given arguments. | ||
|
||
Args: | ||
args: The validated input arguments | ||
cancellation_token: Token for cancelling the operation | ||
|
||
Returns: | ||
The response body from the HTTP call in JSON format | ||
|
||
Raises: | ||
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(url, params=model_dump) | ||
case "PUT": | ||
response = await client.put(url, json=model_dump) | ||
case "DELETE": | ||
response = await client.delete(url, params=model_dump) | ||
case "PATCH": | ||
response = await client.patch(url, json=model_dump) | ||
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 |
103 changes: 103 additions & 0 deletions
103
python/packages/autogen-ext/tests/tools/http/conftest.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
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.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: | ||
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", | ||
"scheme": "http", | ||
"path": "/test", | ||
"host": "localhost", | ||
"port": 8000, | ||
"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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should headers be optional?
This code will fail (validation error, headers missing)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you confirm we have tests that evaluate ..
HttpTool.load_component()
behavior?