From aa512fac6e6f064315b95a87a94891f52839d6a8 Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Tue, 18 Mar 2025 11:46:35 +0800 Subject: [PATCH 01/26] refactor(app): Complete exception logging in LLM.ask --- app/llm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/llm.py b/app/llm.py index 18a13af..f78a7f7 100644 --- a/app/llm.py +++ b/app/llm.py @@ -309,8 +309,8 @@ class LLM: elif isinstance(oe, APIError): logger.error(f"API error: {oe}") raise - except Exception as e: - logger.error(f"Unexpected error in ask: {e}") + except Exception: + logger.exception(f"Unexpected error in ask") raise @retry( From ca612699ec0d2b1942c27a0ccd2c1adae39a1a48 Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Tue, 18 Mar 2025 11:53:47 +0800 Subject: [PATCH 02/26] refactor(app): explicitly specify LLM request parameters to allow typing --- app/llm.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/llm.py b/app/llm.py index f78a7f7..b527058 100644 --- a/app/llm.py +++ b/app/llm.py @@ -263,23 +263,23 @@ class LLM: if not stream: # Non-streaming request - params["stream"] = False - - response = await self.client.chat.completions.create(**params) + response = await self.client.chat.completions.create( + **params, stream=False + ) if not response.choices or not response.choices[0].message.content: raise ValueError("Empty or invalid response from LLM") # Update token counts - self.update_token_count(response.usage.prompt_tokens) + if response.usage: + self.update_token_count(response.usage.prompt_tokens) return response.choices[0].message.content # Streaming request, For streaming, update estimated token count before making the request self.update_token_count(input_tokens) - params["stream"] = True - response = await self.client.chat.completions.create(**params) + response = await self.client.chat.completions.create(**params, stream=True) collected_messages = [] async for chunk in response: @@ -292,6 +292,8 @@ class LLM: if not full_response: raise ValueError("Empty response from streaming LLM") + # TODO Update token counts + return full_response except TokenLimitExceeded: From cf7d6c120775fce34366661a611bf73352e9f8fc Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Tue, 18 Mar 2025 13:36:15 +0800 Subject: [PATCH 03/26] chore(app): Update error logging to use exception details --- app/llm.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/llm.py b/app/llm.py index b527058..6a147c4 100644 --- a/app/llm.py +++ b/app/llm.py @@ -299,11 +299,11 @@ class LLM: except TokenLimitExceeded: # Re-raise token limit errors without logging raise - except ValueError as ve: - logger.error(f"Validation error: {ve}") + except ValueError: + logger.exception(f"Validation error") raise except OpenAIError as oe: - logger.error(f"OpenAI API error: {oe}") + logger.exception(f"OpenAI API error") if isinstance(oe, AuthenticationError): logger.error("Authentication failed. Check API key.") elif isinstance(oe, RateLimitError): From b95244a60b461748a59894f06df7f62d84e2730f Mon Sep 17 00:00:00 2001 From: via Date: Tue, 18 Mar 2025 15:40:25 +0800 Subject: [PATCH 04/26] add bing search --- app/tool/search/__init__.py | 2 + app/tool/search/bing_search.py | 153 +++++++++++++++++++++++++++++++++ app/tool/web_search.py | 2 + 3 files changed, 157 insertions(+) create mode 100644 app/tool/search/bing_search.py diff --git a/app/tool/search/__init__.py b/app/tool/search/__init__.py index 4f486ac..8b6cfdb 100644 --- a/app/tool/search/__init__.py +++ b/app/tool/search/__init__.py @@ -2,6 +2,7 @@ from app.tool.search.baidu_search import BaiduSearchEngine from app.tool.search.base import WebSearchEngine from app.tool.search.duckduckgo_search import DuckDuckGoSearchEngine from app.tool.search.google_search import GoogleSearchEngine +from app.tool.search.bing_search import BingSearchEngine __all__ = [ @@ -9,4 +10,5 @@ __all__ = [ "BaiduSearchEngine", "DuckDuckGoSearchEngine", "GoogleSearchEngine", + "BingSearchEngine", ] diff --git a/app/tool/search/bing_search.py b/app/tool/search/bing_search.py new file mode 100644 index 0000000..53e94c9 --- /dev/null +++ b/app/tool/search/bing_search.py @@ -0,0 +1,153 @@ +import asyncio +from typing import List +import requests +from app.logger import logger +from bs4 import BeautifulSoup +from app.tool.search.base import WebSearchEngine + +ABSTRACT_MAX_LENGTH = 300 + +USER_AGENTS = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36', + 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/49.0.2623.108 Chrome/49.0.2623.108 Safari/537.36', + 'Mozilla/5.0 (Windows; U; Windows NT 5.1; pt-BR) AppleWebKit/533.3 (KHTML, like Gecko) QtWeb Internet Browser/3.7 http://www.QtWeb.net', + 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36', + 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/532.2 (KHTML, like Gecko) ChromePlus/4.0.222.3 Chrome/4.0.222.3 Safari/532.2', + 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4pre) Gecko/20070404 K-Ninja/2.1.3', + 'Mozilla/5.0 (Future Star Technologies Corp.; Star-Blade OS; x86_64; U; en-US) iNet Browser 4.7', + 'Mozilla/5.0 (Windows; U; Windows NT 6.1; rv:2.2) Gecko/20110201', + 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.13) Gecko/20080414 Firefox/2.0.0.13 Pogo/2.0.0.13.6866' +] + +HEADERS = { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENTS[0], + "Referer": "https://www.bing.com/", + "Accept-Encoding": "gzip, deflate", + "Accept-Language": "zh-CN,zh;q=0.9" +} + +BING_HOST_URL = "https://www.bing.com" +BING_SEARCH_URL = "https://www.bing.com/search?q=" + + +class BingSearchEngine(WebSearchEngine): + session: requests.Session = None + + def __init__(self, **data): + """Initialize the BingSearch tool with a requests session.""" + super().__init__(**data) + self.session = requests.Session() + self.session.headers.update(HEADERS) + + def _search_sync(self, query: str, num_results: int = 10) -> List[str]: + """ + Synchronous Bing search implementation to retrieve a list of URLs matching a query. + + Args: + query (str): The search query to submit to Bing. Must not be empty. + num_results (int, optional): The maximum number of URLs to return. Defaults to 10. + + Returns: + List[str]: A list of URLs from the search results, capped at `num_results`. + Returns an empty list if the query is empty or no results are found. + + Notes: + - Pagination is handled by incrementing the `first` parameter and following `next_url` links. + - If fewer results than `num_results` are available, all found URLs are returned. + """ + if not query: + return [] + + list_result = [] + first = 1 + next_url = BING_SEARCH_URL + query + + while len(list_result) < num_results: + data, next_url = self._parse_html(next_url, rank_start=len(list_result), first=first) + if data: + list_result.extend([item["url"] for item in data]) + if not next_url: + break + first += 10 + + return list_result[:num_results] + + def _parse_html(self, url: str, rank_start: int = 0, first: int = 1) -> tuple: + """ + Parse Bing search result HTML synchronously to extract search results and the next page URL. + + Args: + url (str): The URL of the Bing search results page to parse. + rank_start (int, optional): The starting rank for numbering the search results. Defaults to 0. + first (int, optional): Unused parameter (possibly legacy). Defaults to 1. + Returns: + tuple: A tuple containing: + - list: A list of dictionaries with keys 'title', 'abstract', 'url', and 'rank' for each result. + - str or None: The URL of the next results page, or None if there is no next page. + Example: + This function is called by `execute` in the following way: + ```python + results, next_url = self._parse_html(url, rank_start=0) + ``` + """ + try: + res = self.session.get(url=url) + res.encoding = "utf-8" + root = BeautifulSoup(res.text, "lxml") + + list_data = [] + ol_results = root.find("ol", id="b_results") + if not ol_results: + return [], None + + for li in ol_results.find_all("li", class_="b_algo"): + title = '' + url = '' + abstract = '' + try: + h2 = li.find("h2") + if h2: + title = h2.text.strip() + url = h2.a['href'].strip() + + p = li.find("p") + if p: + abstract = p.text.strip() + + if ABSTRACT_MAX_LENGTH and len(abstract) > ABSTRACT_MAX_LENGTH: + abstract = abstract[:ABSTRACT_MAX_LENGTH] + + rank_start += 1 + list_data.append({"title": title, "abstract": abstract, "url": url, "rank": rank_start}) + except Exception: + continue + + next_btn = root.find("a", title="Next page") + if not next_btn: + return list_data, None + + next_url = BING_HOST_URL + next_btn["href"] + return list_data, next_url + except Exception as e: + logger.warning(f"Error parsing HTML: {e}") + return [], None + + async def execute(self, query: str, num_results: int = 10) -> List[str]: + """ + Execute a Bing search and return a list of URLs asynchronously. + + Args: + query (str): The search query to submit to Bing. + num_results (int, optional): The number of search results to return. Default is 10. + + Returns: + List[str]: A list of URLs matching the search query. + """ + loop = asyncio.get_event_loop() + links = await loop.run_in_executor( + None, lambda: self._search_sync(query, num_results=num_results) + ) + return links diff --git a/app/tool/web_search.py b/app/tool/web_search.py index 7b1018b..99d8916 100644 --- a/app/tool/web_search.py +++ b/app/tool/web_search.py @@ -10,6 +10,7 @@ from app.tool.search import ( DuckDuckGoSearchEngine, GoogleSearchEngine, WebSearchEngine, + BingSearchEngine ) @@ -37,6 +38,7 @@ class WebSearch(BaseTool): "google": GoogleSearchEngine(), "baidu": BaiduSearchEngine(), "duckduckgo": DuckDuckGoSearchEngine(), + "bing": BingSearchEngine() } async def execute(self, query: str, num_results: int = 10) -> List[str]: From b62bf92e19849915cb9ca0d878bd27e3e00281ad Mon Sep 17 00:00:00 2001 From: via Date: Tue, 18 Mar 2025 16:57:29 +0800 Subject: [PATCH 05/26] modified bing search --- app/tool/search/bing_search.py | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/app/tool/search/bing_search.py b/app/tool/search/bing_search.py index 53e94c9..c5eaa86 100644 --- a/app/tool/search/bing_search.py +++ b/app/tool/search/bing_search.py @@ -1,4 +1,3 @@ -import asyncio from typing import List import requests from app.logger import logger @@ -45,13 +44,13 @@ class BingSearchEngine(WebSearchEngine): def _search_sync(self, query: str, num_results: int = 10) -> List[str]: """ Synchronous Bing search implementation to retrieve a list of URLs matching a query. - + Args: query (str): The search query to submit to Bing. Must not be empty. num_results (int, optional): The maximum number of URLs to return. Defaults to 10. Returns: - List[str]: A list of URLs from the search results, capped at `num_results`. + List[str]: A list of URLs from the search results, capped at `num_results`. Returns an empty list if the query is empty or no results are found. Notes: @@ -87,11 +86,6 @@ class BingSearchEngine(WebSearchEngine): tuple: A tuple containing: - list: A list of dictionaries with keys 'title', 'abstract', 'url', and 'rank' for each result. - str or None: The URL of the next results page, or None if there is no next page. - Example: - This function is called by `execute` in the following way: - ```python - results, next_url = self._parse_html(url, rank_start=0) - ``` """ try: res = self.session.get(url=url) @@ -135,19 +129,6 @@ class BingSearchEngine(WebSearchEngine): logger.warning(f"Error parsing HTML: {e}") return [], None - async def execute(self, query: str, num_results: int = 10) -> List[str]: - """ - Execute a Bing search and return a list of URLs asynchronously. - - Args: - query (str): The search query to submit to Bing. - num_results (int, optional): The number of search results to return. Default is 10. - - Returns: - List[str]: A list of URLs matching the search query. - """ - loop = asyncio.get_event_loop() - links = await loop.run_in_executor( - None, lambda: self._search_sync(query, num_results=num_results) - ) - return links + def perform_search(self, query, num_results=10, *args, **kwargs): + """Bing search engine.""" + return self._search_sync(query, num_results=num_results) \ No newline at end of file From 0654d36e4050b09fad031c5c7c53e98faf71adf7 Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Tue, 18 Mar 2025 17:23:09 +0800 Subject: [PATCH 06/26] ci: update Markdown issue templates to forms --- .../ISSUE_TEMPLATE/request_new_features.md | 14 ------ .../ISSUE_TEMPLATE/request_new_features.yaml | 21 +++++++++ .github/ISSUE_TEMPLATE/show_me_the_bug.md | 25 ----------- .github/ISSUE_TEMPLATE/show_me_the_bug.yaml | 44 +++++++++++++++++++ 4 files changed, 65 insertions(+), 39 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/request_new_features.md create mode 100644 .github/ISSUE_TEMPLATE/request_new_features.yaml delete mode 100644 .github/ISSUE_TEMPLATE/show_me_the_bug.md create mode 100644 .github/ISSUE_TEMPLATE/show_me_the_bug.yaml diff --git a/.github/ISSUE_TEMPLATE/request_new_features.md b/.github/ISSUE_TEMPLATE/request_new_features.md deleted file mode 100644 index c191adb..0000000 --- a/.github/ISSUE_TEMPLATE/request_new_features.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: "🤔 Request new features" -about: Suggest ideas or features you’d like to see implemented in OpenManus. -title: '' -labels: kind/features -assignees: '' ---- - -**Feature description** - - -**Your Feature** - - diff --git a/.github/ISSUE_TEMPLATE/request_new_features.yaml b/.github/ISSUE_TEMPLATE/request_new_features.yaml new file mode 100644 index 0000000..749ab7f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/request_new_features.yaml @@ -0,0 +1,21 @@ +name: "🤔 Request new features" +description: Suggest ideas or features you’d like to see implemented in OpenManus. +labels: enhancement +body: + - type: textarea + id: feature-description + attributes: + label: Feature description + description: | + Provide a clear and concise description of the proposed feature + validations: + required: true + - type: textarea + id: your-feature + attributes: + label: Your Feature + description: | + Explain your idea or implementation process, if any. Optionally, include a Pull Request URL. + Ensure accompanying docs/tests/examples are provided for review. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/show_me_the_bug.md b/.github/ISSUE_TEMPLATE/show_me_the_bug.md deleted file mode 100644 index a3d8700..0000000 --- a/.github/ISSUE_TEMPLATE/show_me_the_bug.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: "🪲 Show me the Bug" -about: Report a bug encountered while using OpenManus and seek assistance. -title: '' -labels: kind/bug -assignees: '' ---- - -**Bug description** - - -**Bug solved method** - - - -**Environment information** - - -- System version: -- Python version: -- OpenManus version or branch: -- Installation method (e.g., `pip install -r requirements.txt` or `pip install -e .`): - -**Screenshots or logs** - diff --git a/.github/ISSUE_TEMPLATE/show_me_the_bug.yaml b/.github/ISSUE_TEMPLATE/show_me_the_bug.yaml new file mode 100644 index 0000000..de9298e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/show_me_the_bug.yaml @@ -0,0 +1,44 @@ +name: "🪲 Show me the Bug" +description: Report a bug encountered while using OpenManus and seek assistance. +labels: bug +body: + - type: textarea + id: bug-description + attributes: + label: Bug Description + description: | + Clearly describe the bug you encountered + validations: + required: true + - type: textarea + id: solve-method + attributes: + label: Bug solved method + description: | + If resolved, explain the solution. Optionally, include a Pull Request URL. + If unresolved, provide additional details to aid investigation + validations: + required: true + - type: textarea + id: environment-information + attributes: + label: Environment information + description: | + System: e.g., Ubuntu 22.04 + Python: e.g., 3.12 + OpenManus version: e.g., 0.1.0 + value: | + - System version: + - Python version: + - OpenManus version or branch: + - Installation method (e.g., `pip install -r requirements.txt` or `pip install -e .`): + validations: + required: true + - type: textarea + id: extra-information + attributes: + label: Extra information + description: | + For example, attach screenshots or logs to help diagnose the issue + validations: + required: false From 2fad2904d7e8f922464e1df6d25f119bc7b778b1 Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Tue, 18 Mar 2025 21:10:43 +0800 Subject: [PATCH 07/26] ci(pr-autodiff): add Chinese explicit shot --- .github/workflows/pr-autodiff.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/pr-autodiff.yaml b/.github/workflows/pr-autodiff.yaml index ed218dc..85c79d0 100644 --- a/.github/workflows/pr-autodiff.yaml +++ b/.github/workflows/pr-autodiff.yaml @@ -86,6 +86,17 @@ jobs: ### Spelling/Offensive Content Check - No spelling mistakes or offensive content found in the code or comments. + + ## 中文(简体) + - 新增了 `ABC` 类 + - `foo` 模块中的 `f()` 行为已修复 + + ### 评论高亮 + - `config.toml` 需要正确配置才能确保新功能正常运行。 + + ### 内容检查 + - 没有发现代码或注释中的拼写错误或不当措辞。 + 3. Highlight non-English comments 4. Check for spelling/offensive content''' From 47adb33bd9352450cb5c070f62c26832d8862207 Mon Sep 17 00:00:00 2001 From: via Date: Wed, 19 Mar 2025 09:13:47 +0800 Subject: [PATCH 08/26] Apply black and isort formatting --- app/tool/search/__init__.py | 3 +-- app/tool/search/bing_search.py | 49 +++++++++++++++++++++------------- app/tool/web_search.py | 8 +++--- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/app/tool/search/__init__.py b/app/tool/search/__init__.py index 8b6cfdb..70ea3d7 100644 --- a/app/tool/search/__init__.py +++ b/app/tool/search/__init__.py @@ -1,9 +1,8 @@ from app.tool.search.baidu_search import BaiduSearchEngine from app.tool.search.base import WebSearchEngine +from app.tool.search.bing_search import BingSearchEngine from app.tool.search.duckduckgo_search import DuckDuckGoSearchEngine from app.tool.search.google_search import GoogleSearchEngine -from app.tool.search.bing_search import BingSearchEngine - __all__ = [ "WebSearchEngine", diff --git a/app/tool/search/bing_search.py b/app/tool/search/bing_search.py index c5eaa86..701841b 100644 --- a/app/tool/search/bing_search.py +++ b/app/tool/search/bing_search.py @@ -1,22 +1,24 @@ from typing import List + import requests -from app.logger import logger from bs4 import BeautifulSoup + +from app.logger import logger from app.tool.search.base import WebSearchEngine ABSTRACT_MAX_LENGTH = 300 USER_AGENTS = [ - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36', - 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/49.0.2623.108 Chrome/49.0.2623.108 Safari/537.36', - 'Mozilla/5.0 (Windows; U; Windows NT 5.1; pt-BR) AppleWebKit/533.3 (KHTML, like Gecko) QtWeb Internet Browser/3.7 http://www.QtWeb.net', - 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36', - 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/532.2 (KHTML, like Gecko) ChromePlus/4.0.222.3 Chrome/4.0.222.3 Safari/532.2', - 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4pre) Gecko/20070404 K-Ninja/2.1.3', - 'Mozilla/5.0 (Future Star Technologies Corp.; Star-Blade OS; x86_64; U; en-US) iNet Browser 4.7', - 'Mozilla/5.0 (Windows; U; Windows NT 6.1; rv:2.2) Gecko/20110201', - 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.13) Gecko/20080414 Firefox/2.0.0.13 Pogo/2.0.0.13.6866' + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36", + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/49.0.2623.108 Chrome/49.0.2623.108 Safari/537.36", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; pt-BR) AppleWebKit/533.3 (KHTML, like Gecko) QtWeb Internet Browser/3.7 http://www.QtWeb.net", + "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/532.2 (KHTML, like Gecko) ChromePlus/4.0.222.3 Chrome/4.0.222.3 Safari/532.2", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4pre) Gecko/20070404 K-Ninja/2.1.3", + "Mozilla/5.0 (Future Star Technologies Corp.; Star-Blade OS; x86_64; U; en-US) iNet Browser 4.7", + "Mozilla/5.0 (Windows; U; Windows NT 6.1; rv:2.2) Gecko/20110201", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.13) Gecko/20080414 Firefox/2.0.0.13 Pogo/2.0.0.13.6866", ] HEADERS = { @@ -25,7 +27,7 @@ HEADERS = { "User-Agent": USER_AGENTS[0], "Referer": "https://www.bing.com/", "Accept-Encoding": "gzip, deflate", - "Accept-Language": "zh-CN,zh;q=0.9" + "Accept-Language": "zh-CN,zh;q=0.9", } BING_HOST_URL = "https://www.bing.com" @@ -65,7 +67,9 @@ class BingSearchEngine(WebSearchEngine): next_url = BING_SEARCH_URL + query while len(list_result) < num_results: - data, next_url = self._parse_html(next_url, rank_start=len(list_result), first=first) + data, next_url = self._parse_html( + next_url, rank_start=len(list_result), first=first + ) if data: list_result.extend([item["url"] for item in data]) if not next_url: @@ -98,14 +102,14 @@ class BingSearchEngine(WebSearchEngine): return [], None for li in ol_results.find_all("li", class_="b_algo"): - title = '' - url = '' - abstract = '' + title = "" + url = "" + abstract = "" try: h2 = li.find("h2") if h2: title = h2.text.strip() - url = h2.a['href'].strip() + url = h2.a["href"].strip() p = li.find("p") if p: @@ -115,7 +119,14 @@ class BingSearchEngine(WebSearchEngine): abstract = abstract[:ABSTRACT_MAX_LENGTH] rank_start += 1 - list_data.append({"title": title, "abstract": abstract, "url": url, "rank": rank_start}) + list_data.append( + { + "title": title, + "abstract": abstract, + "url": url, + "rank": rank_start, + } + ) except Exception: continue @@ -131,4 +142,4 @@ class BingSearchEngine(WebSearchEngine): def perform_search(self, query, num_results=10, *args, **kwargs): """Bing search engine.""" - return self._search_sync(query, num_results=num_results) \ No newline at end of file + return self._search_sync(query, num_results=num_results) diff --git a/app/tool/web_search.py b/app/tool/web_search.py index 99d8916..3647883 100644 --- a/app/tool/web_search.py +++ b/app/tool/web_search.py @@ -6,11 +6,11 @@ from tenacity import retry, stop_after_attempt, wait_exponential from app.config import config from app.tool.base import BaseTool from app.tool.search import ( - BaiduSearchEngine, + BaiduSearchEngine, + BingSearchEngine, DuckDuckGoSearchEngine, GoogleSearchEngine, - WebSearchEngine, - BingSearchEngine + WebSearchEngine ) @@ -38,7 +38,7 @@ class WebSearch(BaseTool): "google": GoogleSearchEngine(), "baidu": BaiduSearchEngine(), "duckduckgo": DuckDuckGoSearchEngine(), - "bing": BingSearchEngine() + "bing": BingSearchEngine(), } async def execute(self, query: str, num_results: int = 10) -> List[str]: From b9fdade6e42afacdc7622416d8d1856c43336e2d Mon Sep 17 00:00:00 2001 From: via Date: Wed, 19 Mar 2025 09:15:51 +0800 Subject: [PATCH 09/26] Apply black and isort formatting --- app/tool/web_search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/tool/web_search.py b/app/tool/web_search.py index 3647883..8b80e46 100644 --- a/app/tool/web_search.py +++ b/app/tool/web_search.py @@ -6,7 +6,7 @@ from tenacity import retry, stop_after_attempt, wait_exponential from app.config import config from app.tool.base import BaseTool from app.tool.search import ( - BaiduSearchEngine, + BaiduSearchEngine, BingSearchEngine, DuckDuckGoSearchEngine, GoogleSearchEngine, From 1279d77ccae1dc1c38c38ec7f9d01d84230c6bf8 Mon Sep 17 00:00:00 2001 From: minbang930 Date: Wed, 19 Mar 2025 10:52:58 +0900 Subject: [PATCH 10/26] update api_type field description to include Ollama Clarify the description of the api_type field in LLMSettings to accurately reflect all supported types including Azure, OpenAI, and Ollama. This makes the documentation consistent with the example configuration. --- app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config.py b/app/config.py index 0be771b..011ceb1 100644 --- a/app/config.py +++ b/app/config.py @@ -25,7 +25,7 @@ class LLMSettings(BaseModel): description="Maximum input tokens to use across all requests (None for unlimited)", ) temperature: float = Field(1.0, description="Sampling temperature") - api_type: str = Field(..., description="AzureOpenai or Openai") + api_type: str = Field(..., description="Azure, Openai, or Ollama") api_version: str = Field(..., description="Azure Openai version if AzureOpenai") From 7b38dd7fbc3a6197e88e0fa98a16efe30125bf36 Mon Sep 17 00:00:00 2001 From: liangxinbing <1580466765@qq.com> Date: Wed, 19 Mar 2025 13:18:45 +0800 Subject: [PATCH 11/26] update format_messages --- app/llm.py | 145 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 88 insertions(+), 57 deletions(-) diff --git a/app/llm.py b/app/llm.py index 37de566..a67a86f 100644 --- a/app/llm.py +++ b/app/llm.py @@ -30,6 +30,14 @@ from app.schema import ( REASONING_MODELS = ["o1", "o3-mini"] +MULTIMODAL_MODELS = [ + "gpt-4-vision-preview", + "gpt-4o", + "gpt-4o-mini", + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-3-haiku-20240307", +] class TokenCounter: @@ -259,12 +267,15 @@ class LLM: return "Token limit exceeded" @staticmethod - def format_messages(messages: List[Union[dict, Message]]) -> List[dict]: + def format_messages( + messages: List[Union[dict, Message]], supports_images: bool = False + ) -> List[dict]: """ Format messages for LLM by converting them to OpenAI message format. Args: messages: List of messages that can be either dict or Message objects + supports_images: Flag indicating if the target model supports image inputs Returns: List[dict]: List of formatted messages in OpenAI format @@ -288,54 +299,58 @@ class LLM: if isinstance(message, Message): message = message.to_dict() - if not isinstance(message, dict): + if isinstance(message, dict): + # If message is a dict, ensure it has required fields + if "role" not in message: + raise ValueError("Message dict must contain 'role' field") + + # Process base64 images if present and model supports images + if supports_images and message.get("base64_image"): + # Initialize or convert content to appropriate format + if not message.get("content"): + message["content"] = [] + elif isinstance(message["content"], str): + message["content"] = [ + {"type": "text", "text": message["content"]} + ] + elif isinstance(message["content"], list): + # Convert string items to proper text objects + message["content"] = [ + ( + {"type": "text", "text": item} + if isinstance(item, str) + else item + ) + for item in message["content"] + ] + + # Add the image to content + message["content"].append( + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{message['base64_image']}" + }, + } + ) + + # Remove the base64_image field + del message["base64_image"] + # If model doesn't support images but message has base64_image, handle gracefully + elif not supports_images and message.get("base64_image"): + # Just remove the base64_image field and keep the text content + del message["base64_image"] + + if "content" in message or "tool_calls" in message: + formatted_messages.append(message) + # else: do not include the message + else: raise TypeError(f"Unsupported message type: {type(message)}") - # Validate required fields - if "role" not in message: - raise ValueError("Message dict must contain 'role' field") - - # Process base64 images if present - if message.get("base64_image"): - # Initialize or convert content to appropriate format - if not message.get("content"): - message["content"] = [] - elif isinstance(message["content"], str): - message["content"] = [{"type": "text", "text": message["content"]}] - elif isinstance(message["content"], list): - # Convert string items to proper text objects - message["content"] = [ - ( - {"type": "text", "text": item} - if isinstance(item, str) - else item - ) - for item in message["content"] - ] - - # Add the image to content - message["content"].append( - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{message['base64_image']}" - }, - } - ) - - # Remove the base64_image field - del message["base64_image"] - - # Only include messages with content or tool_calls - if "content" in message or "tool_calls" in message: - formatted_messages.append(message) - - # Validate all roles - invalid_roles = [ - msg for msg in formatted_messages if msg["role"] not in ROLE_VALUES - ] - if invalid_roles: - raise ValueError(f"Invalid role: {invalid_roles[0]['role']}") + # Validate all messages have required fields + for msg in formatted_messages: + if msg["role"] not in ROLE_VALUES: + raise ValueError(f"Invalid role: {msg['role']}") return formatted_messages @@ -372,12 +387,15 @@ class LLM: Exception: For unexpected errors """ try: - # Format system and user messages + # Check if the model supports images + supports_images = self.model in MULTIMODAL_MODELS + + # Format system and user messages with image support check if system_msgs: - system_msgs = self.format_messages(system_msgs) - messages = system_msgs + self.format_messages(messages) + system_msgs = self.format_messages(system_msgs, supports_images) + messages = system_msgs + self.format_messages(messages, supports_images) else: - messages = self.format_messages(messages) + messages = self.format_messages(messages, supports_images) # Calculate input token count input_tokens = self.count_message_tokens(messages) @@ -499,8 +517,15 @@ class LLM: Exception: For unexpected errors """ try: - # Format messages - formatted_messages = self.format_messages(messages) + # For ask_with_images, we always set supports_images to True because + # this method should only be called with models that support images + if self.model not in MULTIMODAL_MODELS: + raise ValueError( + f"Model {self.model} does not support images. Use a model from {MULTIMODAL_MODELS}" + ) + + # Format messages with image support + formatted_messages = self.format_messages(messages, supports_images=True) # Ensure the last message is from the user to attach images if not formatted_messages or formatted_messages[-1]["role"] != "user": @@ -539,7 +564,10 @@ class LLM: # Add system messages if provided if system_msgs: - all_messages = self.format_messages(system_msgs) + formatted_messages + all_messages = ( + self.format_messages(system_msgs, supports_images=True) + + formatted_messages + ) else: all_messages = formatted_messages @@ -653,12 +681,15 @@ class LLM: if tool_choice not in TOOL_CHOICE_VALUES: raise ValueError(f"Invalid tool_choice: {tool_choice}") + # Check if the model supports images + supports_images = self.model in MULTIMODAL_MODELS + # Format messages if system_msgs: - system_msgs = self.format_messages(system_msgs) - messages = system_msgs + self.format_messages(messages) + system_msgs = self.format_messages(system_msgs, supports_images) + messages = system_msgs + self.format_messages(messages, supports_images) else: - messages = self.format_messages(messages) + messages = self.format_messages(messages, supports_images) # Calculate input token count input_tokens = self.count_message_tokens(messages) From 402355533c33c13f202a27e83984b53e4360633f Mon Sep 17 00:00:00 2001 From: liangxinbing <1580466765@qq.com> Date: Wed, 19 Mar 2025 13:24:49 +0800 Subject: [PATCH 12/26] format code --- app/tool/search/__init__.py | 1 + app/tool/search/bing_search.py | 1 + app/tool/web_search.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/tool/search/__init__.py b/app/tool/search/__init__.py index 70ea3d7..fe127ae 100644 --- a/app/tool/search/__init__.py +++ b/app/tool/search/__init__.py @@ -4,6 +4,7 @@ from app.tool.search.bing_search import BingSearchEngine from app.tool.search.duckduckgo_search import DuckDuckGoSearchEngine from app.tool.search.google_search import GoogleSearchEngine + __all__ = [ "WebSearchEngine", "BaiduSearchEngine", diff --git a/app/tool/search/bing_search.py b/app/tool/search/bing_search.py index 701841b..46955b5 100644 --- a/app/tool/search/bing_search.py +++ b/app/tool/search/bing_search.py @@ -6,6 +6,7 @@ from bs4 import BeautifulSoup from app.logger import logger from app.tool.search.base import WebSearchEngine + ABSTRACT_MAX_LENGTH = 300 USER_AGENTS = [ diff --git a/app/tool/web_search.py b/app/tool/web_search.py index 8b80e46..cb13934 100644 --- a/app/tool/web_search.py +++ b/app/tool/web_search.py @@ -10,7 +10,7 @@ from app.tool.search import ( BingSearchEngine, DuckDuckGoSearchEngine, GoogleSearchEngine, - WebSearchEngine + WebSearchEngine, ) From b7dcbfecb3f38fb7aa87453acde557209f35d653 Mon Sep 17 00:00:00 2001 From: liangxinbing <1580466765@qq.com> Date: Wed, 19 Mar 2025 13:27:08 +0800 Subject: [PATCH 13/26] update extract_content action for BrowserUseTool --- app/tool/browser_use_tool.py | 62 ++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/app/tool/browser_use_tool.py b/app/tool/browser_use_tool.py index 7817aef..7fe8f16 100644 --- a/app/tool/browser_use_tool.py +++ b/app/tool/browser_use_tool.py @@ -418,17 +418,7 @@ class BrowserUseTool(BaseTool, Generic[Context]): # Create prompt for LLM prompt_text = """ -Your task is to extract the content of the page. You will be given a page and a goal, and you should extract all relevant information around this goal from the page. - -Examples of extraction goals: -- Extract all company names -- Extract specific descriptions -- Extract all information about a topic -- Extract links with companies in structured format -- Extract all links - -If the goal is vague, summarize the page. Respond in JSON format. - +Your task is to extract the content of the page. You will be given a page and a goal, and you should extract all relevant information around this goal from the page. If the goal is vague, summarize the page. Respond in json format. Extraction goal: {goal} Page content: @@ -445,10 +435,54 @@ Page content: messages = [Message.user_message(formatted_prompt)] - # Use LLM to extract content based on the goal - response = await self.llm.ask(messages) + # Define extraction function for the tool + extraction_function = { + "type": "function", + "function": { + "name": "extract_content", + "description": "Extract specific information from a webpage based on a goal", + "parameters": { + "type": "object", + "properties": { + "extracted_content": { + "type": "object", + "description": "The content extracted from the page according to the goal", + } + }, + "required": ["extracted_content"], + }, + }, + } + + # Use LLM to extract content with required function calling + response = await self.llm.ask_tool( + messages, + tools=[extraction_function], + tool_choice="required", + ) + + # Extract content from function call response + if ( + response + and response.tool_calls + and len(response.tool_calls) > 0 + ): + # Get the first tool call arguments + tool_call = response.tool_calls[0] + # Parse the JSON arguments + try: + args = json.loads(tool_call.function.arguments) + extracted_content = args.get("extracted_content", {}) + # Format extracted content as JSON string + content_json = json.dumps( + extracted_content, indent=2, ensure_ascii=False + ) + msg = f"Extracted from page:\n{content_json}\n" + except Exception as e: + msg = f"Error parsing extraction result: {str(e)}\nRaw response: {tool_call.function.arguments}" + else: + msg = "No content was extracted from the page." - msg = f"Extracted from page:\n{response}\n" return ToolResult(output=msg) except Exception as e: # Provide a more helpful error message From d7b3f9a5c3e87f31ff61fe8baa9939b5038c1a7b Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Wed, 19 Mar 2025 13:32:31 +0800 Subject: [PATCH 14/26] fix(pr-autodiff): make sure compare does correctly --- .github/workflows/pr-autodiff.yaml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr-autodiff.yaml b/.github/workflows/pr-autodiff.yaml index 85c79d0..46c95c6 100644 --- a/.github/workflows/pr-autodiff.yaml +++ b/.github/workflows/pr-autodiff.yaml @@ -15,21 +15,20 @@ jobs: (github.event_name == 'pull_request') || (github.event_name == 'issue_comment' && contains(github.event.comment.body, '!pr-diff') && - (github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') && + (github.event.comment.author_association == 'CONTRIBUTOR' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') && github.event.issue.pull_request) steps: - name: Get PR head SHA id: get-pr-sha run: | - if [ "${{ github.event_name }}" == "pull_request" ]; then - echo "pr_sha=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT - echo "Retrieved PR head SHA: ${{ github.event.pull_request.head.sha }}" - else - PR_URL="${{ github.event.issue.pull_request.url }}" - SHA=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" $PR_URL | jq -r '.head.sha') - echo "pr_sha=$SHA" >> $GITHUB_OUTPUT - echo "Retrieved PR head SHA from API: $SHA" - fi + PR_URL="${{ github.event.issue.pull_request.url || github.event.pull_request.url }}" + # https://api.github.com/repos/OpenManus/pulls/1 + RESPONSE=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" $PR_URL) + SHA=$(echo $RESPONSE | jq -r '.head.sha') + TARGET_BRANCH=$(echo $RESPONSE | jq -r '.base.ref') + echo "pr_sha=$SHA" >> $GITHUB_OUTPUT + echo "target_branch=$TARGET_BRANCH" >> $GITHUB_OUTPUT + echo "Retrieved PR head SHA from API: $SHA, target branch: $TARGET_BRANCH" - name: Check out code uses: actions/checkout@v4 with: @@ -49,6 +48,7 @@ jobs: OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + TARGET_BRANCH: ${{ steps.get-pr-sha.outputs.target_branch }} run: |- cat << 'EOF' > /tmp/_workflow_core.py import os @@ -59,7 +59,7 @@ jobs: def get_diff(): result = subprocess.run( - ['git', 'diff', 'origin/main...HEAD'], + ['git', 'diff', 'origin/' + os.getenv('TARGET_BRANCH') + '...HEAD'], capture_output=True, text=True, check=True) return '\n'.join( line for line in result.stdout.split('\n') From 94e2ab7c86607505807def45ddd3c4709ac85969 Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Wed, 19 Mar 2025 14:09:46 +0800 Subject: [PATCH 15/26] fix(llm): accept empty choices as valid response and handle that case gracefully --- app/agent/toolcall.py | 37 +++++++++++++++++++------------------ app/llm.py | 10 +++++++--- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/app/agent/toolcall.py b/app/agent/toolcall.py index 131fd91..b9fab70 100644 --- a/app/agent/toolcall.py +++ b/app/agent/toolcall.py @@ -10,7 +10,6 @@ from app.prompt.toolcall import NEXT_STEP_PROMPT, SYSTEM_PROMPT from app.schema import TOOL_CHOICE_TYPE, AgentState, Message, ToolCall, ToolChoice from app.tool import CreateChatCompletion, Terminate, ToolCollection - TOOL_CALL_REQUIRED = "Tool calls required but none provided" @@ -71,40 +70,42 @@ class ToolCallAgent(ReActAgent): return False raise - self.tool_calls = response.tool_calls + self.tool_calls = tool_calls = ( + response.tool_calls if response and response.tool_calls else [] + ) + content = response.content if response and response.content else "" # Log response info - logger.info(f"✨ {self.name}'s thoughts: {response.content}") + logger.info(f"✨ {self.name}'s thoughts: {content}") logger.info( - f"🛠️ {self.name} selected {len(response.tool_calls) if response.tool_calls else 0} tools to use" + f"🛠️ {self.name} selected {len(tool_calls) if tool_calls else 0} tools to use" ) - if response.tool_calls: + if tool_calls: logger.info( - f"🧰 Tools being prepared: {[call.function.name for call in response.tool_calls]}" - ) - logger.info( - f"🔧 Tool arguments: {response.tool_calls[0].function.arguments}" + f"🧰 Tools being prepared: {[call.function.name for call in tool_calls]}" ) + logger.info(f"🔧 Tool arguments: {tool_calls[0].function.arguments}") try: + if response is None: + raise RuntimeError("No response received from the LLM") + # Handle different tool_choices modes if self.tool_choices == ToolChoice.NONE: - if response.tool_calls: + if tool_calls: logger.warning( f"🤔 Hmm, {self.name} tried to use tools when they weren't available!" ) - if response.content: - self.memory.add_message(Message.assistant_message(response.content)) + if content: + self.memory.add_message(Message.assistant_message(content)) return True return False # Create and add assistant message assistant_msg = ( - Message.from_tool_calls( - content=response.content, tool_calls=self.tool_calls - ) + Message.from_tool_calls(content=content, tool_calls=self.tool_calls) if self.tool_calls - else Message.assistant_message(response.content) + else Message.assistant_message(content) ) self.memory.add_message(assistant_msg) @@ -113,7 +114,7 @@ class ToolCallAgent(ReActAgent): # For 'auto' mode, continue with content if no commands but content exists if self.tool_choices == ToolChoice.AUTO and not self.tool_calls: - return bool(response.content) + return bool(content) return bool(self.tool_calls) except Exception as e: @@ -209,7 +210,7 @@ class ToolCallAgent(ReActAgent): return f"Error: {error_msg}" except Exception as e: error_msg = f"⚠️ Tool '{name}' encountered a problem: {str(e)}" - logger.error(error_msg) + logger.exception(error_msg) return f"Error: {error_msg}" async def _handle_special_tool(self, name: str, result: Any, **kwargs): diff --git a/app/llm.py b/app/llm.py index f99b6b1..1a4e05b 100644 --- a/app/llm.py +++ b/app/llm.py @@ -10,6 +10,7 @@ from openai import ( OpenAIError, RateLimitError, ) +from openai.types.chat.chat_completion_message import ChatCompletionMessage from tenacity import ( retry, retry_if_exception_type, @@ -653,7 +654,7 @@ class LLM: tool_choice: TOOL_CHOICE_TYPE = ToolChoice.AUTO, # type: ignore temperature: Optional[float] = None, **kwargs, - ): + ) -> ChatCompletionMessage | None: """ Ask LLM using functions/tools and return the response. @@ -731,12 +732,15 @@ class LLM: temperature if temperature is not None else self.temperature ) - response = await self.client.chat.completions.create(**params) + response: ChatCompletion = await self.client.chat.completions.create( + **params, stream=False + ) # Check if response is valid if not response.choices or not response.choices[0].message: print(response) - raise ValueError("Invalid or empty response from LLM") + # raise ValueError("Invalid or empty response from LLM") + return None # Update token counts self.update_token_count( From 7e3609f19fb29412d57e2556af77ebc2f968e02d Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Wed, 19 Mar 2025 14:10:07 +0800 Subject: [PATCH 16/26] chore(file_operators): use utf-8 as default encoding --- app/tool/file_operators.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/tool/file_operators.py b/app/tool/file_operators.py index 61f8b16..5720172 100644 --- a/app/tool/file_operators.py +++ b/app/tool/file_operators.py @@ -42,17 +42,20 @@ class FileOperator(Protocol): class LocalFileOperator(FileOperator): """File operations implementation for local filesystem.""" + def __init__(self, encoding: str = "utf-8"): + self.encoding = encoding + async def read_file(self, path: PathLike) -> str: """Read content from a local file.""" try: - return Path(path).read_text() + return Path(path).read_text(encoding=self.encoding) except Exception as e: raise ToolError(f"Failed to read {path}: {str(e)}") from None async def write_file(self, path: PathLike, content: str) -> None: """Write content to a local file.""" try: - Path(path).write_text(content) + Path(path).write_text(content, encoding=self.encoding) except Exception as e: raise ToolError(f"Failed to write to {path}: {str(e)}") from None From d644d976b0bf0d791b96f4fe883b9544457a0836 Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Wed, 19 Mar 2025 14:12:46 +0800 Subject: [PATCH 17/26] fix: pre-commit --- app/agent/toolcall.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/agent/toolcall.py b/app/agent/toolcall.py index b9fab70..76f6f01 100644 --- a/app/agent/toolcall.py +++ b/app/agent/toolcall.py @@ -10,6 +10,7 @@ from app.prompt.toolcall import NEXT_STEP_PROMPT, SYSTEM_PROMPT from app.schema import TOOL_CHOICE_TYPE, AgentState, Message, ToolCall, ToolChoice from app.tool import CreateChatCompletion, Terminate, ToolCollection + TOOL_CALL_REQUIRED = "Tool calls required but none provided" From b9df45bc68e8cb995b47ba9d40d75e3d47db2f8c Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Wed, 19 Mar 2025 15:44:06 +0800 Subject: [PATCH 18/26] chore(app/tool): remove constructor in LocalFileOperator --- app/tool/file_operators.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/tool/file_operators.py b/app/tool/file_operators.py index 5720172..dd64c83 100644 --- a/app/tool/file_operators.py +++ b/app/tool/file_operators.py @@ -42,8 +42,7 @@ class FileOperator(Protocol): class LocalFileOperator(FileOperator): """File operations implementation for local filesystem.""" - def __init__(self, encoding: str = "utf-8"): - self.encoding = encoding + encoding: str = "utf-8" async def read_file(self, path: PathLike) -> str: """Read content from a local file.""" From 44243a1b979ed5ee11a4d88085aea439494b611e Mon Sep 17 00:00:00 2001 From: Sheng Fan Date: Wed, 19 Mar 2025 16:57:12 +0800 Subject: [PATCH 19/26] fix(browser_use_tool): reimplement screenshot logics to get JPEG data --- app/tool/browser_use_tool.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/tool/browser_use_tool.py b/app/tool/browser_use_tool.py index 7fe8f16..0158e07 100644 --- a/app/tool/browser_use_tool.py +++ b/app/tool/browser_use_tool.py @@ -1,4 +1,5 @@ import asyncio +import base64 import json from typing import Generic, Optional, TypeVar @@ -552,7 +553,16 @@ Page content: viewport_height = ctx.config.browser_window_size.get("height", 0) # Take a screenshot for the state - screenshot = await ctx.take_screenshot(full_page=True) + page = await ctx.get_current_page() + + await page.bring_to_front() + await page.wait_for_load_state() + + screenshot = await page.screenshot( + full_page=True, animations="disabled", type="jpeg", quality=100 + ) + + screenshot = base64.b64encode(screenshot).decode("utf-8") # Build the state info with all required fields state_info = { From 14fa48e8d7eef2baca40984ef339204d5d792840 Mon Sep 17 00:00:00 2001 From: liangxinbing <1580466765@qq.com> Date: Thu, 20 Mar 2025 00:39:48 +0800 Subject: [PATCH 20/26] add mcp --- app/agent/__init__.py | 2 + app/agent/mcp.py | 185 ++++++++++++++++++++++++++++++++++ app/config.py | 5 + app/mcp/__init__.py | 0 app/mcp/server.py | 196 ++++++++++++++++++++++++++++++++++++ app/tool/mcp.py | 114 +++++++++++++++++++++ app/tool/tool_collection.py | 3 + run_mcp.py | 107 ++++++++++++++++++++ 8 files changed, 612 insertions(+) create mode 100644 app/agent/mcp.py create mode 100644 app/mcp/__init__.py create mode 100644 app/mcp/server.py create mode 100644 app/tool/mcp.py create mode 100644 run_mcp.py diff --git a/app/agent/__init__.py b/app/agent/__init__.py index 4b8fb9d..082d91d 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -1,5 +1,6 @@ from app.agent.base import BaseAgent from app.agent.browser import BrowserAgent +from app.agent.mcp import MCPAgent from app.agent.planning import PlanningAgent from app.agent.react import ReActAgent from app.agent.swe import SWEAgent @@ -13,4 +14,5 @@ __all__ = [ "ReActAgent", "SWEAgent", "ToolCallAgent", + "MCPAgent", ] diff --git a/app/agent/mcp.py b/app/agent/mcp.py new file mode 100644 index 0000000..badbf0e --- /dev/null +++ b/app/agent/mcp.py @@ -0,0 +1,185 @@ +from typing import Any, Dict, List, Optional, Tuple + +from pydantic import Field + +from app.agent.toolcall import ToolCallAgent +from app.logger import logger +from app.prompt.mcp import MULTIMEDIA_RESPONSE_PROMPT, NEXT_STEP_PROMPT, SYSTEM_PROMPT +from app.schema import AgentState, Message +from app.tool.base import ToolResult +from app.tool.mcp import MCP + + +class MCPAgent(ToolCallAgent): + """Agent for interacting with MCP (Model Context Protocol) servers. + + This agent connects to an MCP server using either SSE or stdio transport + and makes the server's tools available through the agent's tool interface. + """ + + name: str = "mcp_agent" + description: str = "An agent that connects to an MCP server and uses its tools." + + system_prompt: str = SYSTEM_PROMPT + next_step_prompt: str = NEXT_STEP_PROMPT + + # Initialize MCP tool collection + mcp_tools: MCP = Field(default_factory=MCP) + available_tools: MCP = None # Will be set in initialize() + + max_steps: int = 20 + connection_type: str = "stdio" # "stdio" or "sse" + + # Track tool schemas to detect changes + tool_schemas: Dict[str, Dict[str, Any]] = Field(default_factory=dict) + _refresh_tools_interval: int = 5 # Refresh tools every N steps + + # Special tool names that should trigger termination + special_tool_names: List[str] = Field(default_factory=lambda: ["terminate"]) + + async def initialize( + self, + connection_type: Optional[str] = None, + server_url: Optional[str] = None, + command: Optional[str] = None, + args: Optional[List[str]] = None, + ) -> None: + """Initialize the MCP connection. + + Args: + connection_type: Type of connection to use ("stdio" or "sse") + server_url: URL of the MCP server (for SSE connection) + command: Command to run (for stdio connection) + args: Arguments for the command (for stdio connection) + """ + if connection_type: + self.connection_type = connection_type + + # Connect to the MCP server based on connection type + if self.connection_type == "sse": + if not server_url: + raise ValueError("Server URL is required for SSE connection") + await self.mcp_tools.connect_sse(server_url=server_url) + elif self.connection_type == "stdio": + if not command: + raise ValueError("Command is required for stdio connection") + await self.mcp_tools.connect_stdio(command=command, args=args or []) + else: + raise ValueError(f"Unsupported connection type: {self.connection_type}") + + # Set available_tools to our MCP instance + self.available_tools = self.mcp_tools + + # Store initial tool schemas + await self._refresh_tools() + + # Add system message about available tools + tool_names = list(self.mcp_tools.tool_map.keys()) + tools_info = ", ".join(tool_names) + + # Add system prompt and available tools information + self.memory.add_message( + Message.system_message( + f"{self.system_prompt}\n\nAvailable MCP tools: {tools_info}" + ) + ) + + async def _refresh_tools(self) -> Tuple[List[str], List[str]]: + """Refresh the list of available tools from the MCP server. + + Returns: + A tuple of (added_tools, removed_tools) + """ + if not self.mcp_tools.session: + return [], [] + + # Get current tool schemas directly from the server + response = await self.mcp_tools.session.list_tools() + current_tools = {tool.name: tool.inputSchema for tool in response.tools} + + # Determine added, removed, and changed tools + current_names = set(current_tools.keys()) + previous_names = set(self.tool_schemas.keys()) + + added_tools = list(current_names - previous_names) + removed_tools = list(previous_names - current_names) + + # Check for schema changes in existing tools + changed_tools = [] + for name in current_names.intersection(previous_names): + if current_tools[name] != self.tool_schemas.get(name): + changed_tools.append(name) + + # Update stored schemas + self.tool_schemas = current_tools + + # Log and notify about changes + if added_tools: + logger.info(f"Added MCP tools: {added_tools}") + self.memory.add_message( + Message.system_message(f"New tools available: {', '.join(added_tools)}") + ) + if removed_tools: + logger.info(f"Removed MCP tools: {removed_tools}") + self.memory.add_message( + Message.system_message( + f"Tools no longer available: {', '.join(removed_tools)}" + ) + ) + if changed_tools: + logger.info(f"Changed MCP tools: {changed_tools}") + + return added_tools, removed_tools + + async def think(self) -> bool: + """Process current state and decide next action.""" + # Check MCP session and tools availability + if not self.mcp_tools.session or not self.mcp_tools.tool_map: + logger.info("MCP service is no longer available, ending interaction") + self.state = AgentState.FINISHED + return False + + # Refresh tools periodically + if self.current_step % self._refresh_tools_interval == 0: + added, removed = await self._refresh_tools() + # All tools removed indicates shutdown + if not self.mcp_tools.tool_map: + logger.info("MCP service has shut down, ending interaction") + self.state = AgentState.FINISHED + return False + + # Use the parent class's think method + return await super().think() + + async def _handle_special_tool(self, name: str, result: Any, **kwargs) -> None: + """Handle special tool execution and state changes""" + # First process with parent handler + await super()._handle_special_tool(name, result, **kwargs) + + # Handle multimedia responses + if isinstance(result, ToolResult) and result.base64_image: + self.memory.add_message( + Message.system_message( + MULTIMEDIA_RESPONSE_PROMPT.format(tool_name=name) + ) + ) + + def _should_finish_execution(self, name: str, **kwargs) -> bool: + """Determine if tool execution should finish the agent""" + # Terminate if the tool name is 'terminate' + return name.lower() == "terminate" + + async def cleanup(self) -> None: + """Clean up MCP connection when done.""" + if self.mcp_tools.session: + await self.mcp_tools.disconnect() + logger.info("MCP connection closed") + + async def run(self, request: Optional[str] = None) -> str: + """Run the agent with cleanup when done.""" + try: + result = await super().run(request) + return result + finally: + # Ensure cleanup happens even if there's an error + await self.cleanup() diff --git a/app/config.py b/app/config.py index 011ceb1..9459707 100644 --- a/app/config.py +++ b/app/config.py @@ -227,5 +227,10 @@ class Config: """Get the workspace root directory""" return WORKSPACE_ROOT + @property + def root_path(self) -> Path: + """Get the root path of the application""" + return PROJECT_ROOT + config = Config() diff --git a/app/mcp/__init__.py b/app/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/mcp/server.py b/app/mcp/server.py new file mode 100644 index 0000000..028ffae --- /dev/null +++ b/app/mcp/server.py @@ -0,0 +1,196 @@ +import argparse +import asyncio +import atexit +import json +import logging +import os +import sys +from inspect import Parameter, Signature +from typing import Any, Dict, Optional + +from mcp.server.fastmcp import FastMCP + + +# Add directories to Python path (needed for proper importing) +current_dir = os.path.dirname(os.path.abspath(__file__)) +parent_dir = os.path.dirname(current_dir) +root_dir = os.path.dirname(parent_dir) +sys.path.insert(0, parent_dir) +sys.path.insert(0, current_dir) +sys.path.insert(0, root_dir) + +# Configure logging (using the same format as original) +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger("mcp-server") + +from app.tool.base import BaseTool +from app.tool.bash import Bash +from app.tool.browser_use_tool import BrowserUseTool +from app.tool.str_replace_editor import StrReplaceEditor +from app.tool.terminate import Terminate + + +class MCPServer: + """MCP Server implementation with tool registration and management.""" + + def __init__(self, name: str = "openmanus"): + self.server = FastMCP(name) + self.tools: Dict[str, BaseTool] = {} + + # Initialize standard tools + self.tools["bash"] = Bash() + self.tools["browser"] = BrowserUseTool() + self.tools["editor"] = StrReplaceEditor() + self.tools["terminate"] = Terminate() + + from app.logger import logger as app_logger + + global logger + logger = app_logger + + def register_tool(self, tool: BaseTool, method_name: Optional[str] = None) -> None: + """Register a tool with parameter validation and documentation.""" + tool_name = method_name or tool.name + tool_param = tool.to_param() + tool_function = tool_param["function"] + + # Define the async function to be registered + async def tool_method(**kwargs): + logger.info(f"Executing {tool_name}: {kwargs}") + result = await tool.execute(**kwargs) + + logger.info(f"Result of {tool_name}: {result}") + + # Handle different types of results (match original logic) + if hasattr(result, "model_dump"): + return json.dumps(result.model_dump()) + elif isinstance(result, dict): + return json.dumps(result) + return result + + # Set method metadata + tool_method.__name__ = tool_name + tool_method.__doc__ = self._build_docstring(tool_function) + tool_method.__signature__ = self._build_signature(tool_function) + + # Store parameter schema (important for tools that access it programmatically) + param_props = tool_function.get("parameters", {}).get("properties", {}) + required_params = tool_function.get("parameters", {}).get("required", []) + tool_method._parameter_schema = { + param_name: { + "description": param_details.get("description", ""), + "type": param_details.get("type", "any"), + "required": param_name in required_params, + } + for param_name, param_details in param_props.items() + } + + # Register with server + self.server.tool()(tool_method) + logger.info(f"Registered tool: {tool_name}") + + def _build_docstring(self, tool_function: dict) -> str: + """Build a formatted docstring from tool function metadata.""" + description = tool_function.get("description", "") + param_props = tool_function.get("parameters", {}).get("properties", {}) + required_params = tool_function.get("parameters", {}).get("required", []) + + # Build docstring (match original format) + docstring = description + if param_props: + docstring += "\n\nParameters:\n" + for param_name, param_details in param_props.items(): + required_str = ( + "(required)" if param_name in required_params else "(optional)" + ) + param_type = param_details.get("type", "any") + param_desc = param_details.get("description", "") + docstring += ( + f" {param_name} ({param_type}) {required_str}: {param_desc}\n" + ) + + return docstring + + def _build_signature(self, tool_function: dict) -> Signature: + """Build a function signature from tool function metadata.""" + param_props = tool_function.get("parameters", {}).get("properties", {}) + required_params = tool_function.get("parameters", {}).get("required", []) + + parameters = [] + + # Follow original type mapping + for param_name, param_details in param_props.items(): + param_type = param_details.get("type", "") + default = Parameter.empty if param_name in required_params else None + + # Map JSON Schema types to Python types (same as original) + annotation = Any + if param_type == "string": + annotation = str + elif param_type == "integer": + annotation = int + elif param_type == "number": + annotation = float + elif param_type == "boolean": + annotation = bool + elif param_type == "object": + annotation = dict + elif param_type == "array": + annotation = list + + # Create parameter with same structure as original + param = Parameter( + name=param_name, + kind=Parameter.KEYWORD_ONLY, + default=default, + annotation=annotation, + ) + parameters.append(param) + + return Signature(parameters=parameters) + + async def cleanup(self) -> None: + """Clean up server resources.""" + logger.info("Cleaning up resources") + # Follow original cleanup logic - only clean browser tool + if "browser" in self.tools and hasattr(self.tools["browser"], "cleanup"): + await self.tools["browser"].cleanup() + + def register_all_tools(self) -> None: + """Register all tools with the server.""" + for tool in self.tools.values(): + self.register_tool(tool) + + def run(self, transport: str = "stdio") -> None: + """Run the MCP server.""" + # Register all tools + self.register_all_tools() + + # Register cleanup function (match original behavior) + atexit.register(lambda: asyncio.run(self.cleanup())) + + # Start server (with same logging as original) + logger.info(f"Starting OpenManus server ({transport} mode)") + self.server.run(transport=transport) + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="OpenManus MCP Server") + parser.add_argument( + "--transport", + choices=["stdio"], + default="stdio", + help="Communication method: stdio or http (default: stdio)", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + + # Create and run server (maintaining original flow) + server = MCPServer() + server.run(transport=args.transport) diff --git a/app/tool/mcp.py b/app/tool/mcp.py new file mode 100644 index 0000000..25aedff --- /dev/null +++ b/app/tool/mcp.py @@ -0,0 +1,114 @@ +from contextlib import AsyncExitStack +from typing import Any, Dict, List, Optional + +from mcp import ClientSession, StdioServerParameters +from mcp.client.sse import sse_client +from mcp.client.stdio import stdio_client +from mcp.types import TextContent + +from app.logger import logger +from app.tool.base import BaseTool, ToolResult +from app.tool.tool_collection import ToolCollection + + +class MCPServerTool(BaseTool): + """Represents a tool available on the MCP server.""" + + schema: Dict[str, Any] = None + session: Optional[ClientSession] = None + + async def execute(self, **kwargs) -> ToolResult: + """Execute the tool on the MCP server.""" + if not self.session: + return ToolResult(error="Not connected to MCP server") + + try: + result = await self.session.call_tool(self.name, kwargs) + content_str = ", ".join( + item.text for item in result.content if isinstance(item, TextContent) + ) + return ToolResult(output=content_str or "No output returned.") + except Exception as e: + return ToolResult(error=f"Error executing tool: {str(e)}") + + +class MCP(ToolCollection): + """AN MCP tool collection that connects to an MCP server and executes commands.""" + + session: Optional[ClientSession] = None + exit_stack: AsyncExitStack = None + description: str = "MCP tools for server interaction" + + def __init__(self): + super().__init__() # Initialize with empty tools list + self.name = "mcp" # Keep name for backward compatibility + self.exit_stack = AsyncExitStack() + + async def connect_sse(self, server_url: str) -> None: + """Connect to an MCP server using SSE transport.""" + if not server_url: + raise ValueError("Server URL is required.") + if self.session: + await self.disconnect() + + streams_context = sse_client(url=server_url) + streams = await self.exit_stack.enter_async_context(streams_context) + self.session = await self.exit_stack.enter_async_context( + ClientSession(*streams) + ) + + await self._initialize_and_list_tools() + + async def connect_stdio(self, command: str, args: List[str]) -> None: + """Connect to an MCP server using stdio transport.""" + if not command: + raise ValueError("Server command is required.") + if self.session: + await self.disconnect() + + server_params = StdioServerParameters(command=command, args=args) + stdio_transport = await self.exit_stack.enter_async_context( + stdio_client(server_params) + ) + read, write = stdio_transport + self.session = await self.exit_stack.enter_async_context( + ClientSession(read, write) + ) + + await self._initialize_and_list_tools() + + async def _initialize_and_list_tools(self) -> None: + """Initialize session and populate tool map.""" + if not self.session: + raise RuntimeError("Session not initialized.") + + await self.session.initialize() + response = await self.session.list_tools() + + # Clear existing tools + self.tools = tuple() + self.tool_map = {} + + # Create proper tool objects for each server tool + for tool in response.tools: + server_tool = MCPServerTool( + name=tool.name, + description=tool.description, + schema=tool.inputSchema, + session=self.session, + ) + self.tool_map[tool.name] = server_tool + + self.tools = tuple(self.tool_map.values()) + logger.info( + f"Connected to server with tools: {[tool.name for tool in response.tools]}" + ) + + async def disconnect(self) -> None: + """Disconnect from the MCP server and clean up resources.""" + if self.session and self.exit_stack: + await self.exit_stack.aclose() + self.session = None + self.tools = tuple() + self.tool_map = {} + logger.info("Disconnected from MCP server") diff --git a/app/tool/tool_collection.py b/app/tool/tool_collection.py index c41ae91..41c4d84 100644 --- a/app/tool/tool_collection.py +++ b/app/tool/tool_collection.py @@ -8,6 +8,9 @@ from app.tool.base import BaseTool, ToolFailure, ToolResult class ToolCollection: """A collection of defined tools.""" + class Config: + arbitrary_types_allowed = True + def __init__(self, *tools: BaseTool): self.tools = tools self.tool_map = {tool.name: tool for tool in tools} diff --git a/run_mcp.py b/run_mcp.py new file mode 100644 index 0000000..9cb3671 --- /dev/null +++ b/run_mcp.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +import argparse +import asyncio +import sys + +from app.agent.mcp import MCPAgent +from app.config import config +from app.logger import logger + + +class MCPRunner: + """Runner class for MCP Agent with proper path handling and configuration.""" + + def __init__(self): + self.root_path = config.root_path + self.server_script = self.root_path / "app" / "mcp" / "server.py" + self.agent = MCPAgent() + + async def initialize(self, connection_type: str, server_url: str = None) -> None: + """Initialize the MCP agent with the appropriate connection.""" + logger.info(f"Initializing MCPAgent with {connection_type} connection...") + + if connection_type == "stdio": + await self.agent.initialize( + connection_type="stdio", + command=sys.executable, + args=[str(self.server_script)], + ) + else: # sse + await self.agent.initialize(connection_type="sse", server_url=server_url) + + logger.info(f"Connected to MCP server via {connection_type}") + + async def run_interactive(self) -> None: + """Run the agent in interactive mode.""" + print("\nMCP Agent Interactive Mode (type 'exit' to quit)\n") + while True: + user_input = input("\nEnter your request: ") + if user_input.lower() in ["exit", "quit", "q"]: + break + response = await self.agent.run(user_input) + print(f"\nAgent: {response}") + + async def run_single_prompt(self, prompt: str) -> None: + """Run the agent with a single prompt.""" + await self.agent.run(prompt) + + async def run_default(self) -> None: + """Run the agent in default mode.""" + await self.agent.run( + "Hello, what tools are available to me? Terminate after you have listed the tools." + ) + + async def cleanup(self) -> None: + """Clean up agent resources.""" + await self.agent.cleanup() + logger.info("Session ended") + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Run the MCP Agent") + parser.add_argument( + "--connection", + "-c", + choices=["stdio", "sse"], + default="stdio", + help="Connection type: stdio or sse", + ) + parser.add_argument( + "--server-url", + default="http://127.0.0.1:8000/sse", + help="URL for SSE connection", + ) + parser.add_argument( + "--interactive", "-i", action="store_true", help="Run in interactive mode" + ) + parser.add_argument("--prompt", "-p", help="Single prompt to execute and exit") + return parser.parse_args() + + +async def run_mcp() -> None: + """Main entry point for the MCP runner.""" + args = parse_args() + runner = MCPRunner() + + try: + await runner.initialize(args.connection, args.server_url) + + if args.prompt: + await runner.run_single_prompt(args.prompt) + elif args.interactive: + await runner.run_interactive() + else: + await runner.run_default() + + except KeyboardInterrupt: + logger.info("Program interrupted by user") + except Exception as e: + logger.error(f"Error running MCPAgent: {str(e)}", exc_info=True) + sys.exit(1) + finally: + await runner.cleanup() + + +if __name__ == "__main__": + asyncio.run(run_mcp()) From 74f438bde38a47da884ba5de2d20960aa9a57990 Mon Sep 17 00:00:00 2001 From: liangxinbing <1580466765@qq.com> Date: Thu, 20 Mar 2025 02:03:20 +0800 Subject: [PATCH 21/26] update mcp name --- app/agent/mcp.py | 8 ++++---- app/tool/mcp.py | 21 +++++++++++---------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/app/agent/mcp.py b/app/agent/mcp.py index badbf0e..ca34da4 100644 --- a/app/agent/mcp.py +++ b/app/agent/mcp.py @@ -7,7 +7,7 @@ from app.logger import logger from app.prompt.mcp import MULTIMEDIA_RESPONSE_PROMPT, NEXT_STEP_PROMPT, SYSTEM_PROMPT from app.schema import AgentState, Message from app.tool.base import ToolResult -from app.tool.mcp import MCP +from app.tool.mcp import MCPClients class MCPAgent(ToolCallAgent): @@ -24,8 +24,8 @@ class MCPAgent(ToolCallAgent): next_step_prompt: str = NEXT_STEP_PROMPT # Initialize MCP tool collection - mcp_tools: MCP = Field(default_factory=MCP) - available_tools: MCP = None # Will be set in initialize() + mcp_tools: MCPClients = Field(default_factory=MCPClients) + available_tools: MCPClients = None # Will be set in initialize() max_steps: int = 20 connection_type: str = "stdio" # "stdio" or "sse" @@ -141,7 +141,7 @@ class MCPAgent(ToolCallAgent): # Refresh tools periodically if self.current_step % self._refresh_tools_interval == 0: - added, removed = await self._refresh_tools() + await self._refresh_tools() # All tools removed indicates shutdown if not self.mcp_tools.tool_map: logger.info("MCP service has shut down, ending interaction") diff --git a/app/tool/mcp.py b/app/tool/mcp.py index 25aedff..3115286 100644 --- a/app/tool/mcp.py +++ b/app/tool/mcp.py @@ -1,5 +1,5 @@ from contextlib import AsyncExitStack -from typing import Any, Dict, List, Optional +from typing import List, Optional from mcp import ClientSession, StdioServerParameters from mcp.client.sse import sse_client @@ -11,14 +11,13 @@ from app.tool.base import BaseTool, ToolResult from app.tool.tool_collection import ToolCollection -class MCPServerTool(BaseTool): - """Represents a tool available on the MCP server.""" +class MCPClientTool(BaseTool): + """Represents a tool proxy that can be called on the MCP server from the client side.""" - schema: Dict[str, Any] = None session: Optional[ClientSession] = None async def execute(self, **kwargs) -> ToolResult: - """Execute the tool on the MCP server.""" + """Execute the tool by making a remote call to the MCP server.""" if not self.session: return ToolResult(error="Not connected to MCP server") @@ -32,12 +31,14 @@ class MCPServerTool(BaseTool): return ToolResult(error=f"Error executing tool: {str(e)}") -class MCP(ToolCollection): - """AN MCP tool collection that connects to an MCP server and executes commands.""" +class MCPClients(ToolCollection): + """ + A collection of tools that connects to an MCP server and manages available tools through the Model Context Protocol. + """ session: Optional[ClientSession] = None exit_stack: AsyncExitStack = None - description: str = "MCP tools for server interaction" + description: str = "MCP client tools for server interaction" def __init__(self): super().__init__() # Initialize with empty tools list @@ -91,10 +92,10 @@ class MCP(ToolCollection): # Create proper tool objects for each server tool for tool in response.tools: - server_tool = MCPServerTool( + server_tool = MCPClientTool( name=tool.name, description=tool.description, - schema=tool.inputSchema, + parameters=tool.inputSchema, session=self.session, ) self.tool_map[tool.name] = server_tool From e5808d1a90a60c191a65c0bb1e7dc80025efdeca Mon Sep 17 00:00:00 2001 From: liangxinbing <1580466765@qq.com> Date: Thu, 20 Mar 2025 09:02:28 +0800 Subject: [PATCH 22/26] update mcp_tools to mcp_clients --- app/agent/mcp.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/agent/mcp.py b/app/agent/mcp.py index ca34da4..01a48b0 100644 --- a/app/agent/mcp.py +++ b/app/agent/mcp.py @@ -24,7 +24,7 @@ class MCPAgent(ToolCallAgent): next_step_prompt: str = NEXT_STEP_PROMPT # Initialize MCP tool collection - mcp_tools: MCPClients = Field(default_factory=MCPClients) + mcp_clients: MCPClients = Field(default_factory=MCPClients) available_tools: MCPClients = None # Will be set in initialize() max_steps: int = 20 @@ -59,22 +59,22 @@ class MCPAgent(ToolCallAgent): if self.connection_type == "sse": if not server_url: raise ValueError("Server URL is required for SSE connection") - await self.mcp_tools.connect_sse(server_url=server_url) + await self.mcp_clients.connect_sse(server_url=server_url) elif self.connection_type == "stdio": if not command: raise ValueError("Command is required for stdio connection") - await self.mcp_tools.connect_stdio(command=command, args=args or []) + await self.mcp_clients.connect_stdio(command=command, args=args or []) else: raise ValueError(f"Unsupported connection type: {self.connection_type}") # Set available_tools to our MCP instance - self.available_tools = self.mcp_tools + self.available_tools = self.mcp_clients # Store initial tool schemas await self._refresh_tools() # Add system message about available tools - tool_names = list(self.mcp_tools.tool_map.keys()) + tool_names = list(self.mcp_clients.tool_map.keys()) tools_info = ", ".join(tool_names) # Add system prompt and available tools information @@ -90,11 +90,11 @@ class MCPAgent(ToolCallAgent): Returns: A tuple of (added_tools, removed_tools) """ - if not self.mcp_tools.session: + if not self.mcp_clients.session: return [], [] # Get current tool schemas directly from the server - response = await self.mcp_tools.session.list_tools() + response = await self.mcp_clients.session.list_tools() current_tools = {tool.name: tool.inputSchema for tool in response.tools} # Determine added, removed, and changed tools @@ -134,7 +134,7 @@ class MCPAgent(ToolCallAgent): async def think(self) -> bool: """Process current state and decide next action.""" # Check MCP session and tools availability - if not self.mcp_tools.session or not self.mcp_tools.tool_map: + if not self.mcp_clients.session or not self.mcp_clients.tool_map: logger.info("MCP service is no longer available, ending interaction") self.state = AgentState.FINISHED return False @@ -143,7 +143,7 @@ class MCPAgent(ToolCallAgent): if self.current_step % self._refresh_tools_interval == 0: await self._refresh_tools() # All tools removed indicates shutdown - if not self.mcp_tools.tool_map: + if not self.mcp_clients.tool_map: logger.info("MCP service has shut down, ending interaction") self.state = AgentState.FINISHED return False @@ -171,8 +171,8 @@ class MCPAgent(ToolCallAgent): async def cleanup(self) -> None: """Clean up MCP connection when done.""" - if self.mcp_tools.session: - await self.mcp_tools.disconnect() + if self.mcp_clients.session: + await self.mcp_clients.disconnect() logger.info("MCP connection closed") async def run(self, request: Optional[str] = None) -> str: From 6fa0b1be955d75df8637ac358b7b20e6a1bbed79 Mon Sep 17 00:00:00 2001 From: liangxinbing <1580466765@qq.com> Date: Thu, 20 Mar 2025 09:02:50 +0800 Subject: [PATCH 23/26] update SWEAgent --- app/agent/swe.py | 3 ++- app/tool/bash.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/agent/swe.py b/app/agent/swe.py index 8db61ca..0ac1b36 100644 --- a/app/agent/swe.py +++ b/app/agent/swe.py @@ -29,7 +29,8 @@ class SWEAgent(ToolCallAgent): async def think(self) -> bool: """Process current state and decide next action""" # Update working directory - self.working_dir = await self.bash.execute("pwd") + result = await self.bash.execute("pwd") + self.working_dir = result.output self.next_step_prompt = self.next_step_prompt.format( current_dir=self.working_dir ) diff --git a/app/tool/bash.py b/app/tool/bash.py index 0e5c2ff..c6b9072 100644 --- a/app/tool/bash.py +++ b/app/tool/bash.py @@ -3,7 +3,7 @@ import os from typing import Optional from app.exceptions import ToolError -from app.tool.base import BaseTool, CLIResult, ToolResult +from app.tool.base import BaseTool, CLIResult _BASH_DESCRIPTION = """Execute a bash command in the terminal. @@ -57,7 +57,7 @@ class _BashSession: if not self._started: raise ToolError("Session has not started.") if self._process.returncode is not None: - return ToolResult( + return CLIResult( system="tool must be restarted", error=f"bash has exited with returncode {self._process.returncode}", ) @@ -140,7 +140,7 @@ class Bash(BaseTool): self._session = _BashSession() await self._session.start() - return ToolResult(system="tool has been restarted.") + return CLIResult(system="tool has been restarted.") if self._session is None: self._session = _BashSession() From 721d8636427dea031d1aed7edb373b80cd469646 Mon Sep 17 00:00:00 2001 From: liangxinbing <1580466765@qq.com> Date: Thu, 20 Mar 2025 09:16:10 +0800 Subject: [PATCH 24/26] update README --- README.md | 12 +++++++++++- README_ja.md | 12 +++++++++++- README_ko.md | 12 +++++++++++- README_zh.md | 12 +++++++++++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d8e5bb0..9e757b6 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,11 @@ source .venv/bin/activate # On Unix/macOS uv pip install -r requirements.txt ``` +### Browser Automation Tool (Optional) +```bash +playwright install +``` + ## Configuration OpenManus requires configuration for the LLM APIs it uses. Follow these steps to set up your configuration: @@ -119,7 +124,12 @@ python main.py Then input your idea via terminal! -For unstable version, you also can run: +For MCP tool version, you can run: +```bash +python run_mcp.py +``` + +For unstable multi-agent version, you also can run: ```bash python run_flow.py diff --git a/README_ja.md b/README_ja.md index 71e5b68..be163fb 100644 --- a/README_ja.md +++ b/README_ja.md @@ -81,6 +81,11 @@ source .venv/bin/activate # Unix/macOSの場合 uv pip install -r requirements.txt ``` +### ブラウザ自動化ツール(オプション) +```bash +playwright install +``` + ## 設定 OpenManusを使用するには、LLM APIの設定が必要です。以下の手順に従って設定してください: @@ -119,7 +124,12 @@ python main.py その後、ターミナルからプロンプトを入力してください! -開発中バージョンを試すには、以下を実行します: +MCP ツールバージョンを使用する場合は、以下を実行します: +```bash +python run_mcp.py +``` + +開発中のマルチエージェントバージョンを試すには、以下を実行します: ```bash python run_flow.py diff --git a/README_ko.md b/README_ko.md index 1a00afb..9a17c4b 100644 --- a/README_ko.md +++ b/README_ko.md @@ -81,6 +81,11 @@ source .venv/bin/activate # Unix/macOS의 경우 uv pip install -r requirements.txt ``` +### 브라우저 자동화 도구 (선택사항) +```bash +playwright install +``` + ## 설정 방법 OpenManus를 사용하려면 사용하는 LLM API에 대한 설정이 필요합니다. 아래 단계를 따라 설정을 완료하세요: @@ -119,7 +124,12 @@ python main.py 이후 터미널에서 아이디어를 작성하세요! -unstable 버전을 실행하려면 아래 명령어를 사용할 수도 있습니다: +MCP 도구 버전을 사용하려면 다음을 실행하세요: +```bash +python run_mcp.py +``` + +불안정한 멀티 에이전트 버전을 실행하려면 다음을 실행할 수 있습니다: ```bash python run_flow.py diff --git a/README_zh.md b/README_zh.md index 15e010b..bbd7550 100644 --- a/README_zh.md +++ b/README_zh.md @@ -82,6 +82,11 @@ source .venv/bin/activate # Unix/macOS 系统 uv pip install -r requirements.txt ``` +### 浏览器自动化工具(可选) +```bash +playwright install +``` + ## 配置说明 OpenManus 需要配置使用的 LLM API,请按以下步骤设置: @@ -120,7 +125,12 @@ python main.py 然后通过终端输入你的创意! -如需体验不稳定的开发版本,可运行: +如需使用 MCP 工具版本,可运行: +```bash +python run_mcp.py +``` + +如需体验不稳定的多智能体版本,可运行: ```bash python run_flow.py From dda83efaf0e2512620ab3e7a91f41c70dddc44a3 Mon Sep 17 00:00:00 2001 From: liangxinbing <1580466765@qq.com> Date: Thu, 20 Mar 2025 09:30:35 +0800 Subject: [PATCH 25/26] add mcp prompt --- app/prompt/mcp.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 app/prompt/mcp.py diff --git a/app/prompt/mcp.py b/app/prompt/mcp.py new file mode 100644 index 0000000..acf15b2 --- /dev/null +++ b/app/prompt/mcp.py @@ -0,0 +1,43 @@ +"""Prompts for the MCP Agent.""" + +SYSTEM_PROMPT = """You are an AI assistant with access to a Model Context Protocol (MCP) server. +You can use the tools provided by the MCP server to complete tasks. +The MCP server will dynamically expose tools that you can use - always check the available tools first. + +When using an MCP tool: +1. Choose the appropriate tool based on your task requirements +2. Provide properly formatted arguments as required by the tool +3. Observe the results and use them to determine next steps +4. Tools may change during operation - new tools might appear or existing ones might disappear + +Follow these guidelines: +- Call tools with valid parameters as documented in their schemas +- Handle errors gracefully by understanding what went wrong and trying again with corrected parameters +- For multimedia responses (like images), you'll receive a description of the content +- Complete user requests step by step, using the most appropriate tools +- If multiple tools need to be called in sequence, make one call at a time and wait for results + +Remember to clearly explain your reasoning and actions to the user. +""" + +NEXT_STEP_PROMPT = """Based on the current state and available tools, what should be done next? +Think step by step about the problem and identify which MCP tool would be most helpful for the current stage. +If you've already made progress, consider what additional information you need or what actions would move you closer to completing the task. +""" + +# Additional specialized prompts +TOOL_ERROR_PROMPT = """You encountered an error with the tool '{tool_name}'. +Try to understand what went wrong and correct your approach. +Common issues include: +- Missing or incorrect parameters +- Invalid parameter formats +- Using a tool that's no longer available +- Attempting an operation that's not supported + +Please check the tool specifications and try again with corrected parameters. +""" + +MULTIMEDIA_RESPONSE_PROMPT = """You've received a multimedia response (image, audio, etc.) from the tool '{tool_name}'. +This content has been processed and described for you. +Use this information to continue the task or provide insights to the user. +""" From d4358ef53792be46697c9178e93873198ee55bed Mon Sep 17 00:00:00 2001 From: liangxinbing <1580466765@qq.com> Date: Thu, 20 Mar 2025 09:36:27 +0800 Subject: [PATCH 26/26] update requirements.txt --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index 77308f0..7e7b82f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,7 @@ playwright~=1.50.0 docker~=7.1.0 pytest~=8.3.5 pytest-asyncio~=0.25.3 + +mcp~=1.4.1 +httpx>=0.27.0 +tomli>=2.0.0