From 051d06a7ba0f89f89d88ce36c35e03e13a1a0908 Mon Sep 17 00:00:00 2001 From: samanhappy Date: Tue, 18 Mar 2025 13:47:50 +0800 Subject: [PATCH] add anp tool --- .gitignore | 2 + app/agent/manus.py | 7 +- app/config.py | 29 +++ app/prompt/manus.py | 4 +- app/tool/anp_tool.py | 230 +++++++++++++++++++ config/config.example.toml | 7 + config/did_test_public_doc/did.json | 25 ++ config/did_test_public_doc/key-1_private.pem | 5 + config/did_test_public_doc/private_keys.json | 10 + requirements.txt | 1 + 10 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 app/tool/anp_tool.py create mode 100644 config/did_test_public_doc/did.json create mode 100644 config/did_test_public_doc/key-1_private.pem create mode 100644 config/did_test_public_doc/private_keys.json diff --git a/.gitignore b/.gitignore index ff8e80d8b..4460a3015 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,5 @@ cython_debug/ # Built Visual Studio Code Extensions *.vsix + +config.toml diff --git a/app/agent/manus.py b/app/agent/manus.py index 06101aabe..a83e76a1e 100644 --- a/app/agent/manus.py +++ b/app/agent/manus.py @@ -7,6 +7,7 @@ from app.logger import logger from app.prompt.manus import NEXT_STEP_PROMPT, SYSTEM_PROMPT from app.tool import Terminate, ToolCollection +from app.tool.anp_tool import ANPTool from app.tool.browser_use_tool import BrowserUseTool from app.tool.file_saver import FileSaver from app.tool.python_execute import PythonExecute @@ -35,7 +36,11 @@ class Manus(ToolCallAgent): # Add general-purpose tools to the tool collection available_tools: ToolCollection = Field( default_factory=lambda: ToolCollection( - PythonExecute(), BrowserUseTool(), FileSaver(), Terminate() + PythonExecute(), + BrowserUseTool(), + FileSaver(), + ANPTool(), + Terminate(), ) ) diff --git a/app/config.py b/app/config.py index 9ab1448d6..8e6b37a34 100644 --- a/app/config.py +++ b/app/config.py @@ -64,6 +64,15 @@ class BrowserSettings(BaseModel): ) +class ANPSettings(BaseModel): + did_document_path: Optional[str] = Field( + None, description="Path to DID document file" + ) + private_key_path: Optional[str] = Field( + None, description="Path to private key file" + ) + + class AppConfig(BaseModel): llm: Dict[str, LLMSettings] browser_config: Optional[BrowserSettings] = Field( @@ -72,6 +81,9 @@ class AppConfig(BaseModel): search_config: Optional[SearchSettings] = Field( None, description="Search configuration" ) + anp_config: Optional[ANPSettings] = Field( + None, description="ANP tool configuration" + ) class Config: arbitrary_types_allowed = True @@ -169,6 +181,18 @@ def _load_initial_config(self): if search_config: search_settings = SearchSettings(**search_config) + # handle ANP config + anp_config = raw_config.get("anp", {}) + anp_settings = None + if anp_config: + anp_settings = ANPSettings( + **{ + k: v + for k, v in anp_config.items() + if k in ANPSettings.__annotations__ and v is not None + } + ) + config_dict = { "llm": { "default": default_settings, @@ -179,6 +203,7 @@ def _load_initial_config(self): }, "browser_config": browser_settings, "search_config": search_settings, + "anp_config": anp_settings, } self._config = AppConfig(**config_dict) @@ -195,5 +220,9 @@ def browser_config(self) -> Optional[BrowserSettings]: def search_config(self) -> Optional[SearchSettings]: return self._config.search_config + @property + def anp_config(self) -> Optional[ANPSettings]: + return self._config.anp_config + config = Config() diff --git a/app/prompt/manus.py b/app/prompt/manus.py index 0cb944d9f..3e1d0c9e5 100644 --- a/app/prompt/manus.py +++ b/app/prompt/manus.py @@ -1,6 +1,6 @@ SYSTEM_PROMPT = "You are OpenManus, an all-capable AI assistant, aimed at solving any task presented by the user. You have various tools at your disposal that you can call upon to efficiently complete complex requests. Whether it's programming, information retrieval, file processing, or web browsing, you can handle it all." -NEXT_STEP_PROMPT = """You can interact with the computer using PythonExecute, save important content and information files through FileSaver, open browsers with BrowserUseTool, and retrieve information using GoogleSearch. +NEXT_STEP_PROMPT = """You can interact with the computer using PythonExecute, save important content and information files through FileSaver, open browsers with BrowserUseTool, and retrieve information using GoogleSearch, and interact with other agents using ANPTool. PythonExecute: Execute Python code to interact with the computer system, data processing, automation tasks, etc. @@ -10,6 +10,8 @@ Terminate: End the current interaction when the task is complete or when you need additional information from the user. Use this tool to signal that you've finished addressing the user's request or need clarification before proceeding further. +ANPTool: Interact with other agents using Agent Network Protocol (ANP).ANP provides services such as hotel and scenic spot ticket query and booking. + Based on user needs, proactively select the most appropriate tool or combination of tools. For complex tasks, you can break down the problem and use different tools step by step to solve it. After using each tool, clearly explain the execution results and suggest the next steps. Always maintain a helpful, informative tone throughout the interaction. If you encounter any limitations or need more details, clearly communicate this to the user before terminating. diff --git a/app/tool/anp_tool.py b/app/tool/anp_tool.py new file mode 100644 index 000000000..73e808d5a --- /dev/null +++ b/app/tool/anp_tool.py @@ -0,0 +1,230 @@ +import asyncio +import json +import yaml +import aiohttp +from pathlib import Path +from typing import Dict, Any, Optional + +from app.logger import logger +from app.tool.base import BaseTool +from agent_connect.authentication import DIDWbaAuthHeader +from app.config import config, PROJECT_ROOT + + +class ANPTool(BaseTool): + name: str = "anp_tool" + description: str = """Use Agent Network Protocol (ANP) to interact with other agents. +1. For the first use, please enter the URL: https://agent-search.ai/ad.json, which is an agent search service. You can use the interfaces inside to query agents that can provide hotels, tickets, and attractions. +2. After receiving the agent's description document, you can crawl data based on the data link URL in the agent's description document. +3. During the process, you can call the API to complete the service until you think the task is completed. +4. Note, any URL obtained using ANPTool must be called using ANPTool, do not call it directly yourself. +""" + parameters: dict = { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "(required) URL of the agent description file or API endpoint", + }, + "method": { + "type": "string", + "description": "(optional) HTTP method, such as GET, POST, PUT, etc., default is GET", + "enum": ["GET", "POST", "PUT", "DELETE", "PATCH"], + "default": "GET", + }, + "headers": { + "type": "object", + "description": "(optional) HTTP request headers", + "default": {}, + }, + "params": { + "type": "object", + "description": "(optional) URL query parameters", + "default": {}, + }, + "body": { + "type": "object", + "description": "(optional) Request body for POST/PUT requests", + }, + }, + "required": ["url"], + } + + # Declare auth_client field + auth_client: Optional[DIDWbaAuthHeader] = None + + def __init__(self, **data): + super().__init__(**data) + + # Get default paths relative to project root + default_did_path = str(PROJECT_ROOT / "config/did_test_public_doc/did.json") + default_key_path = str( + PROJECT_ROOT / "config/did_test_public_doc/key-1_private.pem" + ) + + # Use paths from configuration if available, otherwise use defaults + did_path = default_did_path + key_path = default_key_path + + if config.anp_config: + if config.anp_config.did_document_path: + did_path = config.anp_config.did_document_path + if config.anp_config.private_key_path: + key_path = config.anp_config.private_key_path + + logger.info( + f"ANPTool initialized - DID path: {did_path}, private key path: {key_path}" + ) + + self.auth_client = DIDWbaAuthHeader( + did_document_path=did_path, private_key_path=key_path + ) + + async def execute( + self, + url: str, + method: str = "GET", + headers: Dict[str, str] = None, + params: Dict[str, Any] = None, + body: Dict[str, Any] = None, + ) -> Dict[str, Any]: + """ + Execute HTTP request to interact with other agents + + Args: + url (str): URL of the agent description file or API endpoint + method (str, optional): HTTP method, default is "GET" + headers (Dict[str, str], optional): HTTP request headers + params (Dict[str, Any], optional): URL query parameters + body (Dict[str, Any], optional): Request body for POST/PUT requests + + Returns: + Dict[str, Any]: Response content + """ + + if headers is None: + headers = {} + if params is None: + params = {} + + logger.info(f"ANP request: {method} {url}") + + # Add basic request headers + if "Content-Type" not in headers and method in ["POST", "PUT", "PATCH"]: + headers["Content-Type"] = "application/json" + + # Add DID authentication + if self.auth_client: + try: + auth_headers = self.auth_client.get_auth_header(url) + headers.update(auth_headers) + except Exception as e: + logger.error(f"Failed to get authentication header: {str(e)}") + + async with aiohttp.ClientSession() as session: + # Prepare request parameters + request_kwargs = { + "url": url, + "headers": headers, + "params": params, + } + + # If there is a request body and the method supports it, add the request body + if body is not None and method in ["POST", "PUT", "PATCH"]: + request_kwargs["json"] = body + + # Execute request + http_method = getattr(session, method.lower()) + + try: + async with http_method(**request_kwargs) as response: + logger.info(f"ANP response: status code {response.status}") + + # Check response status + if ( + response.status == 401 + and "Authorization" in headers + and self.auth_client + ): + logger.warning( + "Authentication failed (401), trying to get authentication again" + ) + # If authentication fails and a token was used, clear the token and retry + self.auth_client.clear_token(url) + # Get authentication header again + headers.update( + self.auth_client.get_auth_header(url, force_new=True) + ) + # Execute request again + request_kwargs["headers"] = headers + async with http_method(**request_kwargs) as retry_response: + logger.info( + f"ANP retry response: status code {retry_response.status}" + ) + return await self._process_response(retry_response, url) + + return await self._process_response(response, url) + except aiohttp.ClientError as e: + logger.error(f"HTTP request failed: {str(e)}") + return {"error": f"HTTP request failed: {str(e)}", "status_code": 500} + + async def _process_response(self, response, url): + """Process HTTP response""" + # If authentication is successful, update the token + if response.status == 200 and self.auth_client: + try: + self.auth_client.update_token(url, dict(response.headers)) + except Exception as e: + logger.error(f"Failed to update token: {str(e)}") + + # Get response content type + content_type = response.headers.get("Content-Type", "").lower() + + # Get response text + text = await response.text() + + # Process response based on content type + if "application/json" in content_type: + # Process JSON response + try: + result = json.loads(text) + logger.info("Successfully parsed JSON response") + except json.JSONDecodeError: + logger.warning( + "Content-Type declared as JSON but parsing failed, returning raw text" + ) + result = {"text": text, "format": "text", "content_type": content_type} + elif "application/yaml" in content_type or "application/x-yaml" in content_type: + # Process YAML response + try: + result = yaml.safe_load(text) + logger.info("Successfully parsed YAML response") + result = { + "data": result, + "format": "yaml", + "content_type": content_type, + } + except yaml.YAMLError: + logger.warning( + "Content-Type declared as YAML but parsing failed, returning raw text" + ) + result = {"text": text, "format": "text", "content_type": content_type} + else: + # Default to text + result = {"text": text, "format": "text", "content_type": content_type} + + # Add status code to result + if isinstance(result, dict): + result["status_code"] = response.status + else: + result = { + "data": result, + "status_code": response.status, + "format": "unknown", + "content_type": content_type, + } + + # Add URL to result for tracking + result["url"] = str(url) + + return result diff --git a/config/config.example.toml b/config/config.example.toml index 51b8ead55..a96443e13 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -65,3 +65,10 @@ temperature = 0.0 # Controls randomness for vision mod # [search] # Search engine for agent to use. Default is "Google", can be set to "Baidu" or "DuckDuckGo". #engine = "Google" + +# Optional configuration for ANP tool +# [anp] +# Path to the DID document file +# did_document_path = "/path/to/your/did.json" +# Path to the private key file +# private_key_path = "/path/to/your/private-key.pem" diff --git a/config/did_test_public_doc/did.json b/config/did_test_public_doc/did.json new file mode 100644 index 000000000..69f75d3e6 --- /dev/null +++ b/config/did_test_public_doc/did.json @@ -0,0 +1,25 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/jws-2020/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1" + ], + "id": "did:wba:agent-did.com:test:public", + "verificationMethod": [ + { + "id": "did:wba:agent-did.com:test:public#key-1", + "type": "EcdsaSecp256k1VerificationKey2019", + "controller": "did:wba:agent-did.com:test:public", + "publicKeyJwk": { + "kty": "EC", + "crv": "secp256k1", + "x": "kNcfVufb4qxUpTC7kT6V56zSFEXbyo3nTDUFxqaZbKs", + "y": "upHpNoIm8h6cDRZqNWjb4VXbaniq2zz43yQoiR8Zfqs", + "kid": "4QOVubQtyJL_fzKreUfKDTOjrYAFRsq6XZ4Itqn2jLg" + } + } + ], + "authentication": [ + "did:wba:agent-did.com:test:public#key-1" + ] +} \ No newline at end of file diff --git a/config/did_test_public_doc/key-1_private.pem b/config/did_test_public_doc/key-1_private.pem new file mode 100644 index 000000000..6c84fc8bb --- /dev/null +++ b/config/did_test_public_doc/key-1_private.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgvt5dMwF4pTOFv1BBD1IV +ezSrGc0X/aDMesV57FeDvpKhRANCAASQ1x9W59virFSlMLuRPpXnrNIURdvKjedM +NQXGpplsq7qR6TaCJvIenA0WajVo2+FV22p4qts8+N8kKIkfGX6r +-----END PRIVATE KEY----- diff --git a/config/did_test_public_doc/private_keys.json b/config/did_test_public_doc/private_keys.json new file mode 100644 index 000000000..9f7c18f6f --- /dev/null +++ b/config/did_test_public_doc/private_keys.json @@ -0,0 +1,10 @@ +{ + "did": "did:wba:agent-did.com:test:public", + "created_at": "2025-03-04T22:14:16.233463", + "keys": { + "key-1": { + "path": "key-1_private.pem", + "type": "EcdsaSecp256k1" + } + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2dcaabda3..ac3560581 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,4 @@ aiofiles~=24.1.0 pydantic_core~=2.27.2 colorama~=0.4.6 playwright~=1.50.0 +agent-connect~=0.3.5