From 638f155a105c990a18119215006d68efea1e55f3 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Thu, 24 Apr 2025 21:27:20 +0800 Subject: [PATCH 1/9] Support A2A protocol --- A2A/Manus/README.md | 187 +++++++++++ A2A/Manus/README_zh.md | 187 +++++++++++ A2A/Manus/__init__.py | 0 A2A/Manus/__main__.py | 96 ++++++ A2A/Manus/agent.py | 32 ++ A2A/Manus/task_manager.py | 246 ++++++++++++++ A2A/__init__.py | 0 A2A/common/__init__.py | 0 A2A/common/client/__init__.py | 4 + A2A/common/client/card_resolver.py | 21 ++ A2A/common/client/client.py | 86 +++++ A2A/common/server/__init__.py | 4 + A2A/common/server/server.py | 120 +++++++ A2A/common/server/task_manager.py | 277 ++++++++++++++++ A2A/common/server/utils.py | 28 ++ A2A/common/types.py | 365 +++++++++++++++++++++ A2A/common/utils/in_memory_cache.py | 109 ++++++ A2A/common/utils/push_notification_auth.py | 135 ++++++++ A2A/requirement.txt | 0 19 files changed, 1897 insertions(+) create mode 100644 A2A/Manus/README.md create mode 100644 A2A/Manus/README_zh.md create mode 100644 A2A/Manus/__init__.py create mode 100644 A2A/Manus/__main__.py create mode 100644 A2A/Manus/agent.py create mode 100644 A2A/Manus/task_manager.py create mode 100644 A2A/__init__.py create mode 100644 A2A/common/__init__.py create mode 100644 A2A/common/client/__init__.py create mode 100644 A2A/common/client/card_resolver.py create mode 100644 A2A/common/client/client.py create mode 100644 A2A/common/server/__init__.py create mode 100644 A2A/common/server/server.py create mode 100644 A2A/common/server/task_manager.py create mode 100644 A2A/common/server/utils.py create mode 100644 A2A/common/types.py create mode 100644 A2A/common/utils/in_memory_cache.py create mode 100644 A2A/common/utils/push_notification_auth.py create mode 100644 A2A/requirement.txt diff --git a/A2A/Manus/README.md b/A2A/Manus/README.md new file mode 100644 index 0000000..8cd5fcf --- /dev/null +++ b/A2A/Manus/README.md @@ -0,0 +1,187 @@ +# Manus Agent with A2A Protocol + +This is an experimental integration of the A2A protocol (https://google.github.io/A2A/#/documentation) with OpenManus, currently supporting only non-streaming mode. + +## Prerequisites +- conda activate 'Your OpenManus python env' +- pip install -r requirements.txt + +## Setup & Running + +1. Run A2A Server: + + ```bash + cd OpenManus + python -m A2A.Manus.__main__ + ``` + +2. Clone A2A official repository and run A2A Client (details at https://github.com/google/A2A): + + ```bash + git clone https://github.com/google/A2A.git + cd A2A + echo "GOOGLE_API_KEY=your_api_key_here" > .env + cd samples/python/hosts/cli + uv run . + ``` + +3. Send tasks to OpenManus via A2A Client command line + + +## Examples + +**Get Agent Card** + +Request: + +``` +curl http://localhost:10000/.well-known/agent.json + +``` + + +``` +Response: + +{ + "name": "Manus Agent", + "description": "A versatile agent that can solve various tasks using multiple tools including MCP-based tools", + "url": "http://localhost:10000/", + "version": "1.0.0", + "capabilities": { + "streaming": false, + "pushNotifications": true, + "stateTransitionHistory": false + }, + "defaultInputModes": [ + "text", + "text/plain" + ], + "defaultOutputModes": [ + "text", + "text/plain" + ], + "skills": [ + { + "id": "Python Execute", + "name": "Python Execute Tool", + "description": "Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.", + "tags": [ + "Execute Python Code" + ], + "examples": [ + "Execute Python code:'''python \n Print('Hello World') \n '''" + ] + }, + { + "id": "Browser use", + "name": "Browser use Tool", + "description": "A powerful browser automation tool that allows interaction with web pages through various actions.\n* This tool provides commands for controlling a browser session, navigating web pages, and extracting information\n* It maintains state across calls, keeping the browser session alive until explicitly closed\n* Use this when you need to browse websites, fill forms, click buttons, extract content, or perform web searches\n* Each action requires specific parameters as defined in the tool's dependencies\n\nKey capabilities include:\n* Navigation: Go to specific URLs, go back, search the web, or refresh pages\n* Interaction: Click elements, input text, select from dropdowns, send keyboard commands\n* Scrolling: Scroll up/down by pixel amount or scroll to specific text\n* Content extraction: Extract and analyze content from web pages based on specific goals\n* Tab management: Switch between tabs, open new tabs, or close tabs\n\nNote: When using element indices, refer to the numbered elements shown in the current browser state.\n", + "tags": [ + "Use Browser" + ], + "examples": [ + "go_to 'https://www.google.com'" + ] + }, + { + "id": "Replace String", + "name": "Str_replace Tool", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n* The `undo_edit` command will revert the last edit made to the file at `path`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`\n", + "tags": [ + "Operate Files" + ], + "examples": [ + "Replace 'old' with 'new' in 'file.txt'" + ] + }, + { + "id": "Ask human", + "name": "Ask human Tool", + "description": "Use this tool to ask human for help.", + "tags": [ + "Ask human for help" + ], + "examples": [ + "Ask human: 'What time is it?'" + ] + }, + { + "id": "terminate", + "name": "terminate Tool", + "description": "Terminate the interaction when the request is met OR if the assistant cannot proceed further with the task.\nWhen you have finished all the tasks, call this tool to end the work.", + "tags": [ + "terminate task" + ], + "examples": [ + "terminate" + ] + } + ] +} +``` + +**Send Task** + +Request: + +``` +curl --location 'http://localhost:10000' \ +--header 'Content-Type: application/json' \ +--data '{ + "jsonrpc": "2.0", + "id": 10, + "method": "tasks/send", + "params": { + "id": "130", + "sessionId": "94dbdab49b2e44a9aac361cfaffbafd1", + "acceptedOutputModes": [ + "text" + ], + "message": { + "role": "user", + "parts": [ + { + "type": "text", + "text": "什么是快乐星球" + } + ] + } + } +}' +``` + +Response: + +``` +{ + "jsonrpc": "2.0", + "id": 10, + "result": { + "id": "130", + "sessionId": "94dbdab49b2e44a9aac361cfaffbafd1", + "status": { + "state": "completed", + "timestamp": "2025-04-24T22:34:06.219036" + }, + "artifacts": [ + { + "parts": [ + { + "type": "text", + "text": "Step 1: “快乐星球”是一个源自中国儿童电视剧《快乐星球》的概念。这部剧讲述了一群来自外星球的孩子在地球上的冒险故事,他们通过科技和智慧帮助地球上的孩子们解决问题,传递了友情、勇气和快乐的主题。\n\n“快乐星球”后来也成为了网络流行语,通常用来调侃或表达对某种理想化、无忧无虑状态的向往。比如,有人可能会问:“什么是快乐星球?”然后回答:“快乐星球就是没有烦恼的地方!”\n\n如果您是想了解这部剧的具体内容,或者想找相关的资源,我可以帮您进一步搜索或提供更多信息!\nTerminated: Reached max steps (1)" + } + ], + "index": 0 + } + ], + "history": [] + } +} +``` + + +## Learn More + +- [A2A Protocol Documentation](https://google.github.io/A2A/#/documentation) + diff --git a/A2A/Manus/README_zh.md b/A2A/Manus/README_zh.md new file mode 100644 index 0000000..31a8dc4 --- /dev/null +++ b/A2A/Manus/README_zh.md @@ -0,0 +1,187 @@ +# Manus Agent with A2A Protocol + +这是一个将A2A协议(https://google.github.io/A2A/#/documentation)与OpenManus结合的一个尝试,当前仅支持非流式 + +## Prerequisites +- conda activate 'Your OpenManus python env' +- pip install -r requirements.txt + +## Setup & Running + +1. 运行A2A Server: + + ```bash + cd OpenManus + python -m A2A.Manus.__main__ + ``` + +2. 拉取A2A官方库并运行A2A Client 详情参考(https://github.com/google/A2A): + + ```bash + git clone https://github.com/google/A2A.git + cd A2A + echo "GOOGLE_API_KEY=your_api_key_here" > .env + cd samples/python/hosts/cli + uv run . + ``` + +4. 通过A2A Client的命令行向OpenManus 发送任务 + + +## Examples + +**获得Agent Card** + +Request: + +``` +curl http://localhost:10000/.well-known/agent.json + +``` + + +``` +Response: + +{ + "name": "Manus Agent", + "description": "A versatile agent that can solve various tasks using multiple tools including MCP-based tools", + "url": "http://localhost:10000/", + "version": "1.0.0", + "capabilities": { + "streaming": false, + "pushNotifications": true, + "stateTransitionHistory": false + }, + "defaultInputModes": [ + "text", + "text/plain" + ], + "defaultOutputModes": [ + "text", + "text/plain" + ], + "skills": [ + { + "id": "Python Execute", + "name": "Python Execute Tool", + "description": "Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.", + "tags": [ + "Execute Python Code" + ], + "examples": [ + "Execute Python code:'''python \n Print('Hello World') \n '''" + ] + }, + { + "id": "Browser use", + "name": "Browser use Tool", + "description": "A powerful browser automation tool that allows interaction with web pages through various actions.\n* This tool provides commands for controlling a browser session, navigating web pages, and extracting information\n* It maintains state across calls, keeping the browser session alive until explicitly closed\n* Use this when you need to browse websites, fill forms, click buttons, extract content, or perform web searches\n* Each action requires specific parameters as defined in the tool's dependencies\n\nKey capabilities include:\n* Navigation: Go to specific URLs, go back, search the web, or refresh pages\n* Interaction: Click elements, input text, select from dropdowns, send keyboard commands\n* Scrolling: Scroll up/down by pixel amount or scroll to specific text\n* Content extraction: Extract and analyze content from web pages based on specific goals\n* Tab management: Switch between tabs, open new tabs, or close tabs\n\nNote: When using element indices, refer to the numbered elements shown in the current browser state.\n", + "tags": [ + "Use Browser" + ], + "examples": [ + "go_to 'https://www.google.com'" + ] + }, + { + "id": "Replace String", + "name": "Str_replace Tool", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n* The `undo_edit` command will revert the last edit made to the file at `path`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`\n", + "tags": [ + "Operate Files" + ], + "examples": [ + "Replace 'old' with 'new' in 'file.txt'" + ] + }, + { + "id": "Ask human", + "name": "Ask human Tool", + "description": "Use this tool to ask human for help.", + "tags": [ + "Ask human for help" + ], + "examples": [ + "Ask human: 'What time is it?'" + ] + }, + { + "id": "terminate", + "name": "terminate Tool", + "description": "Terminate the interaction when the request is met OR if the assistant cannot proceed further with the task.\nWhen you have finished all the tasks, call this tool to end the work.", + "tags": [ + "terminate task" + ], + "examples": [ + "terminate" + ] + } + ] +} +``` + +**发送任务** + +Request: + +``` +curl --location 'http://localhost:10000' \ +--header 'Content-Type: application/json' \ +--data '{ + "jsonrpc": "2.0", + "id": 10, + "method": "tasks/send", + "params": { + "id": "130", + "sessionId": "94dbdab49b2e44a9aac361cfaffbafd1", + "acceptedOutputModes": [ + "text" + ], + "message": { + "role": "user", + "parts": [ + { + "type": "text", + "text": "什么是快乐星球" + } + ] + } + } +}' +``` + +Response: + +``` +{ + "jsonrpc": "2.0", + "id": 10, + "result": { + "id": "130", + "sessionId": "94dbdab49b2e44a9aac361cfaffbafd1", + "status": { + "state": "completed", + "timestamp": "2025-04-24T22:34:06.219036" + }, + "artifacts": [ + { + "parts": [ + { + "type": "text", + "text": "Step 1: “快乐星球”是一个源自中国儿童电视剧《快乐星球》的概念。这部剧讲述了一群来自外星球的孩子在地球上的冒险故事,他们通过科技和智慧帮助地球上的孩子们解决问题,传递了友情、勇气和快乐的主题。\n\n“快乐星球”后来也成为了网络流行语,通常用来调侃或表达对某种理想化、无忧无虑状态的向往。比如,有人可能会问:“什么是快乐星球?”然后回答:“快乐星球就是没有烦恼的地方!”\n\n如果您是想了解这部剧的具体内容,或者想找相关的资源,我可以帮您进一步搜索或提供更多信息!\nTerminated: Reached max steps (1)" + } + ], + "index": 0 + } + ], + "history": [] + } +} +``` + + +## Learn More + +- [A2A Protocol Documentation](https://google.github.io/A2A/#/documentation) + diff --git a/A2A/Manus/__init__.py b/A2A/Manus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/A2A/Manus/__main__.py b/A2A/Manus/__main__.py new file mode 100644 index 0000000..6d0978d --- /dev/null +++ b/A2A/Manus/__main__.py @@ -0,0 +1,96 @@ +from A2A.common.server import A2AServer +from A2A.common.types import AgentCard, AgentCapabilities, AgentSkill, MissingAPIKeyError +from A2A.common.utils.push_notification_auth import PushNotificationSenderAuth +from A2A.Manus.task_manager import AgentTaskManager +from A2A.Manus.agent import ManusAgent +from app.tool.browser_use_tool import _BROWSER_DESCRIPTION +from app.tool.str_replace_editor import _STR_REPLACE_EDITOR_DESCRIPTION +from app.tool.terminate import _TERMINATE_DESCRIPTION +import click +import os +import logging +from dotenv import load_dotenv + +load_dotenv() + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@click.command() +@click.option("--host", "host", default="localhost") +@click.option("--port", "port", default=10000) +def main(host, port): + """Starts the Manus Agent server.""" + try: + capabilities = AgentCapabilities(streamiMng=False, pushNotifications=True) + Python_execute_skill = AgentSkill( + id="Python Execute", + name="Python Execute Tool", + description="Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.", + tags=["Execute Python Code"], + examples=["Execute Python code:'''python \n Print('Hello World') \n '''"], + ) + Browser_use_skill = AgentSkill( + id="Browser use", + name="Browser use Tool", + description=_BROWSER_DESCRIPTION, + tags=["Use Browser"], + examples=["go_to 'https://www.google.com'"], + ) + Str_replace_skill = AgentSkill( + id="Replace String", + name="Str_replace Tool", + description=_STR_REPLACE_EDITOR_DESCRIPTION, + tags=["Operate Files"], + examples=["Replace 'old' with 'new' in 'file.txt'"], + ) + Ask_human_skill = AgentSkill( + id="Ask human", + name="Ask human Tool", + description="Use this tool to ask human for help.", + tags=["Ask human for help"], + examples=["Ask human: 'What time is it?'"], + ) + Terminate_skill = AgentSkill( + id="terminate", + name="terminate Tool", + description=_TERMINATE_DESCRIPTION, + tags=["terminate task"], + examples=["terminate"], + ) + agent_card = AgentCard( + name="Manus Agent", + description="A versatile agent that can solve various tasks using multiple tools including MCP-based tools", + url=f"http://{host}:{port}/", + version="1.0.0", + defaultInputModes=ManusAgent.SUPPORTED_CONTENT_TYPES, + defaultOutputModes=ManusAgent.SUPPORTED_CONTENT_TYPES, + capabilities=capabilities, + skills=[Python_execute_skill,Browser_use_skill,Str_replace_skill,Ask_human_skill,Terminate_skill], + ) + + notification_sender_auth = PushNotificationSenderAuth() + notification_sender_auth.generate_jwk() + server = A2AServer( + agent_card=agent_card, + task_manager=AgentTaskManager(agent=ManusAgent(), notification_sender_auth=notification_sender_auth), + host=host, + port=port, + ) + + server.app.add_route( + "/.well-known/jwks.json", notification_sender_auth.handle_jwks_endpoint, methods=["GET"] + ) + + logger.info(f"Starting server on {host}:{port}") + server.start() + except MissingAPIKeyError as e: + logger.error(f"Error: {e}") + exit(1) + except Exception as e: + logger.error(f"An error occurred during server startup: {e}") + exit(1) + + +if __name__ == "__main__": + main() diff --git a/A2A/Manus/agent.py b/A2A/Manus/agent.py new file mode 100644 index 0000000..8ab2111 --- /dev/null +++ b/A2A/Manus/agent.py @@ -0,0 +1,32 @@ +import httpx +from typing import Any, Dict, AsyncIterable, Literal +from pydantic import BaseModel +from app.agent.manus import Manus + +class ResponseFormat(BaseModel): + """Respond to the user in this format.""" + status: Literal["input_required", "completed", "error"] = "input_required" + message: str + +class ManusAgent: + def __init__(self): + self.agent = Manus() + + async def invoke(self, query, sessionId) -> str: + config = {"configurable": {"thread_id": sessionId}} + response = await self.agent.run(query) + return self.get_agent_response(config,response) + + async def stream(self, query: str) -> AsyncIterable[Dict[str, Any]]: + """Streaming is not supported by Manus.""" + raise NotImplementedError("Streaming is not supported by Manus yet.") + + + def get_agent_response(self, config,agent_response): + return { + "is_task_complete": True, + "require_user_input": False, + "content": agent_response, + } + + SUPPORTED_CONTENT_TYPES = ["text", "text/plain"] diff --git a/A2A/Manus/task_manager.py b/A2A/Manus/task_manager.py new file mode 100644 index 0000000..637eb9f --- /dev/null +++ b/A2A/Manus/task_manager.py @@ -0,0 +1,246 @@ +from typing import AsyncIterable +from A2A.common.types import ( + SendTaskRequest, + TaskSendParams, + Message, + TaskStatus, + Artifact, + TextPart, + TaskState, + SendTaskResponse, + InternalError, + JSONRPCResponse, + SendTaskStreamingRequest, + SendTaskStreamingResponse, + TaskArtifactUpdateEvent, + TaskStatusUpdateEvent, + Task, + TaskIdParams, + PushNotificationConfig, + SetTaskPushNotificationRequest, + SetTaskPushNotificationResponse, + TaskPushNotificationConfig, + TaskNotFoundError, + InvalidParamsError, +) +from A2A.common.server.task_manager import InMemoryTaskManager +from A2A.Manus.agent import ManusAgent +from A2A.common.utils.push_notification_auth import PushNotificationSenderAuth +import A2A.common.server.utils as utils +from typing import Union +import asyncio +import logging +import traceback + +logger = logging.getLogger(__name__) + + +class AgentTaskManager(InMemoryTaskManager): + def __init__(self, agent: ManusAgent, notification_sender_auth: PushNotificationSenderAuth): + super().__init__() + self.agent = agent + self.notification_sender_auth = notification_sender_auth + + async def _run_streaming_agent(self, request: SendTaskStreamingRequest): + task_send_params: TaskSendParams = request.params + query = self._get_user_query(task_send_params) + + try: + async for item in self.agent.stream(query, task_send_params.sessionId): + is_task_complete = item["is_task_complete"] + require_user_input = item["require_user_input"] + artifact = None + message = None + parts = [{"type": "text", "text": item["content"]}] + end_stream = False + + if not is_task_complete and not require_user_input: + task_state = TaskState.WORKING + message = Message(role="agent", parts=parts) + elif require_user_input: + task_state = TaskState.INPUT_REQUIRED + message = Message(role="agent", parts=parts) + end_stream = True + else: + task_state = TaskState.COMPLETED + artifact = Artifact(parts=parts, index=0, append=False) + end_stream = True + + task_status = TaskStatus(state=task_state, message=message) + latest_task = await self.update_store( + task_send_params.id, + task_status, + None if artifact is None else [artifact], + ) + await self.send_task_notification(latest_task) + + if artifact: + task_artifact_update_event = TaskArtifactUpdateEvent( + id=task_send_params.id, artifact=artifact + ) + await self.enqueue_events_for_sse( + task_send_params.id, task_artifact_update_event + ) + + + task_update_event = TaskStatusUpdateEvent( + id=task_send_params.id, status=task_status, final=end_stream + ) + await self.enqueue_events_for_sse( + task_send_params.id, task_update_event + ) + + except Exception as e: + logger.error(f"An error occurred while streaming the response: {e}") + await self.enqueue_events_for_sse( + task_send_params.id, + InternalError(message=f"An error occurred while streaming the response: {e}") + ) + + def _validate_request( + self, request: Union[SendTaskRequest, SendTaskStreamingRequest] + ) -> JSONRPCResponse | None: + task_send_params: TaskSendParams = request.params + if not utils.are_modalities_compatible( + task_send_params.acceptedOutputModes, ManusAgent.SUPPORTED_CONTENT_TYPES + ): + logger.warning( + "Unsupported output mode. Received %s, Support %s", + task_send_params.acceptedOutputModes, + ManusAgent.SUPPORTED_CONTENT_TYPES, + ) + return utils.new_incompatible_types_error(request.id) + + if task_send_params.pushNotification and not task_send_params.pushNotification.url: + logger.warning("Push notification URL is missing") + return JSONRPCResponse(id=request.id, error=InvalidParamsError(message="Push notification URL is missing")) + + return None + + async def on_send_task(self, request: SendTaskRequest) -> SendTaskResponse: + """Handles the 'send task' request.""" + validation_error = self._validate_request(request) + if validation_error: + return SendTaskResponse(id=request.id, error=validation_error.error) + + if request.params.pushNotification: + if not await self.set_push_notification_info(request.params.id, request.params.pushNotification): + return SendTaskResponse(id=request.id, error=InvalidParamsError(message="Push notification URL is invalid")) + + await self.upsert_task(request.params) + task = await self.update_store( + request.params.id, TaskStatus(state=TaskState.WORKING), None + ) + await self.send_task_notification(task) + + task_send_params: TaskSendParams = request.params + query = self._get_user_query(task_send_params) + try: + agent_response = await self.agent.invoke(query, task_send_params.sessionId) + except Exception as e: + logger.error(f"Error invoking agent: {e}") + raise ValueError(f"Error invoking agent: {e}") + return await self._process_agent_response( + request, agent_response + ) + + async def on_send_task_subscribe( + self, request: SendTaskStreamingRequest + ) -> AsyncIterable[SendTaskStreamingResponse] | JSONRPCResponse: + try: + error = self._validate_request(request) + if error: + return error + + await self.upsert_task(request.params) + + if request.params.pushNotification: + if not await self.set_push_notification_info(request.params.id, request.params.pushNotification): + return JSONRPCResponse(id=request.id, error=InvalidParamsError(message="Push notification URL is invalid")) + + task_send_params: TaskSendParams = request.params + sse_event_queue = await self.setup_sse_consumer(task_send_params.id, False) + + asyncio.create_task(self._run_streaming_agent(request)) + + return self.dequeue_events_for_sse( + request.id, task_send_params.id, sse_event_queue + ) + except Exception as e: + logger.error(f"Error in SSE stream: {e}") + print(traceback.format_exc()) + return JSONRPCResponse( + id=request.id, + error=InternalError( + message="An error occurred while streaming the response" + ), + ) + + async def _process_agent_response( + self, request: SendTaskRequest, agent_response: dict + ) -> SendTaskResponse: + """Processes the agent's response and updates the task store.""" + task_send_params: TaskSendParams = request.params + task_id = task_send_params.id + history_length = task_send_params.historyLength + task_status = None + + parts = [{"type": "text", "text": agent_response["content"]}] + artifact = None + if agent_response["require_user_input"]: + task_status = TaskStatus( + state=TaskState.INPUT_REQUIRED, + message=Message(role="agent", parts=parts), + ) + else: + task_status = TaskStatus(state=TaskState.COMPLETED) + artifact = Artifact(parts=parts) + task = await self.update_store( + task_id, task_status, None if artifact is None else [artifact] + ) + task_result = self.append_task_history(task, history_length) + await self.send_task_notification(task) + return SendTaskResponse(id=request.id, result=task_result) + + def _get_user_query(self, task_send_params: TaskSendParams) -> str: + part = task_send_params.message.parts[0] + if not isinstance(part, TextPart): + raise ValueError("Only text parts are supported") + return part.text + + async def send_task_notification(self, task: Task): + if not await self.has_push_notification_info(task.id): + logger.info(f"No push notification info found for task {task.id}") + return + push_info = await self.get_push_notification_info(task.id) + + logger.info(f"Notifying for task {task.id} => {task.status.state}") + await self.notification_sender_auth.send_push_notification( + push_info.url, + data=task.model_dump(exclude_none=True) + ) + + async def on_resubscribe_to_task( + self, request + ) -> AsyncIterable[SendTaskStreamingResponse] | JSONRPCResponse: + task_id_params: TaskIdParams = request.params + try: + sse_event_queue = await self.setup_sse_consumer(task_id_params.id, True) + return self.dequeue_events_for_sse(request.id, task_id_params.id, sse_event_queue) + except Exception as e: + logger.error(f"Error while reconnecting to SSE stream: {e}") + return JSONRPCResponse( + id=request.id, + error=InternalError( + message=f"An error occurred while reconnecting to stream: {e}" + ), + ) + + async def set_push_notification_info(self, task_id: str, push_notification_config: PushNotificationConfig): + # Verify the ownership of notification URL by issuing a challenge request. + is_verified = await self.notification_sender_auth.verify_push_notification_url(push_notification_config.url) + if not is_verified: + return False + + await super().set_push_notification_info(task_id, push_notification_config) + return True diff --git a/A2A/__init__.py b/A2A/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/A2A/common/__init__.py b/A2A/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/A2A/common/client/__init__.py b/A2A/common/client/__init__.py new file mode 100644 index 0000000..5aa0e0d --- /dev/null +++ b/A2A/common/client/__init__.py @@ -0,0 +1,4 @@ +from .client import A2AClient +from .card_resolver import A2ACardResolver + +__all__ = ["A2AClient", "A2ACardResolver"] diff --git a/A2A/common/client/card_resolver.py b/A2A/common/client/card_resolver.py new file mode 100644 index 0000000..dd5873c --- /dev/null +++ b/A2A/common/client/card_resolver.py @@ -0,0 +1,21 @@ +import httpx +from common.types import ( + AgentCard, + A2AClientJSONError, +) +import json + + +class A2ACardResolver: + def __init__(self, base_url, agent_card_path="/.well-known/agent.json"): + self.base_url = base_url.rstrip("/") + self.agent_card_path = agent_card_path.lstrip("/") + + def get_agent_card(self) -> AgentCard: + with httpx.Client() as client: + response = client.get(self.base_url + "/" + self.agent_card_path) + response.raise_for_status() + try: + return AgentCard(**response.json()) + except json.JSONDecodeError as e: + raise A2AClientJSONError(str(e)) from e diff --git a/A2A/common/client/client.py b/A2A/common/client/client.py new file mode 100644 index 0000000..5e969d1 --- /dev/null +++ b/A2A/common/client/client.py @@ -0,0 +1,86 @@ +import httpx +from httpx_sse import connect_sse +from typing import Any, AsyncIterable +from common.types import ( + AgentCard, + GetTaskRequest, + SendTaskRequest, + SendTaskResponse, + JSONRPCRequest, + GetTaskResponse, + CancelTaskResponse, + CancelTaskRequest, + SetTaskPushNotificationRequest, + SetTaskPushNotificationResponse, + GetTaskPushNotificationRequest, + GetTaskPushNotificationResponse, + A2AClientHTTPError, + A2AClientJSONError, + SendTaskStreamingRequest, + SendTaskStreamingResponse, +) +import json + + +class A2AClient: + def __init__(self, agent_card: AgentCard = None, url: str = None): + if agent_card: + self.url = agent_card.url + elif url: + self.url = url + else: + raise ValueError("Must provide either agent_card or url") + + async def send_task(self, payload: dict[str, Any]) -> SendTaskResponse: + request = SendTaskRequest(params=payload) + return SendTaskResponse(**await self._send_request(request)) + + async def send_task_streaming( + self, payload: dict[str, Any] + ) -> AsyncIterable[SendTaskStreamingResponse]: + request = SendTaskStreamingRequest(params=payload) + with httpx.Client(timeout=None) as client: + with connect_sse( + client, "POST", self.url, json=request.model_dump() + ) as event_source: + try: + for sse in event_source.iter_sse(): + yield SendTaskStreamingResponse(**json.loads(sse.data)) + except json.JSONDecodeError as e: + raise A2AClientJSONError(str(e)) from e + except httpx.RequestError as e: + raise A2AClientHTTPError(400, str(e)) from e + + async def _send_request(self, request: JSONRPCRequest) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + try: + # Image generation could take time, adding timeout + response = await client.post( + self.url, json=request.model_dump(), timeout=30 + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + raise A2AClientHTTPError(e.response.status_code, str(e)) from e + except json.JSONDecodeError as e: + raise A2AClientJSONError(str(e)) from e + + async def get_task(self, payload: dict[str, Any]) -> GetTaskResponse: + request = GetTaskRequest(params=payload) + return GetTaskResponse(**await self._send_request(request)) + + async def cancel_task(self, payload: dict[str, Any]) -> CancelTaskResponse: + request = CancelTaskRequest(params=payload) + return CancelTaskResponse(**await self._send_request(request)) + + async def set_task_callback( + self, payload: dict[str, Any] + ) -> SetTaskPushNotificationResponse: + request = SetTaskPushNotificationRequest(params=payload) + return SetTaskPushNotificationResponse(**await self._send_request(request)) + + async def get_task_callback( + self, payload: dict[str, Any] + ) -> GetTaskPushNotificationResponse: + request = GetTaskPushNotificationRequest(params=payload) + return GetTaskPushNotificationResponse(**await self._send_request(request)) diff --git a/A2A/common/server/__init__.py b/A2A/common/server/__init__.py new file mode 100644 index 0000000..10f5fa4 --- /dev/null +++ b/A2A/common/server/__init__.py @@ -0,0 +1,4 @@ +from .server import A2AServer +from .task_manager import TaskManager, InMemoryTaskManager + +__all__ = ["A2AServer", "TaskManager", "InMemoryTaskManager"] diff --git a/A2A/common/server/server.py b/A2A/common/server/server.py new file mode 100644 index 0000000..3f78f1b --- /dev/null +++ b/A2A/common/server/server.py @@ -0,0 +1,120 @@ +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from sse_starlette.sse import EventSourceResponse +from starlette.requests import Request +from A2A.common.types import ( + A2ARequest, + JSONRPCResponse, + InvalidRequestError, + JSONParseError, + GetTaskRequest, + CancelTaskRequest, + SendTaskRequest, + SetTaskPushNotificationRequest, + GetTaskPushNotificationRequest, + InternalError, + AgentCard, + TaskResubscriptionRequest, + SendTaskStreamingRequest, +) +from pydantic import ValidationError +import json +from typing import AsyncIterable, Any +from A2A.common.server.task_manager import TaskManager + +import logging + +logger = logging.getLogger(__name__) + + +class A2AServer: + def __init__( + self, + host="0.0.0.0", + port=5000, + endpoint="/", + agent_card: AgentCard = None, + task_manager: TaskManager = None, + ): + self.host = host + self.port = port + self.endpoint = endpoint + self.task_manager = task_manager + self.agent_card = agent_card + self.app = Starlette() + self.app.add_route(self.endpoint, self._process_request, methods=["POST"]) + self.app.add_route( + "/.well-known/agent.json", self._get_agent_card, methods=["GET"] + ) + + def start(self): + if self.agent_card is None: + raise ValueError("agent_card is not defined") + + if self.task_manager is None: + raise ValueError("request_handler is not defined") + + import uvicorn + + uvicorn.run(self.app, host=self.host, port=self.port) + + def _get_agent_card(self, request: Request) -> JSONResponse: + return JSONResponse(self.agent_card.model_dump(exclude_none=True)) + + async def _process_request(self, request: Request): + try: + body = await request.json() + json_rpc_request = A2ARequest.validate_python(body) + + if isinstance(json_rpc_request, GetTaskRequest): + result = await self.task_manager.on_get_task(json_rpc_request) + elif isinstance(json_rpc_request, SendTaskRequest): + result = await self.task_manager.on_send_task(json_rpc_request) + elif isinstance(json_rpc_request, SendTaskStreamingRequest): + result = await self.task_manager.on_send_task_subscribe( + json_rpc_request + ) + elif isinstance(json_rpc_request, CancelTaskRequest): + result = await self.task_manager.on_cancel_task(json_rpc_request) + elif isinstance(json_rpc_request, SetTaskPushNotificationRequest): + result = await self.task_manager.on_set_task_push_notification(json_rpc_request) + elif isinstance(json_rpc_request, GetTaskPushNotificationRequest): + result = await self.task_manager.on_get_task_push_notification(json_rpc_request) + elif isinstance(json_rpc_request, TaskResubscriptionRequest): + result = await self.task_manager.on_resubscribe_to_task( + json_rpc_request + ) + else: + logger.warning(f"Unexpected request type: {type(json_rpc_request)}") + raise ValueError(f"Unexpected request type: {type(request)}") + + return self._create_response(result) + + except Exception as e: + return self._handle_exception(e) + + def _handle_exception(self, e: Exception) -> JSONResponse: + if isinstance(e, json.decoder.JSONDecodeError): + json_rpc_error = JSONParseError() + elif isinstance(e, ValidationError): + json_rpc_error = InvalidRequestError(data=json.loads(e.json())) + else: + logger.error(f"Unhandled exception: {e}") + json_rpc_error = InternalError() + + response = JSONRPCResponse(id=None, error=json_rpc_error) + return JSONResponse(response.model_dump(exclude_none=True), status_code=400) + + def _create_response(self, result: Any) -> JSONResponse | EventSourceResponse: + if isinstance(result, AsyncIterable): + + async def event_generator(result) -> AsyncIterable[dict[str, str]]: + async for item in result: + yield {"data": item.model_dump_json(exclude_none=True)} + + return EventSourceResponse(event_generator(result)) + elif isinstance(result, JSONRPCResponse): + return JSONResponse(result.model_dump(exclude_none=True)) + else: + logger.error(f"Unexpected result type: {type(result)}") + raise ValueError(f"Unexpected result type: {type(result)}") diff --git a/A2A/common/server/task_manager.py b/A2A/common/server/task_manager.py new file mode 100644 index 0000000..54f5ee4 --- /dev/null +++ b/A2A/common/server/task_manager.py @@ -0,0 +1,277 @@ +from abc import ABC, abstractmethod +from typing import Union, AsyncIterable, List +from A2A.common.types import Task +from A2A.common.types import ( + JSONRPCResponse, + TaskIdParams, + TaskQueryParams, + GetTaskRequest, + TaskNotFoundError, + SendTaskRequest, + CancelTaskRequest, + TaskNotCancelableError, + SetTaskPushNotificationRequest, + GetTaskPushNotificationRequest, + GetTaskResponse, + CancelTaskResponse, + SendTaskResponse, + SetTaskPushNotificationResponse, + GetTaskPushNotificationResponse, + PushNotificationNotSupportedError, + TaskSendParams, + TaskStatus, + TaskState, + TaskResubscriptionRequest, + SendTaskStreamingRequest, + SendTaskStreamingResponse, + Artifact, + PushNotificationConfig, + TaskStatusUpdateEvent, + JSONRPCError, + TaskPushNotificationConfig, + InternalError, +) +from A2A.common.server.utils import new_not_implemented_error +import asyncio +import logging + +logger = logging.getLogger(__name__) + +class TaskManager(ABC): + @abstractmethod + async def on_get_task(self, request: GetTaskRequest) -> GetTaskResponse: + pass + + @abstractmethod + async def on_cancel_task(self, request: CancelTaskRequest) -> CancelTaskResponse: + pass + + @abstractmethod + async def on_send_task(self, request: SendTaskRequest) -> SendTaskResponse: + pass + + @abstractmethod + async def on_send_task_subscribe( + self, request: SendTaskStreamingRequest + ) -> Union[AsyncIterable[SendTaskStreamingResponse], JSONRPCResponse]: + pass + + @abstractmethod + async def on_set_task_push_notification( + self, request: SetTaskPushNotificationRequest + ) -> SetTaskPushNotificationResponse: + pass + + @abstractmethod + async def on_get_task_push_notification( + self, request: GetTaskPushNotificationRequest + ) -> GetTaskPushNotificationResponse: + pass + + @abstractmethod + async def on_resubscribe_to_task( + self, request: TaskResubscriptionRequest + ) -> Union[AsyncIterable[SendTaskResponse], JSONRPCResponse]: + pass + + +class InMemoryTaskManager(TaskManager): + def __init__(self): + self.tasks: dict[str, Task] = {} + self.push_notification_infos: dict[str, PushNotificationConfig] = {} + self.lock = asyncio.Lock() + self.task_sse_subscribers: dict[str, List[asyncio.Queue]] = {} + self.subscriber_lock = asyncio.Lock() + + async def on_get_task(self, request: GetTaskRequest) -> GetTaskResponse: + logger.info(f"Getting task {request.params.id}") + task_query_params: TaskQueryParams = request.params + + async with self.lock: + task = self.tasks.get(task_query_params.id) + if task is None: + return GetTaskResponse(id=request.id, error=TaskNotFoundError()) + + task_result = self.append_task_history( + task, task_query_params.historyLength + ) + + return GetTaskResponse(id=request.id, result=task_result) + + async def on_cancel_task(self, request: CancelTaskRequest) -> CancelTaskResponse: + logger.info(f"Cancelling task {request.params.id}") + task_id_params: TaskIdParams = request.params + + async with self.lock: + task = self.tasks.get(task_id_params.id) + if task is None: + return CancelTaskResponse(id=request.id, error=TaskNotFoundError()) + + return CancelTaskResponse(id=request.id, error=TaskNotCancelableError()) + + @abstractmethod + async def on_send_task(self, request: SendTaskRequest) -> SendTaskResponse: + pass + + @abstractmethod + async def on_send_task_subscribe( + self, request: SendTaskStreamingRequest + ) -> Union[AsyncIterable[SendTaskStreamingResponse], JSONRPCResponse]: + pass + + async def set_push_notification_info(self, task_id: str, notification_config: PushNotificationConfig): + async with self.lock: + task = self.tasks.get(task_id) + if task is None: + raise ValueError(f"Task not found for {task_id}") + + self.push_notification_infos[task_id] = notification_config + + return + + async def get_push_notification_info(self, task_id: str) -> PushNotificationConfig: + async with self.lock: + task = self.tasks.get(task_id) + if task is None: + raise ValueError(f"Task not found for {task_id}") + + return self.push_notification_infos[task_id] + + return + + async def has_push_notification_info(self, task_id: str) -> bool: + async with self.lock: + return task_id in self.push_notification_infos + + + async def on_set_task_push_notification( + self, request: SetTaskPushNotificationRequest + ) -> SetTaskPushNotificationResponse: + logger.info(f"Setting task push notification {request.params.id}") + task_notification_params: TaskPushNotificationConfig = request.params + + try: + await self.set_push_notification_info(task_notification_params.id, task_notification_params.pushNotificationConfig) + except Exception as e: + logger.error(f"Error while setting push notification info: {e}") + return JSONRPCResponse( + id=request.id, + error=InternalError( + message="An error occurred while setting push notification info" + ), + ) + + return SetTaskPushNotificationResponse(id=request.id, result=task_notification_params) + + async def on_get_task_push_notification( + self, request: GetTaskPushNotificationRequest + ) -> GetTaskPushNotificationResponse: + logger.info(f"Getting task push notification {request.params.id}") + task_params: TaskIdParams = request.params + + try: + notification_info = await self.get_push_notification_info(task_params.id) + except Exception as e: + logger.error(f"Error while getting push notification info: {e}") + return GetTaskPushNotificationResponse( + id=request.id, + error=InternalError( + message="An error occurred while getting push notification info" + ), + ) + + return GetTaskPushNotificationResponse(id=request.id, result=TaskPushNotificationConfig(id=task_params.id, pushNotificationConfig=notification_info)) + + async def upsert_task(self, task_send_params: TaskSendParams) -> Task: + logger.info(f"Upserting task {task_send_params.id}") + async with self.lock: + task = self.tasks.get(task_send_params.id) + if task is None: + task = Task( + id=task_send_params.id, + sessionId = task_send_params.sessionId, + messages=[task_send_params.message], + status=TaskStatus(state=TaskState.SUBMITTED), + history=[task_send_params.message], + ) + self.tasks[task_send_params.id] = task + else: + task.history.append(task_send_params.message) + + return task + + async def on_resubscribe_to_task( + self, request: TaskResubscriptionRequest + ) -> Union[AsyncIterable[SendTaskStreamingResponse], JSONRPCResponse]: + return new_not_implemented_error(request.id) + + async def update_store( + self, task_id: str, status: TaskStatus, artifacts: list[Artifact] + ) -> Task: + async with self.lock: + try: + task = self.tasks[task_id] + except KeyError: + logger.error(f"Task {task_id} not found for updating the task") + raise ValueError(f"Task {task_id} not found") + + task.status = status + + if status.message is not None: + task.history.append(status.message) + + if artifacts is not None: + if task.artifacts is None: + task.artifacts = [] + task.artifacts.extend(artifacts) + + return task + + def append_task_history(self, task: Task, historyLength: int | None): + new_task = task.model_copy() + if historyLength is not None and historyLength > 0: + new_task.history = new_task.history[-historyLength:] + else: + new_task.history = [] + + return new_task + + async def setup_sse_consumer(self, task_id: str, is_resubscribe: bool = False): + async with self.subscriber_lock: + if task_id not in self.task_sse_subscribers: + if is_resubscribe: + raise ValueError("Task not found for resubscription") + else: + self.task_sse_subscribers[task_id] = [] + + sse_event_queue = asyncio.Queue(maxsize=0) # <=0 is unlimited + self.task_sse_subscribers[task_id].append(sse_event_queue) + return sse_event_queue + + async def enqueue_events_for_sse(self, task_id, task_update_event): + async with self.subscriber_lock: + if task_id not in self.task_sse_subscribers: + return + + current_subscribers = self.task_sse_subscribers[task_id] + for subscriber in current_subscribers: + await subscriber.put(task_update_event) + + async def dequeue_events_for_sse( + self, request_id, task_id, sse_event_queue: asyncio.Queue + ) -> AsyncIterable[SendTaskStreamingResponse] | JSONRPCResponse: + try: + while True: + event = await sse_event_queue.get() + if isinstance(event, JSONRPCError): + yield SendTaskStreamingResponse(id=request_id, error=event) + break + + yield SendTaskStreamingResponse(id=request_id, result=event) + if isinstance(event, TaskStatusUpdateEvent) and event.final: + break + finally: + async with self.subscriber_lock: + if task_id in self.task_sse_subscribers: + self.task_sse_subscribers[task_id].remove(sse_event_queue) + diff --git a/A2A/common/server/utils.py b/A2A/common/server/utils.py new file mode 100644 index 0000000..0727a5b --- /dev/null +++ b/A2A/common/server/utils.py @@ -0,0 +1,28 @@ +from A2A.common.types import ( + JSONRPCResponse, + ContentTypeNotSupportedError, + UnsupportedOperationError, +) +from typing import List + + +def are_modalities_compatible( + server_output_modes: List[str], client_output_modes: List[str] +): + """Modalities are compatible if they are both non-empty + and there is at least one common element.""" + if client_output_modes is None or len(client_output_modes) == 0: + return True + + if server_output_modes is None or len(server_output_modes) == 0: + return True + + return any(x in server_output_modes for x in client_output_modes) + + +def new_incompatible_types_error(request_id): + return JSONRPCResponse(id=request_id, error=ContentTypeNotSupportedError()) + + +def new_not_implemented_error(request_id): + return JSONRPCResponse(id=request_id, error=UnsupportedOperationError()) diff --git a/A2A/common/types.py b/A2A/common/types.py new file mode 100644 index 0000000..585fec3 --- /dev/null +++ b/A2A/common/types.py @@ -0,0 +1,365 @@ +from typing import Union, Any +from pydantic import BaseModel, Field, TypeAdapter +from typing import Literal, List, Annotated, Optional +from datetime import datetime +from pydantic import model_validator, ConfigDict, field_serializer +from uuid import uuid4 +from enum import Enum +from typing_extensions import Self + + +class TaskState(str, Enum): + SUBMITTED = "submitted" + WORKING = "working" + INPUT_REQUIRED = "input-required" + COMPLETED = "completed" + CANCELED = "canceled" + FAILED = "failed" + UNKNOWN = "unknown" + + +class TextPart(BaseModel): + type: Literal["text"] = "text" + text: str + metadata: dict[str, Any] | None = None + + +class FileContent(BaseModel): + name: str | None = None + mimeType: str | None = None + bytes: str | None = None + uri: str | None = None + + @model_validator(mode="after") + def check_content(self) -> Self: + if not (self.bytes or self.uri): + raise ValueError("Either 'bytes' or 'uri' must be present in the file data") + if self.bytes and self.uri: + raise ValueError( + "Only one of 'bytes' or 'uri' can be present in the file data" + ) + return self + + +class FilePart(BaseModel): + type: Literal["file"] = "file" + file: FileContent + metadata: dict[str, Any] | None = None + + +class DataPart(BaseModel): + type: Literal["data"] = "data" + data: dict[str, Any] + metadata: dict[str, Any] | None = None + + +Part = Annotated[Union[TextPart, FilePart, DataPart], Field(discriminator="type")] + + +class Message(BaseModel): + role: Literal["user", "agent"] + parts: List[Part] + metadata: dict[str, Any] | None = None + + +class TaskStatus(BaseModel): + state: TaskState + message: Message | None = None + timestamp: datetime = Field(default_factory=datetime.now) + + @field_serializer("timestamp") + def serialize_dt(self, dt: datetime, _info): + return dt.isoformat() + + +class Artifact(BaseModel): + name: str | None = None + description: str | None = None + parts: List[Part] + metadata: dict[str, Any] | None = None + index: int = 0 + append: bool | None = None + lastChunk: bool | None = None + + +class Task(BaseModel): + id: str + sessionId: str | None = None + status: TaskStatus + artifacts: List[Artifact] | None = None + history: List[Message] | None = None + metadata: dict[str, Any] | None = None + + +class TaskStatusUpdateEvent(BaseModel): + id: str + status: TaskStatus + final: bool = False + metadata: dict[str, Any] | None = None + + +class TaskArtifactUpdateEvent(BaseModel): + id: str + artifact: Artifact + metadata: dict[str, Any] | None = None + + +class AuthenticationInfo(BaseModel): + model_config = ConfigDict(extra="allow") + + schemes: List[str] + credentials: str | None = None + + +class PushNotificationConfig(BaseModel): + url: str + token: str | None = None + authentication: AuthenticationInfo | None = None + + +class TaskIdParams(BaseModel): + id: str + metadata: dict[str, Any] | None = None + + +class TaskQueryParams(TaskIdParams): + historyLength: int | None = None + + +class TaskSendParams(BaseModel): + id: str + sessionId: str = Field(default_factory=lambda: uuid4().hex) + message: Message + acceptedOutputModes: Optional[List[str]] = None + pushNotification: PushNotificationConfig | None = None + historyLength: int | None = None + metadata: dict[str, Any] | None = None + + +class TaskPushNotificationConfig(BaseModel): + id: str + pushNotificationConfig: PushNotificationConfig + + +## RPC Messages + + +class JSONRPCMessage(BaseModel): + jsonrpc: Literal["2.0"] = "2.0" + id: int | str | None = Field(default_factory=lambda: uuid4().hex) + + +class JSONRPCRequest(JSONRPCMessage): + method: str + params: dict[str, Any] | None = None + + +class JSONRPCError(BaseModel): + code: int + message: str + data: Any | None = None + + +class JSONRPCResponse(JSONRPCMessage): + result: Any | None = None + error: JSONRPCError | None = None + + +class SendTaskRequest(JSONRPCRequest): + method: Literal["tasks/send"] = "tasks/send" + params: TaskSendParams + + +class SendTaskResponse(JSONRPCResponse): + result: Task | None = None + + +class SendTaskStreamingRequest(JSONRPCRequest): + method: Literal["tasks/sendSubscribe"] = "tasks/sendSubscribe" + params: TaskSendParams + + +class SendTaskStreamingResponse(JSONRPCResponse): + result: TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None = None + + +class GetTaskRequest(JSONRPCRequest): + method: Literal["tasks/get"] = "tasks/get" + params: TaskQueryParams + + +class GetTaskResponse(JSONRPCResponse): + result: Task | None = None + + +class CancelTaskRequest(JSONRPCRequest): + method: Literal["tasks/cancel",] = "tasks/cancel" + params: TaskIdParams + + +class CancelTaskResponse(JSONRPCResponse): + result: Task | None = None + + +class SetTaskPushNotificationRequest(JSONRPCRequest): + method: Literal["tasks/pushNotification/set",] = "tasks/pushNotification/set" + params: TaskPushNotificationConfig + + +class SetTaskPushNotificationResponse(JSONRPCResponse): + result: TaskPushNotificationConfig | None = None + + +class GetTaskPushNotificationRequest(JSONRPCRequest): + method: Literal["tasks/pushNotification/get",] = "tasks/pushNotification/get" + params: TaskIdParams + + +class GetTaskPushNotificationResponse(JSONRPCResponse): + result: TaskPushNotificationConfig | None = None + + +class TaskResubscriptionRequest(JSONRPCRequest): + method: Literal["tasks/resubscribe",] = "tasks/resubscribe" + params: TaskIdParams + + +A2ARequest = TypeAdapter( + Annotated[ + Union[ + SendTaskRequest, + GetTaskRequest, + CancelTaskRequest, + SetTaskPushNotificationRequest, + GetTaskPushNotificationRequest, + TaskResubscriptionRequest, + SendTaskStreamingRequest, + ], + Field(discriminator="method"), + ] +) + +## Error types + + +class JSONParseError(JSONRPCError): + code: int = -32700 + message: str = "Invalid JSON payload" + data: Any | None = None + + +class InvalidRequestError(JSONRPCError): + code: int = -32600 + message: str = "Request payload validation error" + data: Any | None = None + + +class MethodNotFoundError(JSONRPCError): + code: int = -32601 + message: str = "Method not found" + data: None = None + + +class InvalidParamsError(JSONRPCError): + code: int = -32602 + message: str = "Invalid parameters" + data: Any | None = None + + +class InternalError(JSONRPCError): + code: int = -32603 + message: str = "Internal error" + data: Any | None = None + + +class TaskNotFoundError(JSONRPCError): + code: int = -32001 + message: str = "Task not found" + data: None = None + + +class TaskNotCancelableError(JSONRPCError): + code: int = -32002 + message: str = "Task cannot be canceled" + data: None = None + + +class PushNotificationNotSupportedError(JSONRPCError): + code: int = -32003 + message: str = "Push Notification is not supported" + data: None = None + + +class UnsupportedOperationError(JSONRPCError): + code: int = -32004 + message: str = "This operation is not supported" + data: None = None + + +class ContentTypeNotSupportedError(JSONRPCError): + code: int = -32005 + message: str = "Incompatible content types" + data: None = None + + +class AgentProvider(BaseModel): + organization: str + url: str | None = None + + +class AgentCapabilities(BaseModel): + streaming: bool = False + pushNotifications: bool = False + stateTransitionHistory: bool = False + + +class AgentAuthentication(BaseModel): + schemes: List[str] + credentials: str | None = None + + +class AgentSkill(BaseModel): + id: str + name: str + description: str | None = None + tags: List[str] | None = None + examples: List[str] | None = None + inputModes: List[str] | None = None + outputModes: List[str] | None = None + + +class AgentCard(BaseModel): + name: str + description: str | None = None + url: str + provider: AgentProvider | None = None + version: str + documentationUrl: str | None = None + capabilities: AgentCapabilities + authentication: AgentAuthentication | None = None + defaultInputModes: List[str] = ["text"] + defaultOutputModes: List[str] = ["text"] + skills: List[AgentSkill] + + +class A2AClientError(Exception): + pass + + +class A2AClientHTTPError(A2AClientError): + def __init__(self, status_code: int, message: str): + self.status_code = status_code + self.message = message + super().__init__(f"HTTP Error {status_code}: {message}") + + +class A2AClientJSONError(A2AClientError): + def __init__(self, message: str): + self.message = message + super().__init__(f"JSON Error: {message}") + + +class MissingAPIKeyError(Exception): + """Exception for missing API key.""" + + pass \ No newline at end of file diff --git a/A2A/common/utils/in_memory_cache.py b/A2A/common/utils/in_memory_cache.py new file mode 100644 index 0000000..06a29fe --- /dev/null +++ b/A2A/common/utils/in_memory_cache.py @@ -0,0 +1,109 @@ +"""In Memory Cache utility.""" + +import threading +import time +from typing import Any, Dict, Optional + + +class InMemoryCache: + """A thread-safe Singleton class to manage cache data. + + Ensures only one instance of the cache exists across the application. + """ + + _instance: Optional["InMemoryCache"] = None + _lock: threading.Lock = threading.Lock() + _initialized: bool = False + + def __new__(cls): + """Override __new__ to control instance creation (Singleton pattern). + + Uses a lock to ensure thread safety during the first instantiation. + + Returns: + The singleton instance of InMemoryCache. + """ + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """Initialize the cache storage. + + Uses a flag (_initialized) to ensure this logic runs only on the very first + creation of the singleton instance. + """ + if not self._initialized: + with self._lock: + if not self._initialized: + # print("Initializing SessionCache storage") + self._cache_data: Dict[str, Dict[str, Any]] = {} + self._ttl: Dict[str, float] = {} + self._data_lock: threading.Lock = threading.Lock() + self._initialized = True + + def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None: + """Set a key-value pair. + + Args: + key: The key for the data. + value: The data to store. + ttl: Time to live in seconds. If None, data will not expire. + """ + with self._data_lock: + self._cache_data[key] = value + + if ttl is not None: + self._ttl[key] = time.time() + ttl + else: + if key in self._ttl: + del self._ttl[key] + + def get(self, key: str, default: Any = None) -> Any: + """Get the value associated with a key. + + Args: + key: The key for the data within the session. + default: The value to return if the session or key is not found. + + Returns: + The cached value, or the default value if not found. + """ + with self._data_lock: + if key in self._ttl and time.time() > self._ttl[key]: + del self._cache_data[key] + del self._ttl[key] + return default + return self._cache_data.get(key, default) + + def delete(self, key: str) -> None: + """Delete a specific key-value pair from a cache. + + Args: + key: The key to delete. + + Returns: + True if the key was found and deleted, False otherwise. + """ + + with self._data_lock: + if key in self._cache_data: + del self._cache_data[key] + if key in self._ttl: + del self._ttl[key] + return True + return False + + def clear(self) -> bool: + """Remove all data. + + Returns: + True if the data was cleared, False otherwise. + """ + with self._data_lock: + self._cache_data.clear() + self._ttl.clear() + return True + return False diff --git a/A2A/common/utils/push_notification_auth.py b/A2A/common/utils/push_notification_auth.py new file mode 100644 index 0000000..cc74ade --- /dev/null +++ b/A2A/common/utils/push_notification_auth.py @@ -0,0 +1,135 @@ +from jwcrypto import jwk +import uuid +from starlette.responses import JSONResponse +from starlette.requests import Request +from typing import Any + +import jwt +import time +import json +import hashlib +import httpx +import logging + +from jwt import PyJWK, PyJWKClient + +logger = logging.getLogger(__name__) +AUTH_HEADER_PREFIX = 'Bearer ' + +class PushNotificationAuth: + def _calculate_request_body_sha256(self, data: dict[str, Any]): + """Calculates the SHA256 hash of a request body. + + This logic needs to be same for both the agent who signs the payload and the client verifier. + """ + body_str = json.dumps( + data, + ensure_ascii=False, + allow_nan=False, + indent=None, + separators=(",", ":"), + ) + return hashlib.sha256(body_str.encode()).hexdigest() + +class PushNotificationSenderAuth(PushNotificationAuth): + def __init__(self): + self.public_keys = [] + self.private_key_jwk: PyJWK = None + + @staticmethod + async def verify_push_notification_url(url: str) -> bool: + async with httpx.AsyncClient(timeout=10) as client: + try: + validation_token = str(uuid.uuid4()) + response = await client.get( + url, + params={"validationToken": validation_token} + ) + response.raise_for_status() + is_verified = response.text == validation_token + + logger.info(f"Verified push-notification URL: {url} => {is_verified}") + return is_verified + except Exception as e: + logger.warning(f"Error during sending push-notification for URL {url}: {e}") + + return False + + def generate_jwk(self): + key = jwk.JWK.generate(kty='RSA', size=2048, kid=str(uuid.uuid4()), use="sig") + self.public_keys.append(key.export_public(as_dict=True)) + self.private_key_jwk = PyJWK.from_json(key.export_private()) + + def handle_jwks_endpoint(self, _request: Request): + """Allow clients to fetch public keys. + """ + return JSONResponse({ + "keys": self.public_keys + }) + + def _generate_jwt(self, data: dict[str, Any]): + """JWT is generated by signing both the request payload SHA digest and time of token generation. + + Payload is signed with private key and it ensures the integrity of payload for client. + Including iat prevents from replay attack. + """ + + iat = int(time.time()) + + return jwt.encode( + {"iat": iat, "request_body_sha256": self._calculate_request_body_sha256(data)}, + key=self.private_key_jwk, + headers={"kid": self.private_key_jwk.key_id}, + algorithm="RS256" + ) + + async def send_push_notification(self, url: str, data: dict[str, Any]): + jwt_token = self._generate_jwt(data) + headers = {'Authorization': f"Bearer {jwt_token}"} + async with httpx.AsyncClient(timeout=10) as client: + try: + response = await client.post( + url, + json=data, + headers=headers + ) + response.raise_for_status() + logger.info(f"Push-notification sent for URL: {url}") + except Exception as e: + logger.warning(f"Error during sending push-notification for URL {url}: {e}") + +class PushNotificationReceiverAuth(PushNotificationAuth): + def __init__(self): + self.public_keys_jwks = [] + self.jwks_client = None + + async def load_jwks(self, jwks_url: str): + self.jwks_client = PyJWKClient(jwks_url) + + async def verify_push_notification(self, request: Request) -> bool: + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith(AUTH_HEADER_PREFIX): + print("Invalid authorization header") + return False + + token = auth_header[len(AUTH_HEADER_PREFIX):] + signing_key = self.jwks_client.get_signing_key_from_jwt(token) + + decode_token = jwt.decode( + token, + signing_key, + options={"require": ["iat", "request_body_sha256"]}, + algorithms=["RS256"], + ) + + actual_body_sha256 = self._calculate_request_body_sha256(await request.json()) + if actual_body_sha256 != decode_token["request_body_sha256"]: + # Payload signature does not match the digest in signed token. + raise ValueError("Invalid request body") + + if time.time() - decode_token["iat"] > 60 * 5: + # Do not allow push-notifications older than 5 minutes. + # This is to prevent replay attack. + raise ValueError("Token is expired") + + return True diff --git a/A2A/requirement.txt b/A2A/requirement.txt new file mode 100644 index 0000000..e69de29 From 3dfb21d1e3352cfbc42b7509b4366f495d7a2282 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Thu, 24 Apr 2025 22:53:35 +0800 Subject: [PATCH 2/9] Change README --- A2A/Manus/README.md | 1 + A2A/Manus/README_zh.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/A2A/Manus/README.md b/A2A/Manus/README.md index 8cd5fcf..7629ec2 100644 --- a/A2A/Manus/README.md +++ b/A2A/Manus/README.md @@ -4,6 +4,7 @@ This is an experimental integration of the A2A protocol (https://google.github.i ## Prerequisites - conda activate 'Your OpenManus python env' +- cd OpenManus/A2A - pip install -r requirements.txt ## Setup & Running diff --git a/A2A/Manus/README_zh.md b/A2A/Manus/README_zh.md index 31a8dc4..e6993a2 100644 --- a/A2A/Manus/README_zh.md +++ b/A2A/Manus/README_zh.md @@ -4,6 +4,7 @@ ## Prerequisites - conda activate 'Your OpenManus python env' +- cd OpenManus/A2A - pip install -r requirements.txt ## Setup & Running @@ -25,7 +26,7 @@ uv run . ``` -4. 通过A2A Client的命令行向OpenManus 发送任务 +3. 通过A2A Client的命令行向OpenManus 发送任务 ## Examples From f457ec846215a96a3e95dd2b2f07317e204c617f Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 27 Apr 2025 22:46:05 +0800 Subject: [PATCH 3/9] use Manus.create() to create instance --- A2A/Manus/README.md | 2 +- A2A/Manus/README_zh.md | 2 +- A2A/Manus/__main__.py | 96 ------------------------------- A2A/Manus/agent.py | 10 ++-- A2A/Manus/main.py | 116 ++++++++++++++++++++++++++++++++++++++ A2A/Manus/task_manager.py | 8 +-- 6 files changed, 126 insertions(+), 108 deletions(-) delete mode 100644 A2A/Manus/__main__.py create mode 100644 A2A/Manus/main.py diff --git a/A2A/Manus/README.md b/A2A/Manus/README.md index 7629ec2..c576dff 100644 --- a/A2A/Manus/README.md +++ b/A2A/Manus/README.md @@ -13,7 +13,7 @@ This is an experimental integration of the A2A protocol (https://google.github.i ```bash cd OpenManus - python -m A2A.Manus.__main__ + python -m A2A.Manus.main ``` 2. Clone A2A official repository and run A2A Client (details at https://github.com/google/A2A): diff --git a/A2A/Manus/README_zh.md b/A2A/Manus/README_zh.md index e6993a2..7fe8f2a 100644 --- a/A2A/Manus/README_zh.md +++ b/A2A/Manus/README_zh.md @@ -13,7 +13,7 @@ ```bash cd OpenManus - python -m A2A.Manus.__main__ + python -m A2A.Manus.main ``` 2. 拉取A2A官方库并运行A2A Client 详情参考(https://github.com/google/A2A): diff --git a/A2A/Manus/__main__.py b/A2A/Manus/__main__.py deleted file mode 100644 index 6d0978d..0000000 --- a/A2A/Manus/__main__.py +++ /dev/null @@ -1,96 +0,0 @@ -from A2A.common.server import A2AServer -from A2A.common.types import AgentCard, AgentCapabilities, AgentSkill, MissingAPIKeyError -from A2A.common.utils.push_notification_auth import PushNotificationSenderAuth -from A2A.Manus.task_manager import AgentTaskManager -from A2A.Manus.agent import ManusAgent -from app.tool.browser_use_tool import _BROWSER_DESCRIPTION -from app.tool.str_replace_editor import _STR_REPLACE_EDITOR_DESCRIPTION -from app.tool.terminate import _TERMINATE_DESCRIPTION -import click -import os -import logging -from dotenv import load_dotenv - -load_dotenv() - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -@click.command() -@click.option("--host", "host", default="localhost") -@click.option("--port", "port", default=10000) -def main(host, port): - """Starts the Manus Agent server.""" - try: - capabilities = AgentCapabilities(streamiMng=False, pushNotifications=True) - Python_execute_skill = AgentSkill( - id="Python Execute", - name="Python Execute Tool", - description="Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.", - tags=["Execute Python Code"], - examples=["Execute Python code:'''python \n Print('Hello World') \n '''"], - ) - Browser_use_skill = AgentSkill( - id="Browser use", - name="Browser use Tool", - description=_BROWSER_DESCRIPTION, - tags=["Use Browser"], - examples=["go_to 'https://www.google.com'"], - ) - Str_replace_skill = AgentSkill( - id="Replace String", - name="Str_replace Tool", - description=_STR_REPLACE_EDITOR_DESCRIPTION, - tags=["Operate Files"], - examples=["Replace 'old' with 'new' in 'file.txt'"], - ) - Ask_human_skill = AgentSkill( - id="Ask human", - name="Ask human Tool", - description="Use this tool to ask human for help.", - tags=["Ask human for help"], - examples=["Ask human: 'What time is it?'"], - ) - Terminate_skill = AgentSkill( - id="terminate", - name="terminate Tool", - description=_TERMINATE_DESCRIPTION, - tags=["terminate task"], - examples=["terminate"], - ) - agent_card = AgentCard( - name="Manus Agent", - description="A versatile agent that can solve various tasks using multiple tools including MCP-based tools", - url=f"http://{host}:{port}/", - version="1.0.0", - defaultInputModes=ManusAgent.SUPPORTED_CONTENT_TYPES, - defaultOutputModes=ManusAgent.SUPPORTED_CONTENT_TYPES, - capabilities=capabilities, - skills=[Python_execute_skill,Browser_use_skill,Str_replace_skill,Ask_human_skill,Terminate_skill], - ) - - notification_sender_auth = PushNotificationSenderAuth() - notification_sender_auth.generate_jwk() - server = A2AServer( - agent_card=agent_card, - task_manager=AgentTaskManager(agent=ManusAgent(), notification_sender_auth=notification_sender_auth), - host=host, - port=port, - ) - - server.app.add_route( - "/.well-known/jwks.json", notification_sender_auth.handle_jwks_endpoint, methods=["GET"] - ) - - logger.info(f"Starting server on {host}:{port}") - server.start() - except MissingAPIKeyError as e: - logger.error(f"Error: {e}") - exit(1) - except Exception as e: - logger.error(f"An error occurred during server startup: {e}") - exit(1) - - -if __name__ == "__main__": - main() diff --git a/A2A/Manus/agent.py b/A2A/Manus/agent.py index 8ab2111..aeae07a 100644 --- a/A2A/Manus/agent.py +++ b/A2A/Manus/agent.py @@ -1,5 +1,5 @@ import httpx -from typing import Any, Dict, AsyncIterable, Literal +from typing import Any, Dict, AsyncIterable, Literal, List, ClassVar from pydantic import BaseModel from app.agent.manus import Manus @@ -8,13 +8,11 @@ class ResponseFormat(BaseModel): status: Literal["input_required", "completed", "error"] = "input_required" message: str -class ManusAgent: - def __init__(self): - self.agent = Manus() +class A2AManus(Manus): async def invoke(self, query, sessionId) -> str: config = {"configurable": {"thread_id": sessionId}} - response = await self.agent.run(query) + response = await self.run(query) return self.get_agent_response(config,response) async def stream(self, query: str) -> AsyncIterable[Dict[str, Any]]: @@ -29,4 +27,4 @@ class ManusAgent: "content": agent_response, } - SUPPORTED_CONTENT_TYPES = ["text", "text/plain"] + SUPPORTED_CONTENT_TYPES : ClassVar[List[str]] = ["text", "text/plain"] diff --git a/A2A/Manus/main.py b/A2A/Manus/main.py new file mode 100644 index 0000000..443de7b --- /dev/null +++ b/A2A/Manus/main.py @@ -0,0 +1,116 @@ +from A2A.common.server import A2AServer +from A2A.common.types import AgentCard, AgentCapabilities, AgentSkill, MissingAPIKeyError +from A2A.common.utils.push_notification_auth import PushNotificationSenderAuth +from A2A.Manus.task_manager import AgentTaskManager +from A2A.Manus.agent import A2AManus +from app.tool.browser_use_tool import _BROWSER_DESCRIPTION +from app.tool.str_replace_editor import _STR_REPLACE_EDITOR_DESCRIPTION +from app.tool.terminate import _TERMINATE_DESCRIPTION +import click +import os +import logging +from dotenv import load_dotenv +import asyncio +from typing import Optional + +load_dotenv() + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +async def main(host:str = "localhost", port:int = 10000): + """Starts the Manus Agent server.""" + try: + capabilities = AgentCapabilities(streamiMng=False, pushNotifications=True) + skills=[ + AgentSkill( + id="Python Execute", + name="Python Execute Tool", + description="Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.", + tags=["Execute Python Code"], + examples=["Execute Python code:'''python \n Print('Hello World') \n '''"], + ), + AgentSkill( + id="Browser use", + name="Browser use Tool", + description=_BROWSER_DESCRIPTION, + tags=["Use Browser"], + examples=["go_to 'https://www.google.com'"], + ), + AgentSkill( + id="Replace String", + name="Str_replace Tool", + description=_STR_REPLACE_EDITOR_DESCRIPTION, + tags=["Operate Files"], + examples=["Replace 'old' with 'new' in 'file.txt'"], + ), + AgentSkill( + id="Ask human", + name="Ask human Tool", + description="Use this tool to ask human for help.", + tags=["Ask human for help"], + examples=["Ask human: 'What time is it?'"], + ), + AgentSkill( + id="terminate", + name="terminate Tool", + description=_TERMINATE_DESCRIPTION, + tags=["terminate task"], + examples=["terminate"], + ) + # Add more skills as needed + ] + + agent_card = AgentCard( + name="Manus Agent", + description="A versatile agent that can solve various tasks using multiple tools including MCP-based tools", + url=f"http://{host}:{port}/", + version="1.0.0", + defaultInputModes=A2AManus.SUPPORTED_CONTENT_TYPES, + defaultOutputModes=A2AManus.SUPPORTED_CONTENT_TYPES, + capabilities=capabilities, + skills=skills, + ) + + notification_sender_auth = PushNotificationSenderAuth() + notification_sender_auth.generate_jwk() + A2AManus_instance = await A2AManus.create(max_steps=5) + server = A2AServer( + agent_card=agent_card, + task_manager=AgentTaskManager(agent=A2AManus_instance, notification_sender_auth=notification_sender_auth), + host=host, + port=port, + ) + + server.app.add_route( + "/.well-known/jwks.json", notification_sender_auth.handle_jwks_endpoint, methods=["GET"] + ) + + logger.info(f"Starting server on {host}:{port}") + return server + except MissingAPIKeyError as e: + logger.error(f"Error: {e}") + exit(1) + except Exception as e: + logger.error(f"An error occurred during server startup: {e}") + exit(1) + +def run_server(host: Optional[str] = "localhost", port: Optional[int] = 10000): + try: + import uvicorn + server = asyncio.run(main(host, port)) + config = uvicorn.Config(app=server.app, host=host, port=port, loop="asyncio", proxy_headers=True) + uvicorn.Server(config=config).run() + logger.info(f"Server started on {host}:{port}") + except Exception as e: + logger.error(f"An error occurred while starting the server: {e}") +if __name__ == "__main__": + import sys + host = "localhost" # 默认值 + port = 10000 # 默认值 + for i in range(1, len(sys.argv)): + if sys.argv[i] == "--host": + host = sys.argv[i + 1] + elif sys.argv[i] == "--port": + port = int(sys.argv[i + 1]) + run_server(host, port) diff --git a/A2A/Manus/task_manager.py b/A2A/Manus/task_manager.py index 637eb9f..b4be1dc 100644 --- a/A2A/Manus/task_manager.py +++ b/A2A/Manus/task_manager.py @@ -24,7 +24,7 @@ from A2A.common.types import ( InvalidParamsError, ) from A2A.common.server.task_manager import InMemoryTaskManager -from A2A.Manus.agent import ManusAgent +from A2A.Manus.agent import A2AManus from A2A.common.utils.push_notification_auth import PushNotificationSenderAuth import A2A.common.server.utils as utils from typing import Union @@ -36,7 +36,7 @@ logger = logging.getLogger(__name__) class AgentTaskManager(InMemoryTaskManager): - def __init__(self, agent: ManusAgent, notification_sender_auth: PushNotificationSenderAuth): + def __init__(self, agent: A2AManus, notification_sender_auth: PushNotificationSenderAuth): super().__init__() self.agent = agent self.notification_sender_auth = notification_sender_auth @@ -102,12 +102,12 @@ class AgentTaskManager(InMemoryTaskManager): ) -> JSONRPCResponse | None: task_send_params: TaskSendParams = request.params if not utils.are_modalities_compatible( - task_send_params.acceptedOutputModes, ManusAgent.SUPPORTED_CONTENT_TYPES + task_send_params.acceptedOutputModes, A2AManus.SUPPORTED_CONTENT_TYPES ): logger.warning( "Unsupported output mode. Received %s, Support %s", task_send_params.acceptedOutputModes, - ManusAgent.SUPPORTED_CONTENT_TYPES, + A2AManus.SUPPORTED_CONTENT_TYPES, ) return utils.new_incompatible_types_error(request.id) From 8d23b06e46bf56556f7323ef837ad0c834868368 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Mon, 28 Apr 2025 22:05:44 +0800 Subject: [PATCH 4/9] use factory to create Manus Agent instance dynamically --- A2A/Manus/main.py | 4 ++-- A2A/Manus/task_manager.py | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/A2A/Manus/main.py b/A2A/Manus/main.py index 443de7b..d3dce56 100644 --- a/A2A/Manus/main.py +++ b/A2A/Manus/main.py @@ -74,10 +74,10 @@ async def main(host:str = "localhost", port:int = 10000): notification_sender_auth = PushNotificationSenderAuth() notification_sender_auth.generate_jwk() - A2AManus_instance = await A2AManus.create(max_steps=5) server = A2AServer( agent_card=agent_card, - task_manager=AgentTaskManager(agent=A2AManus_instance, notification_sender_auth=notification_sender_auth), + task_manager=AgentTaskManager( + agent_factory=lambda:A2AManus.create(max_steps=3), notification_sender_auth=notification_sender_auth), host=host, port=port, ) diff --git a/A2A/Manus/task_manager.py b/A2A/Manus/task_manager.py index b4be1dc..d2cb681 100644 --- a/A2A/Manus/task_manager.py +++ b/A2A/Manus/task_manager.py @@ -27,7 +27,7 @@ from A2A.common.server.task_manager import InMemoryTaskManager from A2A.Manus.agent import A2AManus from A2A.common.utils.push_notification_auth import PushNotificationSenderAuth import A2A.common.server.utils as utils -from typing import Union +from typing import Union,Callable,Awaitable import asyncio import logging import traceback @@ -36,17 +36,17 @@ logger = logging.getLogger(__name__) class AgentTaskManager(InMemoryTaskManager): - def __init__(self, agent: A2AManus, notification_sender_auth: PushNotificationSenderAuth): + def __init__(self, agent_factory: Callable[[],Awaitable[A2AManus]], notification_sender_auth: PushNotificationSenderAuth): super().__init__() - self.agent = agent + self.agent_factory = agent_factory self.notification_sender_auth = notification_sender_auth async def _run_streaming_agent(self, request: SendTaskStreamingRequest): task_send_params: TaskSendParams = request.params query = self._get_user_query(task_send_params) - + agent= await self.agent_factory() try: - async for item in self.agent.stream(query, task_send_params.sessionId): + async for item in agent.stream(query, task_send_params.sessionId): is_task_complete = item["is_task_complete"] require_user_input = item["require_user_input"] artifact = None @@ -135,8 +135,9 @@ class AgentTaskManager(InMemoryTaskManager): task_send_params: TaskSendParams = request.params query = self._get_user_query(task_send_params) + agent= await self.agent_factory() try: - agent_response = await self.agent.invoke(query, task_send_params.sessionId) + agent_response = await agent.invoke(query, task_send_params.sessionId) except Exception as e: logger.error(f"Error invoking agent: {e}") raise ValueError(f"Error invoking agent: {e}") From 390ac132b8dbf0bfe141e1f795b14d5042fbaac3 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Thu, 1 May 2025 19:18:56 +0800 Subject: [PATCH 5/9] add requirements.txt --- A2A/Manus/main.py | 2 +- A2A/requirement.txt | 0 A2A/requirements.txt | 8 ++++++++ 3 files changed, 9 insertions(+), 1 deletion(-) delete mode 100644 A2A/requirement.txt create mode 100644 A2A/requirements.txt diff --git a/A2A/Manus/main.py b/A2A/Manus/main.py index d3dce56..229d132 100644 --- a/A2A/Manus/main.py +++ b/A2A/Manus/main.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) async def main(host:str = "localhost", port:int = 10000): """Starts the Manus Agent server.""" try: - capabilities = AgentCapabilities(streamiMng=False, pushNotifications=True) + capabilities = AgentCapabilities(streaming=False, pushNotifications=True) skills=[ AgentSkill( id="Python Execute", diff --git a/A2A/requirement.txt b/A2A/requirement.txt deleted file mode 100644 index e69de29..0000000 diff --git a/A2A/requirements.txt b/A2A/requirements.txt new file mode 100644 index 0000000..c6287b9 --- /dev/null +++ b/A2A/requirements.txt @@ -0,0 +1,8 @@ +httpx>=0.28.1 +httpx-sse>=0.4.0 +jwcrypto>=1.5.6 +pyjwt>=2.10.1 +sse-starlette>=2.2.1 +starlette>=0.46.1 +typing-extensions>=4.12.2 +uvicorn>=0.34.0 From b386fec6b9bc716f46b4bf7e9d9a65e4320919f5 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Wed, 28 May 2025 20:13:53 +0800 Subject: [PATCH 6/9] [feat]:Support A2A protocol with a2a-sdk --- A2A/Manus/README.md | 142 ++++---- A2A/Manus/README_zh.md | 143 ++++---- A2A/Manus/agent_executor.py | 71 ++++ A2A/Manus/main.py | 46 +-- A2A/Manus/task_manager.py | 247 -------------- A2A/common/__init__.py | 0 A2A/common/client/__init__.py | 4 - A2A/common/client/card_resolver.py | 21 -- A2A/common/client/client.py | 86 ----- A2A/common/server/__init__.py | 4 - A2A/common/server/server.py | 120 ------- A2A/common/server/task_manager.py | 277 ---------------- A2A/common/server/utils.py | 28 -- A2A/common/types.py | 365 --------------------- A2A/common/utils/in_memory_cache.py | 109 ------ A2A/common/utils/push_notification_auth.py | 135 -------- A2A/requirements.txt | 8 - 17 files changed, 244 insertions(+), 1562 deletions(-) create mode 100644 A2A/Manus/agent_executor.py delete mode 100644 A2A/Manus/task_manager.py delete mode 100644 A2A/common/__init__.py delete mode 100644 A2A/common/client/__init__.py delete mode 100644 A2A/common/client/card_resolver.py delete mode 100644 A2A/common/client/client.py delete mode 100644 A2A/common/server/__init__.py delete mode 100644 A2A/common/server/server.py delete mode 100644 A2A/common/server/task_manager.py delete mode 100644 A2A/common/server/utils.py delete mode 100644 A2A/common/types.py delete mode 100644 A2A/common/utils/in_memory_cache.py delete mode 100644 A2A/common/utils/push_notification_auth.py delete mode 100644 A2A/requirements.txt diff --git a/A2A/Manus/README.md b/A2A/Manus/README.md index c576dff..0dac56a 100644 --- a/A2A/Manus/README.md +++ b/A2A/Manus/README.md @@ -4,8 +4,8 @@ This is an experimental integration of the A2A protocol (https://google.github.i ## Prerequisites - conda activate 'Your OpenManus python env' -- cd OpenManus/A2A -- pip install -r requirements.txt +- pip install a2a-sdk + ## Setup & Running @@ -16,17 +16,17 @@ This is an experimental integration of the A2A protocol (https://google.github.i python -m A2A.Manus.main ``` -2. Clone A2A official repository and run A2A Client (details at https://github.com/google/A2A): +2. Clone A2A official repository and run A2A Client,there are two ways to use A2AClient——CLI and Register A2A Agent Server in UI.(details at https://github.com/google/A2A): ```bash - git clone https://github.com/google/A2A.git - cd A2A + git clone https://github.com/google-a2a/a2a-samples.git + cd a2a-samples echo "GOOGLE_API_KEY=your_api_key_here" > .env cd samples/python/hosts/cli uv run . ``` -3. Send tasks to OpenManus via A2A Client command line +3. Send tasks to OpenManus via A2A Client CLI or Register A2A Agent Server in UI ## Examples @@ -45,14 +45,9 @@ curl http://localhost:10000/.well-known/agent.json Response: { - "name": "Manus Agent", - "description": "A versatile agent that can solve various tasks using multiple tools including MCP-based tools", - "url": "http://localhost:10000/", - "version": "1.0.0", "capabilities": { - "streaming": false, "pushNotifications": true, - "stateTransitionHistory": false + "streaming": false }, "defaultInputModes": [ "text", @@ -62,63 +57,67 @@ Response: "text", "text/plain" ], + "description": "A versatile agent that can solve various tasks using multiple tools including MCP-based tools", + "name": "Manus Agent", "skills": [ { - "id": "Python Execute", - "name": "Python Execute Tool", "description": "Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.", - "tags": [ - "Execute Python Code" - ], "examples": [ "Execute Python code:'''python \n Print('Hello World') \n '''" + ], + "id": "Python Execute", + "name": "Python Execute Tool", + "tags": [ + "Execute Python Code" ] }, { - "id": "Browser use", - "name": "Browser use Tool", "description": "A powerful browser automation tool that allows interaction with web pages through various actions.\n* This tool provides commands for controlling a browser session, navigating web pages, and extracting information\n* It maintains state across calls, keeping the browser session alive until explicitly closed\n* Use this when you need to browse websites, fill forms, click buttons, extract content, or perform web searches\n* Each action requires specific parameters as defined in the tool's dependencies\n\nKey capabilities include:\n* Navigation: Go to specific URLs, go back, search the web, or refresh pages\n* Interaction: Click elements, input text, select from dropdowns, send keyboard commands\n* Scrolling: Scroll up/down by pixel amount or scroll to specific text\n* Content extraction: Extract and analyze content from web pages based on specific goals\n* Tab management: Switch between tabs, open new tabs, or close tabs\n\nNote: When using element indices, refer to the numbered elements shown in the current browser state.\n", - "tags": [ - "Use Browser" - ], "examples": [ "go_to 'https://www.google.com'" + ], + "id": "Browser use", + "name": "Browser use Tool", + "tags": [ + "Use Browser" ] }, { - "id": "Replace String", - "name": "Str_replace Tool", "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n* The `undo_edit` command will revert the last edit made to the file at `path`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`\n", - "tags": [ - "Operate Files" - ], "examples": [ "Replace 'old' with 'new' in 'file.txt'" + ], + "id": "Replace String", + "name": "Str_replace Tool", + "tags": [ + "Operate Files" ] }, { - "id": "Ask human", - "name": "Ask human Tool", "description": "Use this tool to ask human for help.", - "tags": [ - "Ask human for help" - ], "examples": [ "Ask human: 'What time is it?'" + ], + "id": "Ask human", + "name": "Ask human Tool", + "tags": [ + "Ask human for help" ] }, { - "id": "terminate", - "name": "terminate Tool", "description": "Terminate the interaction when the request is met OR if the assistant cannot proceed further with the task.\nWhen you have finished all the tasks, call this tool to end the work.", - "tags": [ - "terminate task" - ], "examples": [ "terminate" + ], + "id": "terminate", + "name": "terminate Tool", + "tags": [ + "terminate task" ] } - ] + ], + "url": "http://localhost:10000/", + "version": "1.0.0" } ``` @@ -128,27 +127,18 @@ Request: ``` curl --location 'http://localhost:10000' \ ---header 'Content-Type: application/json' \ +--header 'Content-Type: application/json' \ --data '{ - "jsonrpc": "2.0", - "id": 10, - "method": "tasks/send", - "params": { - "id": "130", - "sessionId": "94dbdab49b2e44a9aac361cfaffbafd1", - "acceptedOutputModes": [ - "text" - ], - "message": { - "role": "user", - "parts": [ - { - "type": "text", - "text": "什么是快乐星球" + "id":130, + "jsonrpc":"2.0", + "method": "message/send", + "params": { + "message": { + "messageId": "", + "role": "user", + "parts": [{"text":"什么是快乐星球"}] } - ] } - } }' ``` @@ -156,27 +146,43 @@ Response: ``` { + "id": 130, "jsonrpc": "2.0", - "id": 10, "result": { - "id": "130", - "sessionId": "94dbdab49b2e44a9aac361cfaffbafd1", - "status": { - "state": "completed", - "timestamp": "2025-04-24T22:34:06.219036" - }, "artifacts": [ { + "artifactId": "2f9d0af8-c7da-4f88-9c8c-3033836322b8", + "description": "", + "name": "task_cf64d3c9-1e08-4948-a620-76900aa204cf", "parts": [ { - "type": "text", - "text": "Step 1: “快乐星球”是一个源自中国儿童电视剧《快乐星球》的概念。这部剧讲述了一群来自外星球的孩子在地球上的冒险故事,他们通过科技和智慧帮助地球上的孩子们解决问题,传递了友情、勇气和快乐的主题。\n\n“快乐星球”后来也成为了网络流行语,通常用来调侃或表达对某种理想化、无忧无虑状态的向往。比如,有人可能会问:“什么是快乐星球?”然后回答:“快乐星球就是没有烦恼的地方!”\n\n如果您是想了解这部剧的具体内容,或者想找相关的资源,我可以帮您进一步搜索或提供更多信息!\nTerminated: Reached max steps (1)" + "kind": "text", + "text": "Step 1: “快乐星球”是一个流行的网络用语,源自中国儿童科幻电视剧《快乐星球》。这部剧讲述了一群孩子在一个虚构的“快乐星球”上经历的冒险故事,主题围绕着友谊、成长和科学幻想。后来,“快乐星球”逐渐成为一种网络梗,用来形容一种无忧无虑、充满快乐的理想状态。\n\n如果你对这个词的具体含义、出处或者相关的文化背景有更多兴趣,可以告诉我,我可以为你提供更详细的信息!\nStep 2: Observed output of cmd `terminate` executed:\nThe interaction has been completed with status: success" } - ], - "index": 0 + ] } ], - "history": [] + "contextId": "44d16c16-9ccf-49c2-9a99-5c9513969b5f", + "history": [ + { + "contextId": "44d16c16-9ccf-49c2-9a99-5c9513969b5f", + "kind": "message", + "messageId": "", + "parts": [ + { + "kind": "text", + "text": "什么是快乐星球" + } + ], + "role": "user", + "taskId": "cf64d3c9-1e08-4948-a620-76900aa204cf" + } + ], + "id": "cf64d3c9-1e08-4948-a620-76900aa204cf", + "kind": "task", + "status": { + "state": "completed" + } } } ``` diff --git a/A2A/Manus/README_zh.md b/A2A/Manus/README_zh.md index 7fe8f2a..d345ccc 100644 --- a/A2A/Manus/README_zh.md +++ b/A2A/Manus/README_zh.md @@ -4,8 +4,9 @@ ## Prerequisites - conda activate 'Your OpenManus python env' -- cd OpenManus/A2A -- pip install -r requirements.txt +- pip install a2a-sdk + + ## Setup & Running @@ -16,17 +17,17 @@ python -m A2A.Manus.main ``` -2. 拉取A2A官方库并运行A2A Client 详情参考(https://github.com/google/A2A): +2. 拉取A2A官方库并运行A2A Client,有两种使用A2A客户端的方式——CLI以及在前端页面注册Agent服务。(详情参考https://github.com/google/A2A): ```bash - git clone https://github.com/google/A2A.git - cd A2A + git clone https://github.com/google-a2a/a2a-samples.git + cd a2a-samples echo "GOOGLE_API_KEY=your_api_key_here" > .env cd samples/python/hosts/cli uv run . ``` -3. 通过A2A Client的命令行向OpenManus 发送任务 +3. 通过A2A Client的命令行向OpenManus发送任务或者在A2A前端页面上将其注册 ## Examples @@ -45,14 +46,9 @@ curl http://localhost:10000/.well-known/agent.json Response: { - "name": "Manus Agent", - "description": "A versatile agent that can solve various tasks using multiple tools including MCP-based tools", - "url": "http://localhost:10000/", - "version": "1.0.0", "capabilities": { - "streaming": false, "pushNotifications": true, - "stateTransitionHistory": false + "streaming": false }, "defaultInputModes": [ "text", @@ -62,63 +58,67 @@ Response: "text", "text/plain" ], + "description": "A versatile agent that can solve various tasks using multiple tools including MCP-based tools", + "name": "Manus Agent", "skills": [ { - "id": "Python Execute", - "name": "Python Execute Tool", "description": "Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.", - "tags": [ - "Execute Python Code" - ], "examples": [ "Execute Python code:'''python \n Print('Hello World') \n '''" + ], + "id": "Python Execute", + "name": "Python Execute Tool", + "tags": [ + "Execute Python Code" ] }, { - "id": "Browser use", - "name": "Browser use Tool", "description": "A powerful browser automation tool that allows interaction with web pages through various actions.\n* This tool provides commands for controlling a browser session, navigating web pages, and extracting information\n* It maintains state across calls, keeping the browser session alive until explicitly closed\n* Use this when you need to browse websites, fill forms, click buttons, extract content, or perform web searches\n* Each action requires specific parameters as defined in the tool's dependencies\n\nKey capabilities include:\n* Navigation: Go to specific URLs, go back, search the web, or refresh pages\n* Interaction: Click elements, input text, select from dropdowns, send keyboard commands\n* Scrolling: Scroll up/down by pixel amount or scroll to specific text\n* Content extraction: Extract and analyze content from web pages based on specific goals\n* Tab management: Switch between tabs, open new tabs, or close tabs\n\nNote: When using element indices, refer to the numbered elements shown in the current browser state.\n", - "tags": [ - "Use Browser" - ], "examples": [ "go_to 'https://www.google.com'" + ], + "id": "Browser use", + "name": "Browser use Tool", + "tags": [ + "Use Browser" ] }, { - "id": "Replace String", - "name": "Str_replace Tool", "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n* The `undo_edit` command will revert the last edit made to the file at `path`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`\n", - "tags": [ - "Operate Files" - ], "examples": [ "Replace 'old' with 'new' in 'file.txt'" + ], + "id": "Replace String", + "name": "Str_replace Tool", + "tags": [ + "Operate Files" ] }, { - "id": "Ask human", - "name": "Ask human Tool", "description": "Use this tool to ask human for help.", - "tags": [ - "Ask human for help" - ], "examples": [ "Ask human: 'What time is it?'" + ], + "id": "Ask human", + "name": "Ask human Tool", + "tags": [ + "Ask human for help" ] }, { - "id": "terminate", - "name": "terminate Tool", "description": "Terminate the interaction when the request is met OR if the assistant cannot proceed further with the task.\nWhen you have finished all the tasks, call this tool to end the work.", - "tags": [ - "terminate task" - ], "examples": [ "terminate" + ], + "id": "terminate", + "name": "terminate Tool", + "tags": [ + "terminate task" ] } - ] + ], + "url": "http://localhost:10000/", + "version": "1.0.0" } ``` @@ -128,27 +128,18 @@ Request: ``` curl --location 'http://localhost:10000' \ ---header 'Content-Type: application/json' \ +--header 'Content-Type: application/json' \ --data '{ - "jsonrpc": "2.0", - "id": 10, - "method": "tasks/send", - "params": { - "id": "130", - "sessionId": "94dbdab49b2e44a9aac361cfaffbafd1", - "acceptedOutputModes": [ - "text" - ], - "message": { - "role": "user", - "parts": [ - { - "type": "text", - "text": "什么是快乐星球" + "id":130, + "jsonrpc":"2.0", + "method": "message/send", + "params": { + "message": { + "messageId": "", + "role": "user", + "parts": [{"text":"什么是快乐星球"}] } - ] } - } }' ``` @@ -156,27 +147,43 @@ Response: ``` { + "id": 130, "jsonrpc": "2.0", - "id": 10, "result": { - "id": "130", - "sessionId": "94dbdab49b2e44a9aac361cfaffbafd1", - "status": { - "state": "completed", - "timestamp": "2025-04-24T22:34:06.219036" - }, "artifacts": [ { + "artifactId": "2f9d0af8-c7da-4f88-9c8c-3033836322b8", + "description": "", + "name": "task_cf64d3c9-1e08-4948-a620-76900aa204cf", "parts": [ { - "type": "text", - "text": "Step 1: “快乐星球”是一个源自中国儿童电视剧《快乐星球》的概念。这部剧讲述了一群来自外星球的孩子在地球上的冒险故事,他们通过科技和智慧帮助地球上的孩子们解决问题,传递了友情、勇气和快乐的主题。\n\n“快乐星球”后来也成为了网络流行语,通常用来调侃或表达对某种理想化、无忧无虑状态的向往。比如,有人可能会问:“什么是快乐星球?”然后回答:“快乐星球就是没有烦恼的地方!”\n\n如果您是想了解这部剧的具体内容,或者想找相关的资源,我可以帮您进一步搜索或提供更多信息!\nTerminated: Reached max steps (1)" + "kind": "text", + "text": "Step 1: “快乐星球”是一个流行的网络用语,源自中国儿童科幻电视剧《快乐星球》。这部剧讲述了一群孩子在一个虚构的“快乐星球”上经历的冒险故事,主题围绕着友谊、成长和科学幻想。后来,“快乐星球”逐渐成为一种网络梗,用来形容一种无忧无虑、充满快乐的理想状态。\n\n如果你对这个词的具体含义、出处或者相关的文化背景有更多兴趣,可以告诉我,我可以为你提供更详细的信息!\nStep 2: Observed output of cmd `terminate` executed:\nThe interaction has been completed with status: success" } - ], - "index": 0 + ] } ], - "history": [] + "contextId": "44d16c16-9ccf-49c2-9a99-5c9513969b5f", + "history": [ + { + "contextId": "44d16c16-9ccf-49c2-9a99-5c9513969b5f", + "kind": "message", + "messageId": "", + "parts": [ + { + "kind": "text", + "text": "什么是快乐星球" + } + ], + "role": "user", + "taskId": "cf64d3c9-1e08-4948-a620-76900aa204cf" + } + ], + "id": "cf64d3c9-1e08-4948-a620-76900aa204cf", + "kind": "task", + "status": { + "state": "completed" + } } } ``` diff --git a/A2A/Manus/agent_executor.py b/A2A/Manus/agent_executor.py new file mode 100644 index 0000000..857ab05 --- /dev/null +++ b/A2A/Manus/agent_executor.py @@ -0,0 +1,71 @@ +import logging + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import Event, EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import ( + InvalidParamsError, + Part, + Task, + TextPart, + UnsupportedOperationError, +) +from a2a.utils import ( + completed_task, + new_artifact, +) +from A2A.Manus.agent import A2AManus +from a2a.utils.errors import ServerError +from typing import Callable, Awaitable + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class ManusExecutor(AgentExecutor): + """Currency Conversion AgentExecutor Example.""" + + def __init__(self,agent_factory:Callable[[],Awaitable[A2AManus]]): + self.agent_factory = agent_factory + + async def execute( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: + error = self._validate_request(context) + if error: + raise ServerError(error=InvalidParamsError()) + + query = context.get_user_input() + try: + self.agent = await self.agent_factory() + result = await self.agent.invoke(query, context.context_id) + print(f'Final Result ===> {result}') + except Exception as e: + print('Error invoking agent: %s', e) + raise ServerError( + error=ValueError(f'Error invoking agent: {e}') + ) from e + parts = [ + Part( + root=TextPart( + text=result["content"] if result["content"] else 'failed to generate response' + ), + ) + ] + event_queue.enqueue_event( + completed_task( + context.task_id, + context.context_id, + [new_artifact(parts, f'task_{context.task_id}')], + [context.message], + ) + ) + def _validate_request(self, context: RequestContext) -> bool: + return False + + async def cancel( + self, request: RequestContext, event_queue: EventQueue + ) -> Task | None: + raise ServerError(error=UnsupportedOperationError()) diff --git a/A2A/Manus/main.py b/A2A/Manus/main.py index 229d132..c0eb604 100644 --- a/A2A/Manus/main.py +++ b/A2A/Manus/main.py @@ -1,13 +1,20 @@ -from A2A.common.server import A2AServer -from A2A.common.types import AgentCard, AgentCapabilities, AgentSkill, MissingAPIKeyError -from A2A.common.utils.push_notification_auth import PushNotificationSenderAuth -from A2A.Manus.task_manager import AgentTaskManager +import httpx + +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore, InMemoryPushNotifier +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, +) + +from A2A.Manus.agent_executor import ManusExecutor + from A2A.Manus.agent import A2AManus from app.tool.browser_use_tool import _BROWSER_DESCRIPTION from app.tool.str_replace_editor import _STR_REPLACE_EDITOR_DESCRIPTION from app.tool.terminate import _TERMINATE_DESCRIPTION -import click -import os import logging from dotenv import load_dotenv import asyncio @@ -72,25 +79,20 @@ async def main(host:str = "localhost", port:int = 10000): skills=skills, ) - notification_sender_auth = PushNotificationSenderAuth() - notification_sender_auth.generate_jwk() - server = A2AServer( - agent_card=agent_card, - task_manager=AgentTaskManager( - agent_factory=lambda:A2AManus.create(max_steps=3), notification_sender_auth=notification_sender_auth), - host=host, - port=port, + httpx_client = httpx.AsyncClient() + request_handler = DefaultRequestHandler( + agent_executor=ManusExecutor(agent_factory=lambda:A2AManus.create(max_steps=3)), + task_store=InMemoryTaskStore(), + push_notifier=InMemoryPushNotifier(httpx_client), ) - server.app.add_route( - "/.well-known/jwks.json", notification_sender_auth.handle_jwks_endpoint, methods=["GET"] + + server = A2AStarletteApplication( + agent_card=agent_card, http_handler=request_handler ) logger.info(f"Starting server on {host}:{port}") - return server - except MissingAPIKeyError as e: - logger.error(f"Error: {e}") - exit(1) + return server.build() except Exception as e: logger.error(f"An error occurred during server startup: {e}") exit(1) @@ -98,8 +100,8 @@ async def main(host:str = "localhost", port:int = 10000): def run_server(host: Optional[str] = "localhost", port: Optional[int] = 10000): try: import uvicorn - server = asyncio.run(main(host, port)) - config = uvicorn.Config(app=server.app, host=host, port=port, loop="asyncio", proxy_headers=True) + app = asyncio.run(main(host, port)) + config = uvicorn.Config(app=app, host=host, port=port, loop="asyncio", proxy_headers=True) uvicorn.Server(config=config).run() logger.info(f"Server started on {host}:{port}") except Exception as e: diff --git a/A2A/Manus/task_manager.py b/A2A/Manus/task_manager.py deleted file mode 100644 index d2cb681..0000000 --- a/A2A/Manus/task_manager.py +++ /dev/null @@ -1,247 +0,0 @@ -from typing import AsyncIterable -from A2A.common.types import ( - SendTaskRequest, - TaskSendParams, - Message, - TaskStatus, - Artifact, - TextPart, - TaskState, - SendTaskResponse, - InternalError, - JSONRPCResponse, - SendTaskStreamingRequest, - SendTaskStreamingResponse, - TaskArtifactUpdateEvent, - TaskStatusUpdateEvent, - Task, - TaskIdParams, - PushNotificationConfig, - SetTaskPushNotificationRequest, - SetTaskPushNotificationResponse, - TaskPushNotificationConfig, - TaskNotFoundError, - InvalidParamsError, -) -from A2A.common.server.task_manager import InMemoryTaskManager -from A2A.Manus.agent import A2AManus -from A2A.common.utils.push_notification_auth import PushNotificationSenderAuth -import A2A.common.server.utils as utils -from typing import Union,Callable,Awaitable -import asyncio -import logging -import traceback - -logger = logging.getLogger(__name__) - - -class AgentTaskManager(InMemoryTaskManager): - def __init__(self, agent_factory: Callable[[],Awaitable[A2AManus]], notification_sender_auth: PushNotificationSenderAuth): - super().__init__() - self.agent_factory = agent_factory - self.notification_sender_auth = notification_sender_auth - - async def _run_streaming_agent(self, request: SendTaskStreamingRequest): - task_send_params: TaskSendParams = request.params - query = self._get_user_query(task_send_params) - agent= await self.agent_factory() - try: - async for item in agent.stream(query, task_send_params.sessionId): - is_task_complete = item["is_task_complete"] - require_user_input = item["require_user_input"] - artifact = None - message = None - parts = [{"type": "text", "text": item["content"]}] - end_stream = False - - if not is_task_complete and not require_user_input: - task_state = TaskState.WORKING - message = Message(role="agent", parts=parts) - elif require_user_input: - task_state = TaskState.INPUT_REQUIRED - message = Message(role="agent", parts=parts) - end_stream = True - else: - task_state = TaskState.COMPLETED - artifact = Artifact(parts=parts, index=0, append=False) - end_stream = True - - task_status = TaskStatus(state=task_state, message=message) - latest_task = await self.update_store( - task_send_params.id, - task_status, - None if artifact is None else [artifact], - ) - await self.send_task_notification(latest_task) - - if artifact: - task_artifact_update_event = TaskArtifactUpdateEvent( - id=task_send_params.id, artifact=artifact - ) - await self.enqueue_events_for_sse( - task_send_params.id, task_artifact_update_event - ) - - - task_update_event = TaskStatusUpdateEvent( - id=task_send_params.id, status=task_status, final=end_stream - ) - await self.enqueue_events_for_sse( - task_send_params.id, task_update_event - ) - - except Exception as e: - logger.error(f"An error occurred while streaming the response: {e}") - await self.enqueue_events_for_sse( - task_send_params.id, - InternalError(message=f"An error occurred while streaming the response: {e}") - ) - - def _validate_request( - self, request: Union[SendTaskRequest, SendTaskStreamingRequest] - ) -> JSONRPCResponse | None: - task_send_params: TaskSendParams = request.params - if not utils.are_modalities_compatible( - task_send_params.acceptedOutputModes, A2AManus.SUPPORTED_CONTENT_TYPES - ): - logger.warning( - "Unsupported output mode. Received %s, Support %s", - task_send_params.acceptedOutputModes, - A2AManus.SUPPORTED_CONTENT_TYPES, - ) - return utils.new_incompatible_types_error(request.id) - - if task_send_params.pushNotification and not task_send_params.pushNotification.url: - logger.warning("Push notification URL is missing") - return JSONRPCResponse(id=request.id, error=InvalidParamsError(message="Push notification URL is missing")) - - return None - - async def on_send_task(self, request: SendTaskRequest) -> SendTaskResponse: - """Handles the 'send task' request.""" - validation_error = self._validate_request(request) - if validation_error: - return SendTaskResponse(id=request.id, error=validation_error.error) - - if request.params.pushNotification: - if not await self.set_push_notification_info(request.params.id, request.params.pushNotification): - return SendTaskResponse(id=request.id, error=InvalidParamsError(message="Push notification URL is invalid")) - - await self.upsert_task(request.params) - task = await self.update_store( - request.params.id, TaskStatus(state=TaskState.WORKING), None - ) - await self.send_task_notification(task) - - task_send_params: TaskSendParams = request.params - query = self._get_user_query(task_send_params) - agent= await self.agent_factory() - try: - agent_response = await agent.invoke(query, task_send_params.sessionId) - except Exception as e: - logger.error(f"Error invoking agent: {e}") - raise ValueError(f"Error invoking agent: {e}") - return await self._process_agent_response( - request, agent_response - ) - - async def on_send_task_subscribe( - self, request: SendTaskStreamingRequest - ) -> AsyncIterable[SendTaskStreamingResponse] | JSONRPCResponse: - try: - error = self._validate_request(request) - if error: - return error - - await self.upsert_task(request.params) - - if request.params.pushNotification: - if not await self.set_push_notification_info(request.params.id, request.params.pushNotification): - return JSONRPCResponse(id=request.id, error=InvalidParamsError(message="Push notification URL is invalid")) - - task_send_params: TaskSendParams = request.params - sse_event_queue = await self.setup_sse_consumer(task_send_params.id, False) - - asyncio.create_task(self._run_streaming_agent(request)) - - return self.dequeue_events_for_sse( - request.id, task_send_params.id, sse_event_queue - ) - except Exception as e: - logger.error(f"Error in SSE stream: {e}") - print(traceback.format_exc()) - return JSONRPCResponse( - id=request.id, - error=InternalError( - message="An error occurred while streaming the response" - ), - ) - - async def _process_agent_response( - self, request: SendTaskRequest, agent_response: dict - ) -> SendTaskResponse: - """Processes the agent's response and updates the task store.""" - task_send_params: TaskSendParams = request.params - task_id = task_send_params.id - history_length = task_send_params.historyLength - task_status = None - - parts = [{"type": "text", "text": agent_response["content"]}] - artifact = None - if agent_response["require_user_input"]: - task_status = TaskStatus( - state=TaskState.INPUT_REQUIRED, - message=Message(role="agent", parts=parts), - ) - else: - task_status = TaskStatus(state=TaskState.COMPLETED) - artifact = Artifact(parts=parts) - task = await self.update_store( - task_id, task_status, None if artifact is None else [artifact] - ) - task_result = self.append_task_history(task, history_length) - await self.send_task_notification(task) - return SendTaskResponse(id=request.id, result=task_result) - - def _get_user_query(self, task_send_params: TaskSendParams) -> str: - part = task_send_params.message.parts[0] - if not isinstance(part, TextPart): - raise ValueError("Only text parts are supported") - return part.text - - async def send_task_notification(self, task: Task): - if not await self.has_push_notification_info(task.id): - logger.info(f"No push notification info found for task {task.id}") - return - push_info = await self.get_push_notification_info(task.id) - - logger.info(f"Notifying for task {task.id} => {task.status.state}") - await self.notification_sender_auth.send_push_notification( - push_info.url, - data=task.model_dump(exclude_none=True) - ) - - async def on_resubscribe_to_task( - self, request - ) -> AsyncIterable[SendTaskStreamingResponse] | JSONRPCResponse: - task_id_params: TaskIdParams = request.params - try: - sse_event_queue = await self.setup_sse_consumer(task_id_params.id, True) - return self.dequeue_events_for_sse(request.id, task_id_params.id, sse_event_queue) - except Exception as e: - logger.error(f"Error while reconnecting to SSE stream: {e}") - return JSONRPCResponse( - id=request.id, - error=InternalError( - message=f"An error occurred while reconnecting to stream: {e}" - ), - ) - - async def set_push_notification_info(self, task_id: str, push_notification_config: PushNotificationConfig): - # Verify the ownership of notification URL by issuing a challenge request. - is_verified = await self.notification_sender_auth.verify_push_notification_url(push_notification_config.url) - if not is_verified: - return False - - await super().set_push_notification_info(task_id, push_notification_config) - return True diff --git a/A2A/common/__init__.py b/A2A/common/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/A2A/common/client/__init__.py b/A2A/common/client/__init__.py deleted file mode 100644 index 5aa0e0d..0000000 --- a/A2A/common/client/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .client import A2AClient -from .card_resolver import A2ACardResolver - -__all__ = ["A2AClient", "A2ACardResolver"] diff --git a/A2A/common/client/card_resolver.py b/A2A/common/client/card_resolver.py deleted file mode 100644 index dd5873c..0000000 --- a/A2A/common/client/card_resolver.py +++ /dev/null @@ -1,21 +0,0 @@ -import httpx -from common.types import ( - AgentCard, - A2AClientJSONError, -) -import json - - -class A2ACardResolver: - def __init__(self, base_url, agent_card_path="/.well-known/agent.json"): - self.base_url = base_url.rstrip("/") - self.agent_card_path = agent_card_path.lstrip("/") - - def get_agent_card(self) -> AgentCard: - with httpx.Client() as client: - response = client.get(self.base_url + "/" + self.agent_card_path) - response.raise_for_status() - try: - return AgentCard(**response.json()) - except json.JSONDecodeError as e: - raise A2AClientJSONError(str(e)) from e diff --git a/A2A/common/client/client.py b/A2A/common/client/client.py deleted file mode 100644 index 5e969d1..0000000 --- a/A2A/common/client/client.py +++ /dev/null @@ -1,86 +0,0 @@ -import httpx -from httpx_sse import connect_sse -from typing import Any, AsyncIterable -from common.types import ( - AgentCard, - GetTaskRequest, - SendTaskRequest, - SendTaskResponse, - JSONRPCRequest, - GetTaskResponse, - CancelTaskResponse, - CancelTaskRequest, - SetTaskPushNotificationRequest, - SetTaskPushNotificationResponse, - GetTaskPushNotificationRequest, - GetTaskPushNotificationResponse, - A2AClientHTTPError, - A2AClientJSONError, - SendTaskStreamingRequest, - SendTaskStreamingResponse, -) -import json - - -class A2AClient: - def __init__(self, agent_card: AgentCard = None, url: str = None): - if agent_card: - self.url = agent_card.url - elif url: - self.url = url - else: - raise ValueError("Must provide either agent_card or url") - - async def send_task(self, payload: dict[str, Any]) -> SendTaskResponse: - request = SendTaskRequest(params=payload) - return SendTaskResponse(**await self._send_request(request)) - - async def send_task_streaming( - self, payload: dict[str, Any] - ) -> AsyncIterable[SendTaskStreamingResponse]: - request = SendTaskStreamingRequest(params=payload) - with httpx.Client(timeout=None) as client: - with connect_sse( - client, "POST", self.url, json=request.model_dump() - ) as event_source: - try: - for sse in event_source.iter_sse(): - yield SendTaskStreamingResponse(**json.loads(sse.data)) - except json.JSONDecodeError as e: - raise A2AClientJSONError(str(e)) from e - except httpx.RequestError as e: - raise A2AClientHTTPError(400, str(e)) from e - - async def _send_request(self, request: JSONRPCRequest) -> dict[str, Any]: - async with httpx.AsyncClient() as client: - try: - # Image generation could take time, adding timeout - response = await client.post( - self.url, json=request.model_dump(), timeout=30 - ) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - raise A2AClientHTTPError(e.response.status_code, str(e)) from e - except json.JSONDecodeError as e: - raise A2AClientJSONError(str(e)) from e - - async def get_task(self, payload: dict[str, Any]) -> GetTaskResponse: - request = GetTaskRequest(params=payload) - return GetTaskResponse(**await self._send_request(request)) - - async def cancel_task(self, payload: dict[str, Any]) -> CancelTaskResponse: - request = CancelTaskRequest(params=payload) - return CancelTaskResponse(**await self._send_request(request)) - - async def set_task_callback( - self, payload: dict[str, Any] - ) -> SetTaskPushNotificationResponse: - request = SetTaskPushNotificationRequest(params=payload) - return SetTaskPushNotificationResponse(**await self._send_request(request)) - - async def get_task_callback( - self, payload: dict[str, Any] - ) -> GetTaskPushNotificationResponse: - request = GetTaskPushNotificationRequest(params=payload) - return GetTaskPushNotificationResponse(**await self._send_request(request)) diff --git a/A2A/common/server/__init__.py b/A2A/common/server/__init__.py deleted file mode 100644 index 10f5fa4..0000000 --- a/A2A/common/server/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .server import A2AServer -from .task_manager import TaskManager, InMemoryTaskManager - -__all__ = ["A2AServer", "TaskManager", "InMemoryTaskManager"] diff --git a/A2A/common/server/server.py b/A2A/common/server/server.py deleted file mode 100644 index 3f78f1b..0000000 --- a/A2A/common/server/server.py +++ /dev/null @@ -1,120 +0,0 @@ -from starlette.applications import Starlette -from starlette.responses import JSONResponse -from sse_starlette.sse import EventSourceResponse -from starlette.requests import Request -from A2A.common.types import ( - A2ARequest, - JSONRPCResponse, - InvalidRequestError, - JSONParseError, - GetTaskRequest, - CancelTaskRequest, - SendTaskRequest, - SetTaskPushNotificationRequest, - GetTaskPushNotificationRequest, - InternalError, - AgentCard, - TaskResubscriptionRequest, - SendTaskStreamingRequest, -) -from pydantic import ValidationError -import json -from typing import AsyncIterable, Any -from A2A.common.server.task_manager import TaskManager - -import logging - -logger = logging.getLogger(__name__) - - -class A2AServer: - def __init__( - self, - host="0.0.0.0", - port=5000, - endpoint="/", - agent_card: AgentCard = None, - task_manager: TaskManager = None, - ): - self.host = host - self.port = port - self.endpoint = endpoint - self.task_manager = task_manager - self.agent_card = agent_card - self.app = Starlette() - self.app.add_route(self.endpoint, self._process_request, methods=["POST"]) - self.app.add_route( - "/.well-known/agent.json", self._get_agent_card, methods=["GET"] - ) - - def start(self): - if self.agent_card is None: - raise ValueError("agent_card is not defined") - - if self.task_manager is None: - raise ValueError("request_handler is not defined") - - import uvicorn - - uvicorn.run(self.app, host=self.host, port=self.port) - - def _get_agent_card(self, request: Request) -> JSONResponse: - return JSONResponse(self.agent_card.model_dump(exclude_none=True)) - - async def _process_request(self, request: Request): - try: - body = await request.json() - json_rpc_request = A2ARequest.validate_python(body) - - if isinstance(json_rpc_request, GetTaskRequest): - result = await self.task_manager.on_get_task(json_rpc_request) - elif isinstance(json_rpc_request, SendTaskRequest): - result = await self.task_manager.on_send_task(json_rpc_request) - elif isinstance(json_rpc_request, SendTaskStreamingRequest): - result = await self.task_manager.on_send_task_subscribe( - json_rpc_request - ) - elif isinstance(json_rpc_request, CancelTaskRequest): - result = await self.task_manager.on_cancel_task(json_rpc_request) - elif isinstance(json_rpc_request, SetTaskPushNotificationRequest): - result = await self.task_manager.on_set_task_push_notification(json_rpc_request) - elif isinstance(json_rpc_request, GetTaskPushNotificationRequest): - result = await self.task_manager.on_get_task_push_notification(json_rpc_request) - elif isinstance(json_rpc_request, TaskResubscriptionRequest): - result = await self.task_manager.on_resubscribe_to_task( - json_rpc_request - ) - else: - logger.warning(f"Unexpected request type: {type(json_rpc_request)}") - raise ValueError(f"Unexpected request type: {type(request)}") - - return self._create_response(result) - - except Exception as e: - return self._handle_exception(e) - - def _handle_exception(self, e: Exception) -> JSONResponse: - if isinstance(e, json.decoder.JSONDecodeError): - json_rpc_error = JSONParseError() - elif isinstance(e, ValidationError): - json_rpc_error = InvalidRequestError(data=json.loads(e.json())) - else: - logger.error(f"Unhandled exception: {e}") - json_rpc_error = InternalError() - - response = JSONRPCResponse(id=None, error=json_rpc_error) - return JSONResponse(response.model_dump(exclude_none=True), status_code=400) - - def _create_response(self, result: Any) -> JSONResponse | EventSourceResponse: - if isinstance(result, AsyncIterable): - - async def event_generator(result) -> AsyncIterable[dict[str, str]]: - async for item in result: - yield {"data": item.model_dump_json(exclude_none=True)} - - return EventSourceResponse(event_generator(result)) - elif isinstance(result, JSONRPCResponse): - return JSONResponse(result.model_dump(exclude_none=True)) - else: - logger.error(f"Unexpected result type: {type(result)}") - raise ValueError(f"Unexpected result type: {type(result)}") diff --git a/A2A/common/server/task_manager.py b/A2A/common/server/task_manager.py deleted file mode 100644 index 54f5ee4..0000000 --- a/A2A/common/server/task_manager.py +++ /dev/null @@ -1,277 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Union, AsyncIterable, List -from A2A.common.types import Task -from A2A.common.types import ( - JSONRPCResponse, - TaskIdParams, - TaskQueryParams, - GetTaskRequest, - TaskNotFoundError, - SendTaskRequest, - CancelTaskRequest, - TaskNotCancelableError, - SetTaskPushNotificationRequest, - GetTaskPushNotificationRequest, - GetTaskResponse, - CancelTaskResponse, - SendTaskResponse, - SetTaskPushNotificationResponse, - GetTaskPushNotificationResponse, - PushNotificationNotSupportedError, - TaskSendParams, - TaskStatus, - TaskState, - TaskResubscriptionRequest, - SendTaskStreamingRequest, - SendTaskStreamingResponse, - Artifact, - PushNotificationConfig, - TaskStatusUpdateEvent, - JSONRPCError, - TaskPushNotificationConfig, - InternalError, -) -from A2A.common.server.utils import new_not_implemented_error -import asyncio -import logging - -logger = logging.getLogger(__name__) - -class TaskManager(ABC): - @abstractmethod - async def on_get_task(self, request: GetTaskRequest) -> GetTaskResponse: - pass - - @abstractmethod - async def on_cancel_task(self, request: CancelTaskRequest) -> CancelTaskResponse: - pass - - @abstractmethod - async def on_send_task(self, request: SendTaskRequest) -> SendTaskResponse: - pass - - @abstractmethod - async def on_send_task_subscribe( - self, request: SendTaskStreamingRequest - ) -> Union[AsyncIterable[SendTaskStreamingResponse], JSONRPCResponse]: - pass - - @abstractmethod - async def on_set_task_push_notification( - self, request: SetTaskPushNotificationRequest - ) -> SetTaskPushNotificationResponse: - pass - - @abstractmethod - async def on_get_task_push_notification( - self, request: GetTaskPushNotificationRequest - ) -> GetTaskPushNotificationResponse: - pass - - @abstractmethod - async def on_resubscribe_to_task( - self, request: TaskResubscriptionRequest - ) -> Union[AsyncIterable[SendTaskResponse], JSONRPCResponse]: - pass - - -class InMemoryTaskManager(TaskManager): - def __init__(self): - self.tasks: dict[str, Task] = {} - self.push_notification_infos: dict[str, PushNotificationConfig] = {} - self.lock = asyncio.Lock() - self.task_sse_subscribers: dict[str, List[asyncio.Queue]] = {} - self.subscriber_lock = asyncio.Lock() - - async def on_get_task(self, request: GetTaskRequest) -> GetTaskResponse: - logger.info(f"Getting task {request.params.id}") - task_query_params: TaskQueryParams = request.params - - async with self.lock: - task = self.tasks.get(task_query_params.id) - if task is None: - return GetTaskResponse(id=request.id, error=TaskNotFoundError()) - - task_result = self.append_task_history( - task, task_query_params.historyLength - ) - - return GetTaskResponse(id=request.id, result=task_result) - - async def on_cancel_task(self, request: CancelTaskRequest) -> CancelTaskResponse: - logger.info(f"Cancelling task {request.params.id}") - task_id_params: TaskIdParams = request.params - - async with self.lock: - task = self.tasks.get(task_id_params.id) - if task is None: - return CancelTaskResponse(id=request.id, error=TaskNotFoundError()) - - return CancelTaskResponse(id=request.id, error=TaskNotCancelableError()) - - @abstractmethod - async def on_send_task(self, request: SendTaskRequest) -> SendTaskResponse: - pass - - @abstractmethod - async def on_send_task_subscribe( - self, request: SendTaskStreamingRequest - ) -> Union[AsyncIterable[SendTaskStreamingResponse], JSONRPCResponse]: - pass - - async def set_push_notification_info(self, task_id: str, notification_config: PushNotificationConfig): - async with self.lock: - task = self.tasks.get(task_id) - if task is None: - raise ValueError(f"Task not found for {task_id}") - - self.push_notification_infos[task_id] = notification_config - - return - - async def get_push_notification_info(self, task_id: str) -> PushNotificationConfig: - async with self.lock: - task = self.tasks.get(task_id) - if task is None: - raise ValueError(f"Task not found for {task_id}") - - return self.push_notification_infos[task_id] - - return - - async def has_push_notification_info(self, task_id: str) -> bool: - async with self.lock: - return task_id in self.push_notification_infos - - - async def on_set_task_push_notification( - self, request: SetTaskPushNotificationRequest - ) -> SetTaskPushNotificationResponse: - logger.info(f"Setting task push notification {request.params.id}") - task_notification_params: TaskPushNotificationConfig = request.params - - try: - await self.set_push_notification_info(task_notification_params.id, task_notification_params.pushNotificationConfig) - except Exception as e: - logger.error(f"Error while setting push notification info: {e}") - return JSONRPCResponse( - id=request.id, - error=InternalError( - message="An error occurred while setting push notification info" - ), - ) - - return SetTaskPushNotificationResponse(id=request.id, result=task_notification_params) - - async def on_get_task_push_notification( - self, request: GetTaskPushNotificationRequest - ) -> GetTaskPushNotificationResponse: - logger.info(f"Getting task push notification {request.params.id}") - task_params: TaskIdParams = request.params - - try: - notification_info = await self.get_push_notification_info(task_params.id) - except Exception as e: - logger.error(f"Error while getting push notification info: {e}") - return GetTaskPushNotificationResponse( - id=request.id, - error=InternalError( - message="An error occurred while getting push notification info" - ), - ) - - return GetTaskPushNotificationResponse(id=request.id, result=TaskPushNotificationConfig(id=task_params.id, pushNotificationConfig=notification_info)) - - async def upsert_task(self, task_send_params: TaskSendParams) -> Task: - logger.info(f"Upserting task {task_send_params.id}") - async with self.lock: - task = self.tasks.get(task_send_params.id) - if task is None: - task = Task( - id=task_send_params.id, - sessionId = task_send_params.sessionId, - messages=[task_send_params.message], - status=TaskStatus(state=TaskState.SUBMITTED), - history=[task_send_params.message], - ) - self.tasks[task_send_params.id] = task - else: - task.history.append(task_send_params.message) - - return task - - async def on_resubscribe_to_task( - self, request: TaskResubscriptionRequest - ) -> Union[AsyncIterable[SendTaskStreamingResponse], JSONRPCResponse]: - return new_not_implemented_error(request.id) - - async def update_store( - self, task_id: str, status: TaskStatus, artifacts: list[Artifact] - ) -> Task: - async with self.lock: - try: - task = self.tasks[task_id] - except KeyError: - logger.error(f"Task {task_id} not found for updating the task") - raise ValueError(f"Task {task_id} not found") - - task.status = status - - if status.message is not None: - task.history.append(status.message) - - if artifacts is not None: - if task.artifacts is None: - task.artifacts = [] - task.artifacts.extend(artifacts) - - return task - - def append_task_history(self, task: Task, historyLength: int | None): - new_task = task.model_copy() - if historyLength is not None and historyLength > 0: - new_task.history = new_task.history[-historyLength:] - else: - new_task.history = [] - - return new_task - - async def setup_sse_consumer(self, task_id: str, is_resubscribe: bool = False): - async with self.subscriber_lock: - if task_id not in self.task_sse_subscribers: - if is_resubscribe: - raise ValueError("Task not found for resubscription") - else: - self.task_sse_subscribers[task_id] = [] - - sse_event_queue = asyncio.Queue(maxsize=0) # <=0 is unlimited - self.task_sse_subscribers[task_id].append(sse_event_queue) - return sse_event_queue - - async def enqueue_events_for_sse(self, task_id, task_update_event): - async with self.subscriber_lock: - if task_id not in self.task_sse_subscribers: - return - - current_subscribers = self.task_sse_subscribers[task_id] - for subscriber in current_subscribers: - await subscriber.put(task_update_event) - - async def dequeue_events_for_sse( - self, request_id, task_id, sse_event_queue: asyncio.Queue - ) -> AsyncIterable[SendTaskStreamingResponse] | JSONRPCResponse: - try: - while True: - event = await sse_event_queue.get() - if isinstance(event, JSONRPCError): - yield SendTaskStreamingResponse(id=request_id, error=event) - break - - yield SendTaskStreamingResponse(id=request_id, result=event) - if isinstance(event, TaskStatusUpdateEvent) and event.final: - break - finally: - async with self.subscriber_lock: - if task_id in self.task_sse_subscribers: - self.task_sse_subscribers[task_id].remove(sse_event_queue) - diff --git a/A2A/common/server/utils.py b/A2A/common/server/utils.py deleted file mode 100644 index 0727a5b..0000000 --- a/A2A/common/server/utils.py +++ /dev/null @@ -1,28 +0,0 @@ -from A2A.common.types import ( - JSONRPCResponse, - ContentTypeNotSupportedError, - UnsupportedOperationError, -) -from typing import List - - -def are_modalities_compatible( - server_output_modes: List[str], client_output_modes: List[str] -): - """Modalities are compatible if they are both non-empty - and there is at least one common element.""" - if client_output_modes is None or len(client_output_modes) == 0: - return True - - if server_output_modes is None or len(server_output_modes) == 0: - return True - - return any(x in server_output_modes for x in client_output_modes) - - -def new_incompatible_types_error(request_id): - return JSONRPCResponse(id=request_id, error=ContentTypeNotSupportedError()) - - -def new_not_implemented_error(request_id): - return JSONRPCResponse(id=request_id, error=UnsupportedOperationError()) diff --git a/A2A/common/types.py b/A2A/common/types.py deleted file mode 100644 index 585fec3..0000000 --- a/A2A/common/types.py +++ /dev/null @@ -1,365 +0,0 @@ -from typing import Union, Any -from pydantic import BaseModel, Field, TypeAdapter -from typing import Literal, List, Annotated, Optional -from datetime import datetime -from pydantic import model_validator, ConfigDict, field_serializer -from uuid import uuid4 -from enum import Enum -from typing_extensions import Self - - -class TaskState(str, Enum): - SUBMITTED = "submitted" - WORKING = "working" - INPUT_REQUIRED = "input-required" - COMPLETED = "completed" - CANCELED = "canceled" - FAILED = "failed" - UNKNOWN = "unknown" - - -class TextPart(BaseModel): - type: Literal["text"] = "text" - text: str - metadata: dict[str, Any] | None = None - - -class FileContent(BaseModel): - name: str | None = None - mimeType: str | None = None - bytes: str | None = None - uri: str | None = None - - @model_validator(mode="after") - def check_content(self) -> Self: - if not (self.bytes or self.uri): - raise ValueError("Either 'bytes' or 'uri' must be present in the file data") - if self.bytes and self.uri: - raise ValueError( - "Only one of 'bytes' or 'uri' can be present in the file data" - ) - return self - - -class FilePart(BaseModel): - type: Literal["file"] = "file" - file: FileContent - metadata: dict[str, Any] | None = None - - -class DataPart(BaseModel): - type: Literal["data"] = "data" - data: dict[str, Any] - metadata: dict[str, Any] | None = None - - -Part = Annotated[Union[TextPart, FilePart, DataPart], Field(discriminator="type")] - - -class Message(BaseModel): - role: Literal["user", "agent"] - parts: List[Part] - metadata: dict[str, Any] | None = None - - -class TaskStatus(BaseModel): - state: TaskState - message: Message | None = None - timestamp: datetime = Field(default_factory=datetime.now) - - @field_serializer("timestamp") - def serialize_dt(self, dt: datetime, _info): - return dt.isoformat() - - -class Artifact(BaseModel): - name: str | None = None - description: str | None = None - parts: List[Part] - metadata: dict[str, Any] | None = None - index: int = 0 - append: bool | None = None - lastChunk: bool | None = None - - -class Task(BaseModel): - id: str - sessionId: str | None = None - status: TaskStatus - artifacts: List[Artifact] | None = None - history: List[Message] | None = None - metadata: dict[str, Any] | None = None - - -class TaskStatusUpdateEvent(BaseModel): - id: str - status: TaskStatus - final: bool = False - metadata: dict[str, Any] | None = None - - -class TaskArtifactUpdateEvent(BaseModel): - id: str - artifact: Artifact - metadata: dict[str, Any] | None = None - - -class AuthenticationInfo(BaseModel): - model_config = ConfigDict(extra="allow") - - schemes: List[str] - credentials: str | None = None - - -class PushNotificationConfig(BaseModel): - url: str - token: str | None = None - authentication: AuthenticationInfo | None = None - - -class TaskIdParams(BaseModel): - id: str - metadata: dict[str, Any] | None = None - - -class TaskQueryParams(TaskIdParams): - historyLength: int | None = None - - -class TaskSendParams(BaseModel): - id: str - sessionId: str = Field(default_factory=lambda: uuid4().hex) - message: Message - acceptedOutputModes: Optional[List[str]] = None - pushNotification: PushNotificationConfig | None = None - historyLength: int | None = None - metadata: dict[str, Any] | None = None - - -class TaskPushNotificationConfig(BaseModel): - id: str - pushNotificationConfig: PushNotificationConfig - - -## RPC Messages - - -class JSONRPCMessage(BaseModel): - jsonrpc: Literal["2.0"] = "2.0" - id: int | str | None = Field(default_factory=lambda: uuid4().hex) - - -class JSONRPCRequest(JSONRPCMessage): - method: str - params: dict[str, Any] | None = None - - -class JSONRPCError(BaseModel): - code: int - message: str - data: Any | None = None - - -class JSONRPCResponse(JSONRPCMessage): - result: Any | None = None - error: JSONRPCError | None = None - - -class SendTaskRequest(JSONRPCRequest): - method: Literal["tasks/send"] = "tasks/send" - params: TaskSendParams - - -class SendTaskResponse(JSONRPCResponse): - result: Task | None = None - - -class SendTaskStreamingRequest(JSONRPCRequest): - method: Literal["tasks/sendSubscribe"] = "tasks/sendSubscribe" - params: TaskSendParams - - -class SendTaskStreamingResponse(JSONRPCResponse): - result: TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None = None - - -class GetTaskRequest(JSONRPCRequest): - method: Literal["tasks/get"] = "tasks/get" - params: TaskQueryParams - - -class GetTaskResponse(JSONRPCResponse): - result: Task | None = None - - -class CancelTaskRequest(JSONRPCRequest): - method: Literal["tasks/cancel",] = "tasks/cancel" - params: TaskIdParams - - -class CancelTaskResponse(JSONRPCResponse): - result: Task | None = None - - -class SetTaskPushNotificationRequest(JSONRPCRequest): - method: Literal["tasks/pushNotification/set",] = "tasks/pushNotification/set" - params: TaskPushNotificationConfig - - -class SetTaskPushNotificationResponse(JSONRPCResponse): - result: TaskPushNotificationConfig | None = None - - -class GetTaskPushNotificationRequest(JSONRPCRequest): - method: Literal["tasks/pushNotification/get",] = "tasks/pushNotification/get" - params: TaskIdParams - - -class GetTaskPushNotificationResponse(JSONRPCResponse): - result: TaskPushNotificationConfig | None = None - - -class TaskResubscriptionRequest(JSONRPCRequest): - method: Literal["tasks/resubscribe",] = "tasks/resubscribe" - params: TaskIdParams - - -A2ARequest = TypeAdapter( - Annotated[ - Union[ - SendTaskRequest, - GetTaskRequest, - CancelTaskRequest, - SetTaskPushNotificationRequest, - GetTaskPushNotificationRequest, - TaskResubscriptionRequest, - SendTaskStreamingRequest, - ], - Field(discriminator="method"), - ] -) - -## Error types - - -class JSONParseError(JSONRPCError): - code: int = -32700 - message: str = "Invalid JSON payload" - data: Any | None = None - - -class InvalidRequestError(JSONRPCError): - code: int = -32600 - message: str = "Request payload validation error" - data: Any | None = None - - -class MethodNotFoundError(JSONRPCError): - code: int = -32601 - message: str = "Method not found" - data: None = None - - -class InvalidParamsError(JSONRPCError): - code: int = -32602 - message: str = "Invalid parameters" - data: Any | None = None - - -class InternalError(JSONRPCError): - code: int = -32603 - message: str = "Internal error" - data: Any | None = None - - -class TaskNotFoundError(JSONRPCError): - code: int = -32001 - message: str = "Task not found" - data: None = None - - -class TaskNotCancelableError(JSONRPCError): - code: int = -32002 - message: str = "Task cannot be canceled" - data: None = None - - -class PushNotificationNotSupportedError(JSONRPCError): - code: int = -32003 - message: str = "Push Notification is not supported" - data: None = None - - -class UnsupportedOperationError(JSONRPCError): - code: int = -32004 - message: str = "This operation is not supported" - data: None = None - - -class ContentTypeNotSupportedError(JSONRPCError): - code: int = -32005 - message: str = "Incompatible content types" - data: None = None - - -class AgentProvider(BaseModel): - organization: str - url: str | None = None - - -class AgentCapabilities(BaseModel): - streaming: bool = False - pushNotifications: bool = False - stateTransitionHistory: bool = False - - -class AgentAuthentication(BaseModel): - schemes: List[str] - credentials: str | None = None - - -class AgentSkill(BaseModel): - id: str - name: str - description: str | None = None - tags: List[str] | None = None - examples: List[str] | None = None - inputModes: List[str] | None = None - outputModes: List[str] | None = None - - -class AgentCard(BaseModel): - name: str - description: str | None = None - url: str - provider: AgentProvider | None = None - version: str - documentationUrl: str | None = None - capabilities: AgentCapabilities - authentication: AgentAuthentication | None = None - defaultInputModes: List[str] = ["text"] - defaultOutputModes: List[str] = ["text"] - skills: List[AgentSkill] - - -class A2AClientError(Exception): - pass - - -class A2AClientHTTPError(A2AClientError): - def __init__(self, status_code: int, message: str): - self.status_code = status_code - self.message = message - super().__init__(f"HTTP Error {status_code}: {message}") - - -class A2AClientJSONError(A2AClientError): - def __init__(self, message: str): - self.message = message - super().__init__(f"JSON Error: {message}") - - -class MissingAPIKeyError(Exception): - """Exception for missing API key.""" - - pass \ No newline at end of file diff --git a/A2A/common/utils/in_memory_cache.py b/A2A/common/utils/in_memory_cache.py deleted file mode 100644 index 06a29fe..0000000 --- a/A2A/common/utils/in_memory_cache.py +++ /dev/null @@ -1,109 +0,0 @@ -"""In Memory Cache utility.""" - -import threading -import time -from typing import Any, Dict, Optional - - -class InMemoryCache: - """A thread-safe Singleton class to manage cache data. - - Ensures only one instance of the cache exists across the application. - """ - - _instance: Optional["InMemoryCache"] = None - _lock: threading.Lock = threading.Lock() - _initialized: bool = False - - def __new__(cls): - """Override __new__ to control instance creation (Singleton pattern). - - Uses a lock to ensure thread safety during the first instantiation. - - Returns: - The singleton instance of InMemoryCache. - """ - if cls._instance is None: - with cls._lock: - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self): - """Initialize the cache storage. - - Uses a flag (_initialized) to ensure this logic runs only on the very first - creation of the singleton instance. - """ - if not self._initialized: - with self._lock: - if not self._initialized: - # print("Initializing SessionCache storage") - self._cache_data: Dict[str, Dict[str, Any]] = {} - self._ttl: Dict[str, float] = {} - self._data_lock: threading.Lock = threading.Lock() - self._initialized = True - - def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None: - """Set a key-value pair. - - Args: - key: The key for the data. - value: The data to store. - ttl: Time to live in seconds. If None, data will not expire. - """ - with self._data_lock: - self._cache_data[key] = value - - if ttl is not None: - self._ttl[key] = time.time() + ttl - else: - if key in self._ttl: - del self._ttl[key] - - def get(self, key: str, default: Any = None) -> Any: - """Get the value associated with a key. - - Args: - key: The key for the data within the session. - default: The value to return if the session or key is not found. - - Returns: - The cached value, or the default value if not found. - """ - with self._data_lock: - if key in self._ttl and time.time() > self._ttl[key]: - del self._cache_data[key] - del self._ttl[key] - return default - return self._cache_data.get(key, default) - - def delete(self, key: str) -> None: - """Delete a specific key-value pair from a cache. - - Args: - key: The key to delete. - - Returns: - True if the key was found and deleted, False otherwise. - """ - - with self._data_lock: - if key in self._cache_data: - del self._cache_data[key] - if key in self._ttl: - del self._ttl[key] - return True - return False - - def clear(self) -> bool: - """Remove all data. - - Returns: - True if the data was cleared, False otherwise. - """ - with self._data_lock: - self._cache_data.clear() - self._ttl.clear() - return True - return False diff --git a/A2A/common/utils/push_notification_auth.py b/A2A/common/utils/push_notification_auth.py deleted file mode 100644 index cc74ade..0000000 --- a/A2A/common/utils/push_notification_auth.py +++ /dev/null @@ -1,135 +0,0 @@ -from jwcrypto import jwk -import uuid -from starlette.responses import JSONResponse -from starlette.requests import Request -from typing import Any - -import jwt -import time -import json -import hashlib -import httpx -import logging - -from jwt import PyJWK, PyJWKClient - -logger = logging.getLogger(__name__) -AUTH_HEADER_PREFIX = 'Bearer ' - -class PushNotificationAuth: - def _calculate_request_body_sha256(self, data: dict[str, Any]): - """Calculates the SHA256 hash of a request body. - - This logic needs to be same for both the agent who signs the payload and the client verifier. - """ - body_str = json.dumps( - data, - ensure_ascii=False, - allow_nan=False, - indent=None, - separators=(",", ":"), - ) - return hashlib.sha256(body_str.encode()).hexdigest() - -class PushNotificationSenderAuth(PushNotificationAuth): - def __init__(self): - self.public_keys = [] - self.private_key_jwk: PyJWK = None - - @staticmethod - async def verify_push_notification_url(url: str) -> bool: - async with httpx.AsyncClient(timeout=10) as client: - try: - validation_token = str(uuid.uuid4()) - response = await client.get( - url, - params={"validationToken": validation_token} - ) - response.raise_for_status() - is_verified = response.text == validation_token - - logger.info(f"Verified push-notification URL: {url} => {is_verified}") - return is_verified - except Exception as e: - logger.warning(f"Error during sending push-notification for URL {url}: {e}") - - return False - - def generate_jwk(self): - key = jwk.JWK.generate(kty='RSA', size=2048, kid=str(uuid.uuid4()), use="sig") - self.public_keys.append(key.export_public(as_dict=True)) - self.private_key_jwk = PyJWK.from_json(key.export_private()) - - def handle_jwks_endpoint(self, _request: Request): - """Allow clients to fetch public keys. - """ - return JSONResponse({ - "keys": self.public_keys - }) - - def _generate_jwt(self, data: dict[str, Any]): - """JWT is generated by signing both the request payload SHA digest and time of token generation. - - Payload is signed with private key and it ensures the integrity of payload for client. - Including iat prevents from replay attack. - """ - - iat = int(time.time()) - - return jwt.encode( - {"iat": iat, "request_body_sha256": self._calculate_request_body_sha256(data)}, - key=self.private_key_jwk, - headers={"kid": self.private_key_jwk.key_id}, - algorithm="RS256" - ) - - async def send_push_notification(self, url: str, data: dict[str, Any]): - jwt_token = self._generate_jwt(data) - headers = {'Authorization': f"Bearer {jwt_token}"} - async with httpx.AsyncClient(timeout=10) as client: - try: - response = await client.post( - url, - json=data, - headers=headers - ) - response.raise_for_status() - logger.info(f"Push-notification sent for URL: {url}") - except Exception as e: - logger.warning(f"Error during sending push-notification for URL {url}: {e}") - -class PushNotificationReceiverAuth(PushNotificationAuth): - def __init__(self): - self.public_keys_jwks = [] - self.jwks_client = None - - async def load_jwks(self, jwks_url: str): - self.jwks_client = PyJWKClient(jwks_url) - - async def verify_push_notification(self, request: Request) -> bool: - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith(AUTH_HEADER_PREFIX): - print("Invalid authorization header") - return False - - token = auth_header[len(AUTH_HEADER_PREFIX):] - signing_key = self.jwks_client.get_signing_key_from_jwt(token) - - decode_token = jwt.decode( - token, - signing_key, - options={"require": ["iat", "request_body_sha256"]}, - algorithms=["RS256"], - ) - - actual_body_sha256 = self._calculate_request_body_sha256(await request.json()) - if actual_body_sha256 != decode_token["request_body_sha256"]: - # Payload signature does not match the digest in signed token. - raise ValueError("Invalid request body") - - if time.time() - decode_token["iat"] > 60 * 5: - # Do not allow push-notifications older than 5 minutes. - # This is to prevent replay attack. - raise ValueError("Token is expired") - - return True diff --git a/A2A/requirements.txt b/A2A/requirements.txt deleted file mode 100644 index c6287b9..0000000 --- a/A2A/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -httpx>=0.28.1 -httpx-sse>=0.4.0 -jwcrypto>=1.5.6 -pyjwt>=2.10.1 -sse-starlette>=2.2.1 -starlette>=0.46.1 -typing-extensions>=4.12.2 -uvicorn>=0.34.0 From 8faab601e70fe520e172314c4991e267c2cd6e4f Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Tue, 24 Jun 2025 22:19:38 +0800 Subject: [PATCH 7/9] feat:support A2A protocol --- {A2A/Manus => protocol/a2a}/__init__.py | 0 {A2A/Manus => protocol/a2a/app}/README.md | 2 +- {A2A/Manus => protocol/a2a/app}/README_zh.md | 2 +- {A2A => protocol/a2a/app}/__init__.py | 0 {A2A/Manus => protocol/a2a/app}/agent.py | 0 {A2A/Manus => protocol/a2a/app}/agent_executor.py | 2 +- {A2A/Manus => protocol/a2a/app}/main.py | 4 ++-- 7 files changed, 5 insertions(+), 5 deletions(-) rename {A2A/Manus => protocol/a2a}/__init__.py (100%) rename {A2A/Manus => protocol/a2a/app}/README.md (99%) rename {A2A/Manus => protocol/a2a/app}/README_zh.md (99%) rename {A2A => protocol/a2a/app}/__init__.py (100%) rename {A2A/Manus => protocol/a2a/app}/agent.py (100%) rename {A2A/Manus => protocol/a2a/app}/agent_executor.py (98%) rename {A2A/Manus => protocol/a2a/app}/main.py (97%) diff --git a/A2A/Manus/__init__.py b/protocol/a2a/__init__.py similarity index 100% rename from A2A/Manus/__init__.py rename to protocol/a2a/__init__.py diff --git a/A2A/Manus/README.md b/protocol/a2a/app/README.md similarity index 99% rename from A2A/Manus/README.md rename to protocol/a2a/app/README.md index 0dac56a..9520f88 100644 --- a/A2A/Manus/README.md +++ b/protocol/a2a/app/README.md @@ -13,7 +13,7 @@ This is an experimental integration of the A2A protocol (https://google.github.i ```bash cd OpenManus - python -m A2A.Manus.main + python -m protocol.a2a.app.main ``` 2. Clone A2A official repository and run A2A Client,there are two ways to use A2AClient——CLI and Register A2A Agent Server in UI.(details at https://github.com/google/A2A): diff --git a/A2A/Manus/README_zh.md b/protocol/a2a/app/README_zh.md similarity index 99% rename from A2A/Manus/README_zh.md rename to protocol/a2a/app/README_zh.md index d345ccc..1b8f66f 100644 --- a/A2A/Manus/README_zh.md +++ b/protocol/a2a/app/README_zh.md @@ -14,7 +14,7 @@ ```bash cd OpenManus - python -m A2A.Manus.main + python -m protocol.a2a.app.main ``` 2. 拉取A2A官方库并运行A2A Client,有两种使用A2A客户端的方式——CLI以及在前端页面注册Agent服务。(详情参考https://github.com/google/A2A): diff --git a/A2A/__init__.py b/protocol/a2a/app/__init__.py similarity index 100% rename from A2A/__init__.py rename to protocol/a2a/app/__init__.py diff --git a/A2A/Manus/agent.py b/protocol/a2a/app/agent.py similarity index 100% rename from A2A/Manus/agent.py rename to protocol/a2a/app/agent.py diff --git a/A2A/Manus/agent_executor.py b/protocol/a2a/app/agent_executor.py similarity index 98% rename from A2A/Manus/agent_executor.py rename to protocol/a2a/app/agent_executor.py index 857ab05..ea66618 100644 --- a/A2A/Manus/agent_executor.py +++ b/protocol/a2a/app/agent_executor.py @@ -14,7 +14,7 @@ from a2a.utils import ( completed_task, new_artifact, ) -from A2A.Manus.agent import A2AManus +from .agent import A2AManus from a2a.utils.errors import ServerError from typing import Callable, Awaitable diff --git a/A2A/Manus/main.py b/protocol/a2a/app/main.py similarity index 97% rename from A2A/Manus/main.py rename to protocol/a2a/app/main.py index c0eb604..66ca26c 100644 --- a/A2A/Manus/main.py +++ b/protocol/a2a/app/main.py @@ -9,9 +9,9 @@ from a2a.types import ( AgentSkill, ) -from A2A.Manus.agent_executor import ManusExecutor +from .agent_executor import ManusExecutor -from A2A.Manus.agent import A2AManus +from .agent import A2AManus from app.tool.browser_use_tool import _BROWSER_DESCRIPTION from app.tool.str_replace_editor import _STR_REPLACE_EDITOR_DESCRIPTION from app.tool.terminate import _TERMINATE_DESCRIPTION From c37100846b656c59243b0fdbe414714fa6815cb0 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Wed, 25 Jun 2025 19:03:02 +0800 Subject: [PATCH 8/9] parse host&port with argparse and reformat files --- protocol/a2a/app/agent.py | 10 +++--- protocol/a2a/app/agent_executor.py | 19 +++++----- protocol/a2a/app/main.py | 58 +++++++++++++++++++----------- 3 files changed, 54 insertions(+), 33 deletions(-) diff --git a/protocol/a2a/app/agent.py b/protocol/a2a/app/agent.py index aeae07a..9559ceb 100644 --- a/protocol/a2a/app/agent.py +++ b/protocol/a2a/app/agent.py @@ -3,28 +3,30 @@ from typing import Any, Dict, AsyncIterable, Literal, List, ClassVar from pydantic import BaseModel from app.agent.manus import Manus + class ResponseFormat(BaseModel): """Respond to the user in this format.""" + status: Literal["input_required", "completed", "error"] = "input_required" message: str + class A2AManus(Manus): async def invoke(self, query, sessionId) -> str: config = {"configurable": {"thread_id": sessionId}} response = await self.run(query) - return self.get_agent_response(config,response) + return self.get_agent_response(config, response) async def stream(self, query: str) -> AsyncIterable[Dict[str, Any]]: """Streaming is not supported by Manus.""" raise NotImplementedError("Streaming is not supported by Manus yet.") - - def get_agent_response(self, config,agent_response): + def get_agent_response(self, config, agent_response): return { "is_task_complete": True, "require_user_input": False, "content": agent_response, } - SUPPORTED_CONTENT_TYPES : ClassVar[List[str]] = ["text", "text/plain"] + SUPPORTED_CONTENT_TYPES: ClassVar[List[str]] = ["text", "text/plain"] diff --git a/protocol/a2a/app/agent_executor.py b/protocol/a2a/app/agent_executor.py index ea66618..bce0b1d 100644 --- a/protocol/a2a/app/agent_executor.py +++ b/protocol/a2a/app/agent_executor.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) class ManusExecutor(AgentExecutor): """Currency Conversion AgentExecutor Example.""" - def __init__(self,agent_factory:Callable[[],Awaitable[A2AManus]]): + def __init__(self, agent_factory: Callable[[], Awaitable[A2AManus]]): self.agent_factory = agent_factory async def execute( @@ -41,16 +41,18 @@ class ManusExecutor(AgentExecutor): try: self.agent = await self.agent_factory() result = await self.agent.invoke(query, context.context_id) - print(f'Final Result ===> {result}') + print(f"Final Result ===> {result}") except Exception as e: - print('Error invoking agent: %s', e) - raise ServerError( - error=ValueError(f'Error invoking agent: {e}') - ) from e + print("Error invoking agent: %s", e) + raise ServerError(error=ValueError(f"Error invoking agent: {e}")) from e parts = [ Part( root=TextPart( - text=result["content"] if result["content"] else 'failed to generate response' + text=( + result["content"] + if result["content"] + else "failed to generate response" + ) ), ) ] @@ -58,10 +60,11 @@ class ManusExecutor(AgentExecutor): completed_task( context.task_id, context.context_id, - [new_artifact(parts, f'task_{context.task_id}')], + [new_artifact(parts, f"task_{context.task_id}")], [context.message], ) ) + def _validate_request(self, context: RequestContext) -> bool: return False diff --git a/protocol/a2a/app/main.py b/protocol/a2a/app/main.py index 66ca26c..dd09f23 100644 --- a/protocol/a2a/app/main.py +++ b/protocol/a2a/app/main.py @@ -1,4 +1,5 @@ import httpx +import argparse from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler @@ -25,17 +26,20 @@ load_dotenv() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -async def main(host:str = "localhost", port:int = 10000): + +async def main(host: str = "localhost", port: int = 10000): """Starts the Manus Agent server.""" try: capabilities = AgentCapabilities(streaming=False, pushNotifications=True) - skills=[ + skills = [ AgentSkill( - id="Python Execute", - name="Python Execute Tool", - description="Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.", - tags=["Execute Python Code"], - examples=["Execute Python code:'''python \n Print('Hello World') \n '''"], + id="Python Execute", + name="Python Execute Tool", + description="Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.", + tags=["Execute Python Code"], + examples=[ + "Execute Python code:'''python \n Print('Hello World') \n '''" + ], ), AgentSkill( id="Browser use", @@ -64,9 +68,9 @@ async def main(host:str = "localhost", port:int = 10000): description=_TERMINATE_DESCRIPTION, tags=["terminate task"], examples=["terminate"], - ) + ), # Add more skills as needed - ] + ] agent_card = AgentCard( name="Manus Agent", @@ -81,12 +85,13 @@ async def main(host:str = "localhost", port:int = 10000): httpx_client = httpx.AsyncClient() request_handler = DefaultRequestHandler( - agent_executor=ManusExecutor(agent_factory=lambda:A2AManus.create(max_steps=3)), + agent_executor=ManusExecutor( + agent_factory=lambda: A2AManus.create(max_steps=3) + ), task_store=InMemoryTaskStore(), push_notifier=InMemoryPushNotifier(httpx_client), ) - server = A2AStarletteApplication( agent_card=agent_card, http_handler=request_handler ) @@ -97,22 +102,33 @@ async def main(host:str = "localhost", port:int = 10000): logger.error(f"An error occurred during server startup: {e}") exit(1) + def run_server(host: Optional[str] = "localhost", port: Optional[int] = 10000): try: import uvicorn + app = asyncio.run(main(host, port)) - config = uvicorn.Config(app=app, host=host, port=port, loop="asyncio", proxy_headers=True) + config = uvicorn.Config( + app=app, host=host, port=port, loop="asyncio", proxy_headers=True + ) uvicorn.Server(config=config).run() logger.info(f"Server started on {host}:{port}") except Exception as e: logger.error(f"An error occurred while starting the server: {e}") + + if __name__ == "__main__": - import sys - host = "localhost" # 默认值 - port = 10000 # 默认值 - for i in range(1, len(sys.argv)): - if sys.argv[i] == "--host": - host = sys.argv[i + 1] - elif sys.argv[i] == "--port": - port = int(sys.argv[i + 1]) - run_server(host, port) + # Parse command line arguments for host and port, with default values + parser = argparse.ArgumentParser(description="Start Manus Agent service") + parser.add_argument( + "--host", + type=str, + default="localhost", + help="Server host address, default is localhost", + ) + parser.add_argument( + "--port", type=int, default=10000, help="Server port, default is 10000" + ) + args = parser.parse_args() + # Start the server with the specified or default host and port + run_server(args.host, args.port) From c37a145edccb1fd8e440378c3eef6b0da5773d90 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Fri, 11 Jul 2025 23:14:27 +0800 Subject: [PATCH 9/9] specify a2a-sdk version --- protocol/a2a/app/README.md | 3 ++- protocol/a2a/app/README_zh.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/protocol/a2a/app/README.md b/protocol/a2a/app/README.md index 9520f88..c5ee04a 100644 --- a/protocol/a2a/app/README.md +++ b/protocol/a2a/app/README.md @@ -4,7 +4,8 @@ This is an experimental integration of the A2A protocol (https://google.github.i ## Prerequisites - conda activate 'Your OpenManus python env' -- pip install a2a-sdk +- pip install a2a-sdk==0.2.5 + ## Setup & Running diff --git a/protocol/a2a/app/README_zh.md b/protocol/a2a/app/README_zh.md index 1b8f66f..afa6fac 100644 --- a/protocol/a2a/app/README_zh.md +++ b/protocol/a2a/app/README_zh.md @@ -4,7 +4,7 @@ ## Prerequisites - conda activate 'Your OpenManus python env' -- pip install a2a-sdk +- pip install a2a-sdk==0.2.5