Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0cf79b6f4 | |||
| be1e22c79c | |||
| c37a145edc | |||
| c37100846b | |||
| 8faab601e7 | |||
| e499149ac4 | |||
| 36713cda62 | |||
| 151082099b | |||
| e1499ee1f6 | |||
| 7b59b64f77 | |||
| 8597ef4925 | |||
| b386fec6b9 | |||
| fe690ec0c3 | |||
| 1886c72cf2 | |||
| 1952af4b2d | |||
| 7cd3057dda | |||
| 6c89485694 | |||
| cb33ca3fea | |||
| 415a45f466 | |||
| d901a98064 | |||
| 303f736dd5 | |||
| 36627a12e2 | |||
| 0810b28d9d | |||
| 390ac132b8 | |||
| 8d23b06e46 | |||
| f457ec8462 | |||
| 03a1bd8586 | |||
| 0158c7233e | |||
| b9f6534315 | |||
| a77823636f | |||
| 3dfb21d1e3 | |||
| 638f155a10 | |||
| 8593116982 | |||
| 0c3f39836f | |||
| 18844dd108 | |||
| f162d629a6 | |||
| 3928a5dc8b | |||
| 42ba8ac51f | |||
| 8dc281f637 | |||
| f917597d0d | |||
| 90d60d673a | |||
| fe56b80c8e | |||
| f760ebfb15 | |||
| 7abe2949a7 | |||
| 7326cca450 | |||
| 8592c631a2 | |||
| 82838bd3b4 | |||
| df9312dfca | |||
| ead168d56c | |||
| 597691befa | |||
| db3c293683 | |||
| baee2ea1e2 | |||
| 9b0a11a1ea | |||
| f65bd9bbf5 | |||
| 1a12b67e4b | |||
| 0e7f5ee0f9 | |||
| a541822350 | |||
| b09f51ec64 | |||
| a66cd94532 | |||
| 391ca78207 | |||
| 0971678eef | |||
| 3f544a49c5 | |||
| 951e2856b6 | |||
| 9dbc2306c8 | |||
| 235b7af89d | |||
| f1cad2f69e | |||
| ca63cb1b32 | |||
| 8c101e1738 | |||
| 1c8bd4e1f7 | |||
| 19204ca8a7 | |||
| cf1500aa7d | |||
| acc6ac69e1 | |||
| 1f3b3d2a03 | |||
| 5fd3baaf77 | |||
| 9f0a647357 | |||
| 3359113b03 | |||
| 7db2887373 | |||
| 0d2fe2b560 | |||
| 40d5c83edc | |||
| 896a5d8d8d | |||
| 84aec2c5ac | |||
| eb844fdf2e | |||
| 5ecb2405d3 | |||
| f616c5d43d | |||
| b8bd822b39 | |||
| 49d1e99728 | |||
| cecedb62bd | |||
| 0a4dce5522 | |||
| 76d62a6ab3 | |||
| d3313a6b39 | |||
| 964b2afc0f | |||
| ec18292c8e | |||
| f4ad612a47 | |||
| 2d4116d98d | |||
| 94426f1a0d | |||
| 8629c0a4e9 | |||
| ae83bc7370 | |||
| 5e479d7777 | |||
| 9f603c6c3d | |||
| 8b940a22f9 | |||
| 3bf06dfd7c | |||
| 2435469f29 | |||
| 6dc43e404d | |||
| 55015797c6 | |||
| af46bfada2 | |||
| 782687f3b7 | |||
| ebe9602fbe | |||
| b17e451d86 | |||
| 49e106fd2c | |||
| f9ad4362f6 | |||
| 6a2ff78ffd | |||
| f821ee729c | |||
| 346666d946 | |||
| b0e9384502 | |||
| 96c23f1f56 | |||
| c28d036ee3 | |||
| da554cd335 | |||
| 91e131a915 | |||
| d674249804 | |||
| 34c610857f | |||
| 128ba7fbc8 | |||
| d2a67d876d | |||
| 628aaf00d1 | |||
| 722d5c787d | |||
| e10aa08013 | |||
| a8625ee5d9 | |||
| 7d3b10ef83 | |||
| 9275c1f7e8 | |||
| a10b973494 | |||
| 6057b9b376 | |||
| e86ce6d79f | |||
| 8f89685f02 | |||
| 3f3d262ebe | |||
| 449ab5e25a | |||
| ac3bc1e061 | |||
| ccfde08fcb | |||
| c7a3f465da | |||
| 8127bf26ad | |||
| cdbefb5fc7 | |||
| f37c3c29a1 | |||
| c432ec9286 | |||
| 65470c2ae9 | |||
| 30342247c0 | |||
| a61ef9b737 | |||
| 14bf016134 | |||
| 82e3140357 | |||
| d0492a500e | |||
| 0d6e3e5457 | |||
| 12bf9a107f | |||
| 8b031958b1 | |||
| 35209978e1 | |||
| 3dd990e554 | |||
| e218c0655f | |||
| 5d18b5dc69 | |||
| 567bffb441 | |||
| acb435f9f5 | |||
| d63e88f089 | |||
| 2425ed71fb | |||
| 2bb72029be | |||
| c3de3ad6f7 | |||
| c768e6a668 | |||
| 2f59b3e798 | |||
| 8567344eb3 | |||
| 802274bd3a | |||
| 08a20f6880 | |||
| 2a13cb49f3 | |||
| 4e10b42b30 | |||
| 56e4c8a0d7 | |||
| a0a535e2f9 | |||
| eef10607e1 | |||
| 8535f41215 | |||
| baf439f6c3 | |||
| 59a92257be | |||
| 855caad4d9 | |||
| 95e3487402 | |||
| fe44fe726d | |||
| ba05d625fd | |||
| c7858c2eb4 | |||
| 19b24cbdf7 | |||
| e39046d175 | |||
| 9fa12e594c | |||
| 711c2805e4 |
@@ -1,4 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "📑 Read online docs"
|
||||
about: Find tutorials, use cases, and guides in the OpenManus documentation.
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "Join the Community Group"
|
||||
about: Join the OpenManus community to discuss and get help from others
|
||||
url: https://github.com/FoundationAgents/OpenManus?tab=readme-ov-file#community-group
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
contents: read
|
||||
name: Display and label top issues
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'mannaandpoem/OpenManus'
|
||||
if: github.repository == 'FoundationAgents/OpenManus'
|
||||
steps:
|
||||
- name: Run top issues action
|
||||
uses: rickstaa/top-issues-action@7e8dda5d5ae3087670f9094b9724a9a091fc3ba1 # v1.3.101
|
||||
|
||||
@@ -197,3 +197,6 @@ cython_debug/
|
||||
|
||||
# OSX
|
||||
.DS_Store
|
||||
|
||||
# node
|
||||
node_modules
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ minimizing disruptions. Let’s work together to build a supportive and welcomin
|
||||
provide context.
|
||||
- Keep discussions in public channels whenever possible to allow others to benefit from the conversation, unless the
|
||||
matter is sensitive or private.
|
||||
- Always adhere to [our standards](https://github.com/mannaandpoem/OpenManus/blob/main/CODE_OF_CONDUCT.md#our-standards)
|
||||
- Always adhere to [our standards](https://github.com/FoundationAgents/OpenManus/blob/main/CODE_OF_CONDUCT.md#our-standards)
|
||||
to ensure a welcoming and collaborative environment.
|
||||
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged. For
|
||||
Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned. For
|
||||
|
||||
@@ -1,176 +1,196 @@
|
||||
<p align="center">
|
||||
<img src="assets/logo.jpg" width="200"/>
|
||||
</p>
|
||||
|
||||
English | [中文](README_zh.md) | [한국어](README_ko.md) | [日本語](README_ja.md)
|
||||
|
||||
[](https://github.com/mannaandpoem/OpenManus/stargazers)
|
||||
 
|
||||
[](https://opensource.org/licenses/MIT)  
|
||||
[](https://discord.gg/DYn29wFk9z)
|
||||
|
||||
# 👋 OpenManus
|
||||
|
||||
Manus is incredible, but OpenManus can achieve any idea without an *Invite Code* 🛫!
|
||||
|
||||
Our team members [@Xinbin Liang](https://github.com/mannaandpoem) and [@Jinyu Xiang](https://github.com/XiangJinyu) (core authors), along with [@Zhaoyang Yu](https://github.com/MoshiQAQ), [@Jiayi Zhang](https://github.com/didiforgithub), and [@Sirui Hong](https://github.com/stellaHSR), we are from [@MetaGPT](https://github.com/geekan/MetaGPT). The prototype is launched within 3 hours and we are keeping building!
|
||||
|
||||
It's a simple implementation, so we welcome any suggestions, contributions, and feedback!
|
||||
|
||||
Enjoy your own agent with OpenManus!
|
||||
|
||||
We're also excited to introduce [OpenManus-RL](https://github.com/OpenManus/OpenManus-RL), an open-source project dedicated to reinforcement learning (RL)- based (such as GRPO) tuning methods for LLM agents, developed collaboratively by researchers from UIUC and OpenManus.
|
||||
|
||||
## Project Demo
|
||||
|
||||
<video src="https://private-user-images.githubusercontent.com/61239030/420168772-6dcfd0d2-9142-45d9-b74e-d10aa75073c6.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDEzMTgwNTksIm5iZiI6MTc0MTMxNzc1OSwicGF0aCI6Ii82MTIzOTAzMC80MjAxNjg3NzItNmRjZmQwZDItOTE0Mi00NWQ5LWI3NGUtZDEwYWE3NTA3M2M2Lm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA3VDAzMjIzOVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTdiZjFkNjlmYWNjMmEzOTliM2Y3M2VlYjgyNDRlZDJmOWE3NWZhZjE1MzhiZWY4YmQ3NjdkNTYwYTU5ZDA2MzYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.UuHQCgWYkh0OQq9qsUWqGsUbhG3i9jcZDAMeHjLt5T4" data-canonical-src="https://private-user-images.githubusercontent.com/61239030/420168772-6dcfd0d2-9142-45d9-b74e-d10aa75073c6.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDEzMTgwNTksIm5iZiI6MTc0MTMxNzc1OSwicGF0aCI6Ii82MTIzOTAzMC80MjAxNjg3NzItNmRjZmQwZDItOTE0Mi00NWQ5LWI3NGUtZDEwYWE3NTA3M2M2Lm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA3VDAzMjIzOVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTdiZjFkNjlmYWNjMmEzOTliM2Y3M2VlYjgyNDRlZDJmOWE3NWZhZjE1MzhiZWY4YmQ3NjdkNTYwYTU5ZDA2MzYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.UuHQCgWYkh0OQq9qsUWqGsUbhG3i9jcZDAMeHjLt5T4" controls="controls" muted="muted" class="d-block rounded-bottom-2 border-top width-fit" style="max-height:640px; min-height: 200px"></video>
|
||||
|
||||
## Installation
|
||||
|
||||
We provide two installation methods. Method 2 (using uv) is recommended for faster installation and better dependency management.
|
||||
|
||||
### Method 1: Using conda
|
||||
|
||||
1. Create a new conda environment:
|
||||
|
||||
```bash
|
||||
conda create -n open_manus python=3.12
|
||||
conda activate open_manus
|
||||
```
|
||||
|
||||
2. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Method 2: Using uv (Recommended)
|
||||
|
||||
1. Install uv (A fast Python package installer and resolver):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
2. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
3. Create a new virtual environment and activate it:
|
||||
|
||||
```bash
|
||||
uv venv --python 3.12
|
||||
source .venv/bin/activate # On Unix/macOS
|
||||
# Or on Windows:
|
||||
# .venv\Scripts\activate
|
||||
```
|
||||
|
||||
4. Install dependencies:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
1. Create a `config.toml` file in the `config` directory (you can copy from the example):
|
||||
|
||||
```bash
|
||||
cp config/config.example.toml config/config.toml
|
||||
```
|
||||
|
||||
2. Edit `config/config.toml` to add your API keys and customize settings:
|
||||
|
||||
```toml
|
||||
# Global LLM configuration
|
||||
[llm]
|
||||
model = "gpt-4o"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
api_key = "sk-..." # Replace with your actual API key
|
||||
max_tokens = 4096
|
||||
temperature = 0.0
|
||||
|
||||
# Optional configuration for specific LLM models
|
||||
[llm.vision]
|
||||
model = "gpt-4o"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
api_key = "sk-..." # Replace with your actual API key
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
One line for run OpenManus:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Then input your idea via terminal!
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## How to contribute
|
||||
|
||||
We welcome any friendly suggestions and helpful contributions! Just create issues or submit pull requests.
|
||||
|
||||
Or contact @mannaandpoem via 📧email: mannaandpoem@gmail.com
|
||||
|
||||
**Note**: Before submitting a pull request, please use the pre-commit tool to check your changes. Run `pre-commit run --all-files` to execute the checks.
|
||||
|
||||
## Community Group
|
||||
Join our networking group on Feishu and share your experience with other developers!
|
||||
|
||||
<div align="center" style="display: flex; gap: 20px;">
|
||||
<img src="assets/community_group.jpg" alt="OpenManus 交流群" width="300" />
|
||||
</div>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#mannaandpoem/OpenManus&Date)
|
||||
|
||||
## Acknowledgement
|
||||
|
||||
Thanks to [anthropic-computer-use](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo)
|
||||
and [browser-use](https://github.com/browser-use/browser-use) for providing basic support for this project!
|
||||
|
||||
Additionally, we are grateful to [AAAJ](https://github.com/metauto-ai/agent-as-a-judge), [MetaGPT](https://github.com/geekan/MetaGPT), [OpenHands](https://github.com/All-Hands-AI/OpenHands) and [SWE-agent](https://github.com/SWE-agent/SWE-agent).
|
||||
|
||||
OpenManus is built by contributors from MetaGPT. Huge thanks to this agent community!
|
||||
|
||||
## Cite
|
||||
```bibtex
|
||||
@misc{openmanus2025,
|
||||
author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong},
|
||||
title = {OpenManus: An open-source framework for building general AI agents},
|
||||
year = {2025},
|
||||
publisher = {GitHub},
|
||||
journal = {GitHub repository},
|
||||
howpublished = {\url{https://github.com/mannaandpoem/OpenManus}},
|
||||
}
|
||||
```
|
||||
<p align="center">
|
||||
<img src="assets/logo.jpg" width="200"/>
|
||||
</p>
|
||||
|
||||
English | [中文](README_zh.md) | [한국어](README_ko.md) | [日本語](README_ja.md)
|
||||
|
||||
[](https://github.com/FoundationAgents/OpenManus/stargazers)
|
||||
 
|
||||
[](https://opensource.org/licenses/MIT)  
|
||||
[](https://discord.gg/DYn29wFk9z)
|
||||
[](https://huggingface.co/spaces/lyh-917/OpenManusDemo)
|
||||
[](https://doi.org/10.5281/zenodo.15186407)
|
||||
|
||||
# 👋 OpenManus
|
||||
|
||||
Manus is incredible, but OpenManus can achieve any idea without an *Invite Code* 🛫!
|
||||
|
||||
Our team members [@Xinbin Liang](https://github.com/mannaandpoem) and [@Jinyu Xiang](https://github.com/XiangJinyu) (core authors), along with [@Zhaoyang Yu](https://github.com/MoshiQAQ), [@Jiayi Zhang](https://github.com/didiforgithub), and [@Sirui Hong](https://github.com/stellaHSR), we are from [@MetaGPT](https://github.com/geekan/MetaGPT). The prototype is launched within 3 hours and we are keeping building!
|
||||
|
||||
It's a simple implementation, so we welcome any suggestions, contributions, and feedback!
|
||||
|
||||
Enjoy your own agent with OpenManus!
|
||||
|
||||
We're also excited to introduce [OpenManus-RL](https://github.com/OpenManus/OpenManus-RL), an open-source project dedicated to reinforcement learning (RL)- based (such as GRPO) tuning methods for LLM agents, developed collaboratively by researchers from UIUC and OpenManus.
|
||||
|
||||
## Project Demo
|
||||
|
||||
<video src="https://private-user-images.githubusercontent.com/61239030/420168772-6dcfd0d2-9142-45d9-b74e-d10aa75073c6.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDEzMTgwNTksIm5iZiI6MTc0MTMxNzc1OSwicGF0aCI6Ii82MTIzOTAzMC80MjAxNjg3NzItNmRjZmQwZDItOTE0Mi00NWQ5LWI3NGUtZDEwYWE3NTA3M2M2Lm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA3VDAzMjIzOVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTdiZjFkNjlmYWNjMmEzOTliM2Y3M2VlYjgyNDRlZDJmOWE3NWZhZjE1MzhiZWY4YmQ3NjdkNTYwYTU5ZDA2MzYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.UuHQCgWYkh0OQq9qsUWqGsUbhG3i9jcZDAMeHjLt5T4" data-canonical-src="https://private-user-images.githubusercontent.com/61239030/420168772-6dcfd0d2-9142-45d9-b74e-d10aa75073c6.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDEzMTgwNTksIm5iZiI6MTc0MTMxNzc1OSwicGF0aCI6Ii82MTIzOTAzMC80MjAxNjg3NzItNmRjZmQwZDItOTE0Mi00NWQ5LWI3NGUtZDEwYWE3NTA3M2M2Lm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMDclMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzA3VDAzMjIzOVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTdiZjFkNjlmYWNjMmEzOTliM2Y3M2VlYjgyNDRlZDJmOWE3NWZhZjE1MzhiZWY4YmQ3NjdkNTYwYTU5ZDA2MzYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.UuHQCgWYkh0OQq9qsUWqGsUbhG3i9jcZDAMeHjLt5T4" controls="controls" muted="muted" class="d-block rounded-bottom-2 border-top width-fit" style="max-height:640px; min-height: 200px"></video>
|
||||
|
||||
## Installation
|
||||
|
||||
We provide two installation methods. Method 2 (using uv) is recommended for faster installation and better dependency management.
|
||||
|
||||
### Method 1: Using conda
|
||||
|
||||
1. Create a new conda environment:
|
||||
|
||||
```bash
|
||||
conda create -n open_manus python=3.12
|
||||
conda activate open_manus
|
||||
```
|
||||
|
||||
2. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/FoundationAgents/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Method 2: Using uv (Recommended)
|
||||
|
||||
1. Install uv (A fast Python package installer and resolver):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
2. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/FoundationAgents/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
3. Create a new virtual environment and activate it:
|
||||
|
||||
```bash
|
||||
uv venv --python 3.12
|
||||
source .venv/bin/activate # On Unix/macOS
|
||||
# Or on Windows:
|
||||
# .venv\Scripts\activate
|
||||
```
|
||||
|
||||
4. Install dependencies:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
1. Create a `config.toml` file in the `config` directory (you can copy from the example):
|
||||
|
||||
```bash
|
||||
cp config/config.example.toml config/config.toml
|
||||
```
|
||||
|
||||
2. Edit `config/config.toml` to add your API keys and customize settings:
|
||||
|
||||
```toml
|
||||
# Global LLM configuration
|
||||
[llm]
|
||||
model = "gpt-4o"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
api_key = "sk-..." # Replace with your actual API key
|
||||
max_tokens = 4096
|
||||
temperature = 0.0
|
||||
|
||||
# Optional configuration for specific LLM models
|
||||
[llm.vision]
|
||||
model = "gpt-4o"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
api_key = "sk-..." # Replace with your actual API key
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
One line for run OpenManus:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Then input your idea via terminal!
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Custom Adding Multiple Agents
|
||||
|
||||
Currently, besides the general OpenManus Agent, we have also integrated the DataAnalysis Agent, which is suitable for data analysis and data visualization tasks. You can add this agent to `run_flow` in `config.toml`.
|
||||
|
||||
```toml
|
||||
# Optional configuration for run-flow
|
||||
[runflow]
|
||||
use_data_analysis_agent = true # Disabled by default, change to true to activate
|
||||
```
|
||||
In addition, you need to install the relevant dependencies to ensure the agent runs properly: [Detailed Installation Guide](app/tool/chart_visualization/README.md##Installation)
|
||||
|
||||
## How to contribute
|
||||
|
||||
We welcome any friendly suggestions and helpful contributions! Just create issues or submit pull requests.
|
||||
|
||||
Or contact @mannaandpoem via 📧email: mannaandpoem@gmail.com
|
||||
|
||||
**Note**: Before submitting a pull request, please use the pre-commit tool to check your changes. Run `pre-commit run --all-files` to execute the checks.
|
||||
|
||||
## Community Group
|
||||
Join our networking group on Feishu and share your experience with other developers!
|
||||
|
||||
<div align="center" style="display: flex; gap: 20px;">
|
||||
<img src="assets/community_group.jpg" alt="OpenManus 交流群" width="300" />
|
||||
</div>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#FoundationAgents/OpenManus&Date)
|
||||
|
||||
## Sponsors
|
||||
Thanks to [PPIO](https://ppinfra.com/user/register?invited_by=OCPKCN&utm_source=github_openmanus&utm_medium=github_readme&utm_campaign=link) for computing source support.
|
||||
> PPIO: The most affordable and easily-integrated MaaS and GPU cloud solution.
|
||||
|
||||
|
||||
## Acknowledgement
|
||||
|
||||
Thanks to [anthropic-computer-use](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo)
|
||||
and [browser-use](https://github.com/browser-use/browser-use) for providing basic support for this project!
|
||||
|
||||
Additionally, we are grateful to [AAAJ](https://github.com/metauto-ai/agent-as-a-judge), [MetaGPT](https://github.com/geekan/MetaGPT), [OpenHands](https://github.com/All-Hands-AI/OpenHands) and [SWE-agent](https://github.com/SWE-agent/SWE-agent).
|
||||
|
||||
We also thank stepfun(阶跃星辰) for supporting our Hugging Face demo space.
|
||||
|
||||
OpenManus is built by contributors from MetaGPT. Huge thanks to this agent community!
|
||||
|
||||
## Cite
|
||||
```bibtex
|
||||
@misc{openmanus2025,
|
||||
author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong and Sheng Fan and Xiao Tang},
|
||||
title = {OpenManus: An open-source framework for building general AI agents},
|
||||
year = {2025},
|
||||
publisher = {Zenodo},
|
||||
doi = {10.5281/zenodo.15186407},
|
||||
url = {https://doi.org/10.5281/zenodo.15186407},
|
||||
}
|
||||
```
|
||||
|
||||
+26
-8
@@ -4,10 +4,12 @@
|
||||
|
||||
[English](README.md) | [中文](README_zh.md) | [한국어](README_ko.md) | 日本語
|
||||
|
||||
[](https://github.com/mannaandpoem/OpenManus/stargazers)
|
||||
[](https://github.com/FoundationAgents/OpenManus/stargazers)
|
||||
 
|
||||
[](https://opensource.org/licenses/MIT)  
|
||||
[](https://discord.gg/DYn29wFk9z)
|
||||
[](https://huggingface.co/spaces/lyh-917/OpenManusDemo)
|
||||
[](https://doi.org/10.5281/zenodo.15186407)
|
||||
|
||||
# 👋 OpenManus
|
||||
|
||||
@@ -41,7 +43,7 @@ conda activate open_manus
|
||||
2. リポジトリをクローンします:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
git clone https://github.com/FoundationAgents/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
@@ -62,7 +64,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
2. リポジトリをクローンします:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
git clone https://github.com/FoundationAgents/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
@@ -135,6 +137,19 @@ python run_mcp.py
|
||||
python run_flow.py
|
||||
```
|
||||
|
||||
## カスタムマルチエージェントの追加
|
||||
|
||||
現在、一般的なOpenManusエージェントに加えて、データ分析とデータ可視化タスクに適したDataAnalysisエージェントが組み込まれています。このエージェントを`config.toml`の`run_flow`に追加することができます。
|
||||
|
||||
```toml
|
||||
# run-flowのオプション設定
|
||||
[runflow]
|
||||
use_data_analysis_agent = true # デフォルトでは無効、trueに変更すると有効化されます
|
||||
```
|
||||
|
||||
これに加えて、エージェントが正常に動作するために必要な依存関係をインストールする必要があります:[具体的なインストールガイド](app/tool/chart_visualization/README_ja.md##インストール)
|
||||
|
||||
|
||||
## 貢献方法
|
||||
|
||||
我々は建設的な意見や有益な貢献を歓迎します!issueを作成するか、プルリクエストを提出してください。
|
||||
@@ -152,7 +167,7 @@ Feishuのネットワーキンググループに参加して、他の開発者
|
||||
|
||||
## スター履歴
|
||||
|
||||
[](https://star-history.com/#mannaandpoem/OpenManus&Date)
|
||||
[](https://star-history.com/#FoundationAgents/OpenManus&Date)
|
||||
|
||||
## 謝辞
|
||||
|
||||
@@ -161,15 +176,18 @@ Feishuのネットワーキンググループに参加して、他の開発者
|
||||
|
||||
さらに、[AAAJ](https://github.com/metauto-ai/agent-as-a-judge)、[MetaGPT](https://github.com/geekan/MetaGPT)、[OpenHands](https://github.com/All-Hands-AI/OpenHands)、[SWE-agent](https://github.com/SWE-agent/SWE-agent)にも感謝します。
|
||||
|
||||
また、Hugging Face デモスペースをサポートしてくださった阶跃星辰 (stepfun)にも感謝いたします。
|
||||
|
||||
OpenManusはMetaGPTのコントリビューターによって構築されました。このエージェントコミュニティに大きな感謝を!
|
||||
|
||||
## 引用
|
||||
```bibtex
|
||||
@misc{openmanus2025,
|
||||
author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong},
|
||||
author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong and Sheng Fan and Xiao Tang},
|
||||
title = {OpenManus: An open-source framework for building general AI agents},
|
||||
year = {2025},
|
||||
publisher = {GitHub},
|
||||
journal = {GitHub repository},
|
||||
howpublished = {\url{https://github.com/mannaandpoem/OpenManus}},
|
||||
publisher = {Zenodo},
|
||||
doi = {10.5281/zenodo.15186407},
|
||||
url = {https://doi.org/10.5281/zenodo.15186407},
|
||||
}
|
||||
```
|
||||
|
||||
+24
-8
@@ -4,10 +4,12 @@
|
||||
|
||||
[English](README.md) | [中文](README_zh.md) | 한국어 | [日本語](README_ja.md)
|
||||
|
||||
[](https://github.com/mannaandpoem/OpenManus/stargazers)
|
||||
[](https://github.com/FoundationAgents/OpenManus/stargazers)
|
||||
 
|
||||
[](https://opensource.org/licenses/MIT)  
|
||||
[](https://discord.gg/DYn29wFk9z)
|
||||
[](https://huggingface.co/spaces/lyh-917/OpenManusDemo)
|
||||
[](https://doi.org/10.5281/zenodo.15186407)
|
||||
|
||||
# 👋 OpenManus
|
||||
|
||||
@@ -41,7 +43,7 @@ conda activate open_manus
|
||||
2. 저장소를 클론합니다:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
git clone https://github.com/FoundationAgents/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
@@ -62,7 +64,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
2. 저장소를 클론합니다:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
git clone https://github.com/FoundationAgents/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
@@ -135,6 +137,18 @@ python run_mcp.py
|
||||
python run_flow.py
|
||||
```
|
||||
|
||||
### 사용자 정의 다중 에이전트 추가
|
||||
|
||||
현재 일반 OpenManus 에이전트 외에도 데이터 분석 및 데이터 시각화 작업에 적합한 DataAnalysis 에이전트를 통합했습니다. 이 에이전트를 `config.toml`의 `run_flow`에 추가할 수 있습니다.
|
||||
|
||||
```toml
|
||||
# run-flow에 대한 선택적 구성
|
||||
[runflow]
|
||||
use_data_analysis_agent = true # 기본적으로 비활성화되어 있으며, 활성화하려면 true로 변경
|
||||
```
|
||||
|
||||
또한, 에이전트가 제대로 작동하도록 관련 종속성을 설치해야 합니다: [상세 설치 가이드](app/tool/chart_visualization/README.md##Installation)
|
||||
|
||||
## 기여 방법
|
||||
|
||||
모든 친절한 제안과 유용한 기여를 환영합니다! 이슈를 생성하거나 풀 리퀘스트를 제출해 주세요.
|
||||
@@ -152,7 +166,7 @@ Feishu 네트워킹 그룹에 참여하여 다른 개발자들과 경험을 공
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#mannaandpoem/OpenManus&Date)
|
||||
[](https://star-history.com/#FoundationAgents/OpenManus&Date)
|
||||
|
||||
## 감사의 글
|
||||
|
||||
@@ -161,16 +175,18 @@ Feishu 네트워킹 그룹에 참여하여 다른 개발자들과 경험을 공
|
||||
|
||||
또한, [AAAJ](https://github.com/metauto-ai/agent-as-a-judge), [MetaGPT](https://github.com/geekan/MetaGPT), [OpenHands](https://github.com/All-Hands-AI/OpenHands), [SWE-agent](https://github.com/SWE-agent/SWE-agent)에 깊은 감사를 드립니다.
|
||||
|
||||
또한 Hugging Face 데모 공간을 지원해 주신 阶跃星辰 (stepfun)에게 감사드립니다.
|
||||
|
||||
OpenManus는 MetaGPT 기여자들에 의해 개발되었습니다. 이 에이전트 커뮤니티에 깊은 감사를 전합니다!
|
||||
|
||||
## 인용
|
||||
```bibtex
|
||||
@misc{openmanus2025,
|
||||
author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong},
|
||||
author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong and Sheng Fan and Xiao Tang},
|
||||
title = {OpenManus: An open-source framework for building general AI agents},
|
||||
year = {2025},
|
||||
publisher = {GitHub},
|
||||
journal = {GitHub repository},
|
||||
howpublished = {\url{https://github.com/mannaandpoem/OpenManus}},
|
||||
publisher = {Zenodo},
|
||||
doi = {10.5281/zenodo.15186407},
|
||||
url = {https://doi.org/10.5281/zenodo.15186407},
|
||||
}
|
||||
```
|
||||
|
||||
+29
-10
@@ -4,10 +4,12 @@
|
||||
|
||||
[English](README.md) | 中文 | [한국어](README_ko.md) | [日本語](README_ja.md)
|
||||
|
||||
[](https://github.com/mannaandpoem/OpenManus/stargazers)
|
||||
[](https://github.com/FoundationAgents/OpenManus/stargazers)
|
||||
 
|
||||
[](https://opensource.org/licenses/MIT)  
|
||||
[](https://discord.gg/DYn29wFk9z)
|
||||
[](https://huggingface.co/spaces/lyh-917/OpenManusDemo)
|
||||
[](https://doi.org/10.5281/zenodo.15186407)
|
||||
|
||||
# 👋 OpenManus
|
||||
|
||||
@@ -42,7 +44,7 @@ conda activate open_manus
|
||||
2. 克隆仓库:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
git clone https://github.com/FoundationAgents/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
@@ -63,7 +65,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
2. 克隆仓库:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
git clone https://github.com/FoundationAgents/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
@@ -136,6 +138,17 @@ python run_mcp.py
|
||||
python run_flow.py
|
||||
```
|
||||
|
||||
## 添加自定义多智能体
|
||||
|
||||
目前除了通用的 OpenManus Agent, 我们还内置了DataAnalysis Agent,适用于数据分析和数据可视化任务,你可以在`config.toml`中将这个智能体加入到`run_flow`中
|
||||
```toml
|
||||
# run-flow可选配置
|
||||
[runflow]
|
||||
use_data_analysis_agent = true # 默认关闭,将其改为true则为激活
|
||||
```
|
||||
除此之外,你还需要安装相关的依赖来确保智能体正常运行:[具体安装指南](app/tool/chart_visualization/README_zh.md##安装)
|
||||
|
||||
|
||||
## 贡献指南
|
||||
|
||||
我们欢迎任何友好的建议和有价值的贡献!可以直接创建 issue 或提交 pull request。
|
||||
@@ -154,7 +167,12 @@ python run_flow.py
|
||||
|
||||
## Star 数量
|
||||
|
||||
[](https://star-history.com/#mannaandpoem/OpenManus&Date)
|
||||
[](https://star-history.com/#FoundationAgents/OpenManus&Date)
|
||||
|
||||
|
||||
## 赞助商
|
||||
感谢[PPIO](https://ppinfra.com/user/register?invited_by=OCPKCN&utm_source=github_openmanus&utm_medium=github_readme&utm_campaign=link) 提供的算力支持。
|
||||
> PPIO派欧云:一键调用高性价比的开源模型API和GPU容器
|
||||
|
||||
## 致谢
|
||||
|
||||
@@ -163,17 +181,18 @@ python run_flow.py
|
||||
|
||||
此外,我们感谢 [AAAJ](https://github.com/metauto-ai/agent-as-a-judge),[MetaGPT](https://github.com/geekan/MetaGPT),[OpenHands](https://github.com/All-Hands-AI/OpenHands) 和 [SWE-agent](https://github.com/SWE-agent/SWE-agent).
|
||||
|
||||
我们也感谢阶跃星辰 (stepfun) 提供的 Hugging Face 演示空间支持。
|
||||
|
||||
OpenManus 由 MetaGPT 社区的贡献者共同构建,感谢这个充满活力的智能体开发者社区!
|
||||
|
||||
## 引用我们
|
||||
|
||||
## 引用
|
||||
```bibtex
|
||||
@misc{openmanus2025,
|
||||
author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong},
|
||||
author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong and Sheng Fan and Xiao Tang},
|
||||
title = {OpenManus: An open-source framework for building general AI agents},
|
||||
year = {2025},
|
||||
publisher = {GitHub},
|
||||
journal = {GitHub repository},
|
||||
howpublished = {\url{https://github.com/mannaandpoem/OpenManus}},
|
||||
publisher = {Zenodo},
|
||||
doi = {10.5281/zenodo.15186407},
|
||||
url = {https://doi.org/10.5281/zenodo.15186407},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,7 +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
|
||||
from app.agent.toolcall import ToolCallAgent
|
||||
@@ -10,7 +9,6 @@ from app.agent.toolcall import ToolCallAgent
|
||||
__all__ = [
|
||||
"BaseAgent",
|
||||
"BrowserAgent",
|
||||
"PlanningAgent",
|
||||
"ReActAgent",
|
||||
"SWEAgent",
|
||||
"ToolCallAgent",
|
||||
|
||||
+82
-87
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from app.agent.toolcall import ToolCallAgent
|
||||
from app.logger import logger
|
||||
@@ -10,6 +10,75 @@ from app.schema import Message, ToolChoice
|
||||
from app.tool import BrowserUseTool, Terminate, ToolCollection
|
||||
|
||||
|
||||
# Avoid circular import if BrowserAgent needs BrowserContextHelper
|
||||
if TYPE_CHECKING:
|
||||
from app.agent.base import BaseAgent # Or wherever memory is defined
|
||||
|
||||
|
||||
class BrowserContextHelper:
|
||||
def __init__(self, agent: "BaseAgent"):
|
||||
self.agent = agent
|
||||
self._current_base64_image: Optional[str] = None
|
||||
|
||||
async def get_browser_state(self) -> Optional[dict]:
|
||||
browser_tool = self.agent.available_tools.get_tool(BrowserUseTool().name)
|
||||
if not browser_tool or not hasattr(browser_tool, "get_current_state"):
|
||||
logger.warning("BrowserUseTool not found or doesn't have get_current_state")
|
||||
return None
|
||||
try:
|
||||
result = await browser_tool.get_current_state()
|
||||
if result.error:
|
||||
logger.debug(f"Browser state error: {result.error}")
|
||||
return None
|
||||
if hasattr(result, "base64_image") and result.base64_image:
|
||||
self._current_base64_image = result.base64_image
|
||||
else:
|
||||
self._current_base64_image = None
|
||||
return json.loads(result.output)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get browser state: {str(e)}")
|
||||
return None
|
||||
|
||||
async def format_next_step_prompt(self) -> str:
|
||||
"""Gets browser state and formats the browser prompt."""
|
||||
browser_state = await self.get_browser_state()
|
||||
url_info, tabs_info, content_above_info, content_below_info = "", "", "", ""
|
||||
results_info = "" # Or get from agent if needed elsewhere
|
||||
|
||||
if browser_state and not browser_state.get("error"):
|
||||
url_info = f"\n URL: {browser_state.get('url', 'N/A')}\n Title: {browser_state.get('title', 'N/A')}"
|
||||
tabs = browser_state.get("tabs", [])
|
||||
if tabs:
|
||||
tabs_info = f"\n {len(tabs)} tab(s) available"
|
||||
pixels_above = browser_state.get("pixels_above", 0)
|
||||
pixels_below = browser_state.get("pixels_below", 0)
|
||||
if pixels_above > 0:
|
||||
content_above_info = f" ({pixels_above} pixels)"
|
||||
if pixels_below > 0:
|
||||
content_below_info = f" ({pixels_below} pixels)"
|
||||
|
||||
if self._current_base64_image:
|
||||
image_message = Message.user_message(
|
||||
content="Current browser screenshot:",
|
||||
base64_image=self._current_base64_image,
|
||||
)
|
||||
self.agent.memory.add_message(image_message)
|
||||
self._current_base64_image = None # Consume the image after adding
|
||||
|
||||
return NEXT_STEP_PROMPT.format(
|
||||
url_placeholder=url_info,
|
||||
tabs_placeholder=tabs_info,
|
||||
content_above_placeholder=content_above_info,
|
||||
content_below_placeholder=content_below_info,
|
||||
results_placeholder=results_info,
|
||||
)
|
||||
|
||||
async def cleanup_browser(self):
|
||||
browser_tool = self.agent.available_tools.get_tool(BrowserUseTool().name)
|
||||
if browser_tool and hasattr(browser_tool, "cleanup"):
|
||||
await browser_tool.cleanup()
|
||||
|
||||
|
||||
class BrowserAgent(ToolCallAgent):
|
||||
"""
|
||||
A browser agent that uses the browser_use library to control a browser.
|
||||
@@ -36,94 +105,20 @@ class BrowserAgent(ToolCallAgent):
|
||||
tool_choices: ToolChoice = ToolChoice.AUTO
|
||||
special_tool_names: list[str] = Field(default_factory=lambda: [Terminate().name])
|
||||
|
||||
_current_base64_image: Optional[str] = None
|
||||
browser_context_helper: Optional[BrowserContextHelper] = None
|
||||
|
||||
async def _handle_special_tool(self, name: str, result: Any, **kwargs):
|
||||
if not self._is_special_tool(name):
|
||||
return
|
||||
else:
|
||||
await self.available_tools.get_tool(BrowserUseTool().name).cleanup()
|
||||
await super()._handle_special_tool(name, result, **kwargs)
|
||||
|
||||
async def get_browser_state(self) -> Optional[dict]:
|
||||
"""Get the current browser state for context in next steps."""
|
||||
browser_tool = self.available_tools.get_tool(BrowserUseTool().name)
|
||||
if not browser_tool:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get browser state directly from the tool
|
||||
result = await browser_tool.get_current_state()
|
||||
|
||||
if result.error:
|
||||
logger.debug(f"Browser state error: {result.error}")
|
||||
return None
|
||||
|
||||
# Store screenshot if available
|
||||
if hasattr(result, "base64_image") and result.base64_image:
|
||||
self._current_base64_image = result.base64_image
|
||||
|
||||
# Parse the state info
|
||||
return json.loads(result.output)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get browser state: {str(e)}")
|
||||
return None
|
||||
@model_validator(mode="after")
|
||||
def initialize_helper(self) -> "BrowserAgent":
|
||||
self.browser_context_helper = BrowserContextHelper(self)
|
||||
return self
|
||||
|
||||
async def think(self) -> bool:
|
||||
"""Process current state and decide next actions using tools, with browser state info added"""
|
||||
# Add browser state to the context
|
||||
browser_state = await self.get_browser_state()
|
||||
|
||||
# Initialize placeholder values
|
||||
url_info = ""
|
||||
tabs_info = ""
|
||||
content_above_info = ""
|
||||
content_below_info = ""
|
||||
results_info = ""
|
||||
|
||||
if browser_state and not browser_state.get("error"):
|
||||
# URL and title info
|
||||
url_info = f"\n URL: {browser_state.get('url', 'N/A')}\n Title: {browser_state.get('title', 'N/A')}"
|
||||
|
||||
# Tab information
|
||||
if "tabs" in browser_state:
|
||||
tabs = browser_state.get("tabs", [])
|
||||
if tabs:
|
||||
tabs_info = f"\n {len(tabs)} tab(s) available"
|
||||
|
||||
# Content above/below viewport
|
||||
pixels_above = browser_state.get("pixels_above", 0)
|
||||
pixels_below = browser_state.get("pixels_below", 0)
|
||||
|
||||
if pixels_above > 0:
|
||||
content_above_info = f" ({pixels_above} pixels)"
|
||||
|
||||
if pixels_below > 0:
|
||||
content_below_info = f" ({pixels_below} pixels)"
|
||||
|
||||
# Add screenshot as base64 if available
|
||||
if self._current_base64_image:
|
||||
# Create a message with image attachment
|
||||
image_message = Message.user_message(
|
||||
content="Current browser screenshot:",
|
||||
base64_image=self._current_base64_image,
|
||||
)
|
||||
self.memory.add_message(image_message)
|
||||
|
||||
# Replace placeholders with actual browser state info
|
||||
self.next_step_prompt = NEXT_STEP_PROMPT.format(
|
||||
url_placeholder=url_info,
|
||||
tabs_placeholder=tabs_info,
|
||||
content_above_placeholder=content_above_info,
|
||||
content_below_placeholder=content_below_info,
|
||||
results_placeholder=results_info,
|
||||
self.next_step_prompt = (
|
||||
await self.browser_context_helper.format_next_step_prompt()
|
||||
)
|
||||
return await super().think()
|
||||
|
||||
# Call parent implementation
|
||||
result = await super().think()
|
||||
|
||||
# Reset the next_step_prompt to its original state
|
||||
self.next_step_prompt = NEXT_STEP_PROMPT
|
||||
|
||||
return result
|
||||
async def cleanup(self):
|
||||
"""Clean up browser agent resources by calling parent cleanup."""
|
||||
await self.browser_context_helper.cleanup_browser()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from pydantic import Field
|
||||
|
||||
from app.agent.toolcall import ToolCallAgent
|
||||
from app.config import config
|
||||
from app.prompt.visualization import NEXT_STEP_PROMPT, SYSTEM_PROMPT
|
||||
from app.tool import Terminate, ToolCollection
|
||||
from app.tool.chart_visualization.chart_prepare import VisualizationPrepare
|
||||
from app.tool.chart_visualization.data_visualization import DataVisualization
|
||||
from app.tool.chart_visualization.python_execute import NormalPythonExecute
|
||||
|
||||
|
||||
class DataAnalysis(ToolCallAgent):
|
||||
"""
|
||||
A data analysis agent that uses planning to solve various data analysis tasks.
|
||||
|
||||
This agent extends ToolCallAgent with a comprehensive set of tools and capabilities,
|
||||
including Data Analysis, Chart Visualization, Data Report.
|
||||
"""
|
||||
|
||||
name: str = "Data_Analysis"
|
||||
description: str = "An analytical agent that utilizes python and data visualization tools to solve diverse data analysis tasks"
|
||||
|
||||
system_prompt: str = SYSTEM_PROMPT.format(directory=config.workspace_root)
|
||||
next_step_prompt: str = NEXT_STEP_PROMPT
|
||||
|
||||
max_observe: int = 15000
|
||||
max_steps: int = 20
|
||||
|
||||
# Add general-purpose tools to the tool collection
|
||||
available_tools: ToolCollection = Field(
|
||||
default_factory=lambda: ToolCollection(
|
||||
NormalPythonExecute(),
|
||||
VisualizationPrepare(),
|
||||
DataVisualization(),
|
||||
Terminate(),
|
||||
)
|
||||
)
|
||||
+125
-23
@@ -1,28 +1,25 @@
|
||||
from pydantic import Field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from app.agent.browser import BrowserAgent
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from app.agent.browser import BrowserContextHelper
|
||||
from app.agent.toolcall import ToolCallAgent
|
||||
from app.config import config
|
||||
from app.prompt.browser import NEXT_STEP_PROMPT as BROWSER_NEXT_STEP_PROMPT
|
||||
from app.logger import logger
|
||||
from app.prompt.manus import NEXT_STEP_PROMPT, SYSTEM_PROMPT
|
||||
from app.tool import Terminate, ToolCollection
|
||||
from app.tool.ask_human import AskHuman
|
||||
from app.tool.browser_use_tool import BrowserUseTool
|
||||
from app.tool.mcp import MCPClients, MCPClientTool
|
||||
from app.tool.python_execute import PythonExecute
|
||||
from app.tool.str_replace_editor import StrReplaceEditor
|
||||
|
||||
|
||||
class Manus(BrowserAgent):
|
||||
"""
|
||||
A versatile general-purpose agent that uses planning to solve various tasks.
|
||||
|
||||
This agent extends BrowserAgent with a comprehensive set of tools and capabilities,
|
||||
including Python execution, web browsing, file operations, and information retrieval
|
||||
to handle a wide range of user requests.
|
||||
"""
|
||||
class Manus(ToolCallAgent):
|
||||
"""A versatile general-purpose agent with support for both local and MCP tools."""
|
||||
|
||||
name: str = "Manus"
|
||||
description: str = (
|
||||
"A versatile agent that can solve various tasks using multiple tools"
|
||||
)
|
||||
description: str = "A versatile agent that can solve various tasks using multiple tools including MCP-based tools"
|
||||
|
||||
system_prompt: str = SYSTEM_PROMPT.format(directory=config.workspace_root)
|
||||
next_step_prompt: str = NEXT_STEP_PROMPT
|
||||
@@ -30,31 +27,136 @@ class Manus(BrowserAgent):
|
||||
max_observe: int = 10000
|
||||
max_steps: int = 20
|
||||
|
||||
# MCP clients for remote tool access
|
||||
mcp_clients: MCPClients = Field(default_factory=MCPClients)
|
||||
|
||||
# Add general-purpose tools to the tool collection
|
||||
available_tools: ToolCollection = Field(
|
||||
default_factory=lambda: ToolCollection(
|
||||
PythonExecute(), BrowserUseTool(), StrReplaceEditor(), Terminate()
|
||||
PythonExecute(),
|
||||
BrowserUseTool(),
|
||||
StrReplaceEditor(),
|
||||
AskHuman(),
|
||||
Terminate(),
|
||||
)
|
||||
)
|
||||
|
||||
special_tool_names: list[str] = Field(default_factory=lambda: [Terminate().name])
|
||||
browser_context_helper: Optional[BrowserContextHelper] = None
|
||||
|
||||
# Track connected MCP servers
|
||||
connected_servers: Dict[str, str] = Field(
|
||||
default_factory=dict
|
||||
) # server_id -> url/command
|
||||
_initialized: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def initialize_helper(self) -> "Manus":
|
||||
"""Initialize basic components synchronously."""
|
||||
self.browser_context_helper = BrowserContextHelper(self)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
async def create(cls, **kwargs) -> "Manus":
|
||||
"""Factory method to create and properly initialize a Manus instance."""
|
||||
instance = cls(**kwargs)
|
||||
await instance.initialize_mcp_servers()
|
||||
instance._initialized = True
|
||||
return instance
|
||||
|
||||
async def initialize_mcp_servers(self) -> None:
|
||||
"""Initialize connections to configured MCP servers."""
|
||||
for server_id, server_config in config.mcp_config.servers.items():
|
||||
try:
|
||||
if server_config.type == "sse":
|
||||
if server_config.url:
|
||||
await self.connect_mcp_server(server_config.url, server_id)
|
||||
logger.info(
|
||||
f"Connected to MCP server {server_id} at {server_config.url}"
|
||||
)
|
||||
elif server_config.type == "stdio":
|
||||
if server_config.command:
|
||||
await self.connect_mcp_server(
|
||||
server_config.command,
|
||||
server_id,
|
||||
use_stdio=True,
|
||||
stdio_args=server_config.args,
|
||||
)
|
||||
logger.info(
|
||||
f"Connected to MCP server {server_id} using command {server_config.command}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MCP server {server_id}: {e}")
|
||||
|
||||
async def connect_mcp_server(
|
||||
self,
|
||||
server_url: str,
|
||||
server_id: str = "",
|
||||
use_stdio: bool = False,
|
||||
stdio_args: List[str] = None,
|
||||
) -> None:
|
||||
"""Connect to an MCP server and add its tools."""
|
||||
if use_stdio:
|
||||
await self.mcp_clients.connect_stdio(
|
||||
server_url, stdio_args or [], server_id
|
||||
)
|
||||
self.connected_servers[server_id or server_url] = server_url
|
||||
else:
|
||||
await self.mcp_clients.connect_sse(server_url, server_id)
|
||||
self.connected_servers[server_id or server_url] = server_url
|
||||
|
||||
# Update available tools with only the new tools from this server
|
||||
new_tools = [
|
||||
tool for tool in self.mcp_clients.tools if tool.server_id == server_id
|
||||
]
|
||||
self.available_tools.add_tools(*new_tools)
|
||||
|
||||
async def disconnect_mcp_server(self, server_id: str = "") -> None:
|
||||
"""Disconnect from an MCP server and remove its tools."""
|
||||
await self.mcp_clients.disconnect(server_id)
|
||||
if server_id:
|
||||
self.connected_servers.pop(server_id, None)
|
||||
else:
|
||||
self.connected_servers.clear()
|
||||
|
||||
# Rebuild available tools without the disconnected server's tools
|
||||
base_tools = [
|
||||
tool
|
||||
for tool in self.available_tools.tools
|
||||
if not isinstance(tool, MCPClientTool)
|
||||
]
|
||||
self.available_tools = ToolCollection(*base_tools)
|
||||
self.available_tools.add_tools(*self.mcp_clients.tools)
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up Manus agent resources."""
|
||||
if self.browser_context_helper:
|
||||
await self.browser_context_helper.cleanup_browser()
|
||||
# Disconnect from all MCP servers only if we were initialized
|
||||
if self._initialized:
|
||||
await self.disconnect_mcp_server()
|
||||
self._initialized = False
|
||||
|
||||
async def think(self) -> bool:
|
||||
"""Process current state and decide next actions with appropriate context."""
|
||||
# Store original prompt
|
||||
original_prompt = self.next_step_prompt
|
||||
if not self._initialized:
|
||||
await self.initialize_mcp_servers()
|
||||
self._initialized = True
|
||||
|
||||
# Only check recent messages (last 3) for browser activity
|
||||
original_prompt = self.next_step_prompt
|
||||
recent_messages = self.memory.messages[-3:] if self.memory.messages else []
|
||||
browser_in_use = any(
|
||||
"browser_use" in msg.content.lower()
|
||||
tc.function.name == BrowserUseTool().name
|
||||
for msg in recent_messages
|
||||
if hasattr(msg, "content") and isinstance(msg.content, str)
|
||||
if msg.tool_calls
|
||||
for tc in msg.tool_calls
|
||||
)
|
||||
|
||||
if browser_in_use:
|
||||
# Override with browser-specific prompt temporarily to get browser context
|
||||
self.next_step_prompt = BROWSER_NEXT_STEP_PROMPT
|
||||
self.next_step_prompt = (
|
||||
await self.browser_context_helper.format_next_step_prompt()
|
||||
)
|
||||
|
||||
# Call parent's think method
|
||||
result = await super().think()
|
||||
|
||||
# Restore original prompt
|
||||
|
||||
+4
-4
@@ -90,11 +90,11 @@ class MCPAgent(ToolCallAgent):
|
||||
Returns:
|
||||
A tuple of (added_tools, removed_tools)
|
||||
"""
|
||||
if not self.mcp_clients.session:
|
||||
if not self.mcp_clients.sessions:
|
||||
return [], []
|
||||
|
||||
# Get current tool schemas directly from the server
|
||||
response = await self.mcp_clients.session.list_tools()
|
||||
response = await self.mcp_clients.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_clients.session or not self.mcp_clients.tool_map:
|
||||
if not self.mcp_clients.sessions or not self.mcp_clients.tool_map:
|
||||
logger.info("MCP service is no longer available, ending interaction")
|
||||
self.state = AgentState.FINISHED
|
||||
return False
|
||||
@@ -171,7 +171,7 @@ class MCPAgent(ToolCallAgent):
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up MCP connection when done."""
|
||||
if self.mcp_clients.session:
|
||||
if self.mcp_clients.sessions:
|
||||
await self.mcp_clients.disconnect()
|
||||
logger.info("MCP connection closed")
|
||||
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from app.agent.toolcall import ToolCallAgent
|
||||
from app.logger import logger
|
||||
from app.prompt.planning import NEXT_STEP_PROMPT, PLANNING_SYSTEM_PROMPT
|
||||
from app.schema import TOOL_CHOICE_TYPE, Message, ToolCall, ToolChoice
|
||||
from app.tool import PlanningTool, Terminate, ToolCollection
|
||||
|
||||
|
||||
class PlanningAgent(ToolCallAgent):
|
||||
"""
|
||||
An agent that creates and manages plans to solve tasks.
|
||||
|
||||
This agent uses a planning tool to create and manage structured plans,
|
||||
and tracks progress through individual steps until task completion.
|
||||
"""
|
||||
|
||||
name: str = "planning"
|
||||
description: str = "An agent that creates and manages plans to solve tasks"
|
||||
|
||||
system_prompt: str = PLANNING_SYSTEM_PROMPT
|
||||
next_step_prompt: str = NEXT_STEP_PROMPT
|
||||
|
||||
available_tools: ToolCollection = Field(
|
||||
default_factory=lambda: ToolCollection(PlanningTool(), Terminate())
|
||||
)
|
||||
tool_choices: TOOL_CHOICE_TYPE = ToolChoice.AUTO # type: ignore
|
||||
special_tool_names: List[str] = Field(default_factory=lambda: [Terminate().name])
|
||||
|
||||
tool_calls: List[ToolCall] = Field(default_factory=list)
|
||||
active_plan_id: Optional[str] = Field(default=None)
|
||||
|
||||
# Add a dictionary to track the step status for each tool call
|
||||
step_execution_tracker: Dict[str, Dict] = Field(default_factory=dict)
|
||||
current_step_index: Optional[int] = None
|
||||
|
||||
max_steps: int = 20
|
||||
|
||||
@model_validator(mode="after")
|
||||
def initialize_plan_and_verify_tools(self) -> "PlanningAgent":
|
||||
"""Initialize the agent with a default plan ID and validate required tools."""
|
||||
self.active_plan_id = f"plan_{int(time.time())}"
|
||||
|
||||
if "planning" not in self.available_tools.tool_map:
|
||||
self.available_tools.add_tool(PlanningTool())
|
||||
|
||||
return self
|
||||
|
||||
async def think(self) -> bool:
|
||||
"""Decide the next action based on plan status."""
|
||||
prompt = (
|
||||
f"CURRENT PLAN STATUS:\n{await self.get_plan()}\n\n{self.next_step_prompt}"
|
||||
if self.active_plan_id
|
||||
else self.next_step_prompt
|
||||
)
|
||||
self.messages.append(Message.user_message(prompt))
|
||||
|
||||
# Get the current step index before thinking
|
||||
self.current_step_index = await self._get_current_step_index()
|
||||
|
||||
result = await super().think()
|
||||
|
||||
# After thinking, if we decided to execute a tool and it's not a planning tool or special tool,
|
||||
# associate it with the current step for tracking
|
||||
if result and self.tool_calls:
|
||||
latest_tool_call = self.tool_calls[0] # Get the most recent tool call
|
||||
if (
|
||||
latest_tool_call.function.name != "planning"
|
||||
and latest_tool_call.function.name not in self.special_tool_names
|
||||
and self.current_step_index is not None
|
||||
):
|
||||
self.step_execution_tracker[latest_tool_call.id] = {
|
||||
"step_index": self.current_step_index,
|
||||
"tool_name": latest_tool_call.function.name,
|
||||
"status": "pending", # Will be updated after execution
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def act(self) -> str:
|
||||
"""Execute a step and track its completion status."""
|
||||
result = await super().act()
|
||||
|
||||
# After executing the tool, update the plan status
|
||||
if self.tool_calls:
|
||||
latest_tool_call = self.tool_calls[0]
|
||||
|
||||
# Update the execution status to completed
|
||||
if latest_tool_call.id in self.step_execution_tracker:
|
||||
self.step_execution_tracker[latest_tool_call.id]["status"] = "completed"
|
||||
self.step_execution_tracker[latest_tool_call.id]["result"] = result
|
||||
|
||||
# Update the plan status if this was a non-planning, non-special tool
|
||||
if (
|
||||
latest_tool_call.function.name != "planning"
|
||||
and latest_tool_call.function.name not in self.special_tool_names
|
||||
):
|
||||
await self.update_plan_status(latest_tool_call.id)
|
||||
|
||||
return result
|
||||
|
||||
async def get_plan(self) -> str:
|
||||
"""Retrieve the current plan status."""
|
||||
if not self.active_plan_id:
|
||||
return "No active plan. Please create a plan first."
|
||||
|
||||
result = await self.available_tools.execute(
|
||||
name="planning",
|
||||
tool_input={"command": "get", "plan_id": self.active_plan_id},
|
||||
)
|
||||
return result.output if hasattr(result, "output") else str(result)
|
||||
|
||||
async def run(self, request: Optional[str] = None) -> str:
|
||||
"""Run the agent with an optional initial request."""
|
||||
if request:
|
||||
await self.create_initial_plan(request)
|
||||
return await super().run()
|
||||
|
||||
async def update_plan_status(self, tool_call_id: str) -> None:
|
||||
"""
|
||||
Update the current plan progress based on completed tool execution.
|
||||
Only marks a step as completed if the associated tool has been successfully executed.
|
||||
"""
|
||||
if not self.active_plan_id:
|
||||
return
|
||||
|
||||
if tool_call_id not in self.step_execution_tracker:
|
||||
logger.warning(f"No step tracking found for tool call {tool_call_id}")
|
||||
return
|
||||
|
||||
tracker = self.step_execution_tracker[tool_call_id]
|
||||
if tracker["status"] != "completed":
|
||||
logger.warning(f"Tool call {tool_call_id} has not completed successfully")
|
||||
return
|
||||
|
||||
step_index = tracker["step_index"]
|
||||
|
||||
try:
|
||||
# Mark the step as completed
|
||||
await self.available_tools.execute(
|
||||
name="planning",
|
||||
tool_input={
|
||||
"command": "mark_step",
|
||||
"plan_id": self.active_plan_id,
|
||||
"step_index": step_index,
|
||||
"step_status": "completed",
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
f"Marked step {step_index} as completed in plan {self.active_plan_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update plan status: {e}")
|
||||
|
||||
async def _get_current_step_index(self) -> Optional[int]:
|
||||
"""
|
||||
Parse the current plan to identify the first non-completed step's index.
|
||||
Returns None if no active step is found.
|
||||
"""
|
||||
if not self.active_plan_id:
|
||||
return None
|
||||
|
||||
plan = await self.get_plan()
|
||||
|
||||
try:
|
||||
plan_lines = plan.splitlines()
|
||||
steps_index = -1
|
||||
|
||||
# Find the index of the "Steps:" line
|
||||
for i, line in enumerate(plan_lines):
|
||||
if line.strip() == "Steps:":
|
||||
steps_index = i
|
||||
break
|
||||
|
||||
if steps_index == -1:
|
||||
return None
|
||||
|
||||
# Find the first non-completed step
|
||||
for i, line in enumerate(plan_lines[steps_index + 1 :], start=0):
|
||||
if "[ ]" in line or "[→]" in line: # not_started or in_progress
|
||||
# Mark current step as in_progress
|
||||
await self.available_tools.execute(
|
||||
name="planning",
|
||||
tool_input={
|
||||
"command": "mark_step",
|
||||
"plan_id": self.active_plan_id,
|
||||
"step_index": i,
|
||||
"step_status": "in_progress",
|
||||
},
|
||||
)
|
||||
return i
|
||||
|
||||
return None # No active step found
|
||||
except Exception as e:
|
||||
logger.warning(f"Error finding current step index: {e}")
|
||||
return None
|
||||
|
||||
async def create_initial_plan(self, request: str) -> None:
|
||||
"""Create an initial plan based on the request."""
|
||||
logger.info(f"Creating initial plan with ID: {self.active_plan_id}")
|
||||
|
||||
messages = [
|
||||
Message.user_message(
|
||||
f"Analyze the request and create a plan with ID {self.active_plan_id}: {request}"
|
||||
)
|
||||
]
|
||||
self.memory.add_messages(messages)
|
||||
response = await self.llm.ask_tool(
|
||||
messages=messages,
|
||||
system_msgs=[Message.system_message(self.system_prompt)],
|
||||
tools=self.available_tools.to_params(),
|
||||
tool_choice=ToolChoice.AUTO,
|
||||
)
|
||||
assistant_msg = Message.from_tool_calls(
|
||||
content=response.content, tool_calls=response.tool_calls
|
||||
)
|
||||
|
||||
self.memory.add_message(assistant_msg)
|
||||
|
||||
plan_created = False
|
||||
for tool_call in response.tool_calls:
|
||||
if tool_call.function.name == "planning":
|
||||
result = await self.execute_tool(tool_call)
|
||||
logger.info(
|
||||
f"Executed tool {tool_call.function.name} with result: {result}"
|
||||
)
|
||||
|
||||
# Add tool response to memory
|
||||
tool_msg = Message.tool_message(
|
||||
content=result,
|
||||
tool_call_id=tool_call.id,
|
||||
name=tool_call.function.name,
|
||||
)
|
||||
self.memory.add_message(tool_msg)
|
||||
plan_created = True
|
||||
break
|
||||
|
||||
if not plan_created:
|
||||
logger.warning("No plan created from initial request")
|
||||
tool_msg = Message.assistant_message(
|
||||
"Error: Parameter `plan_id` is required for command: create"
|
||||
)
|
||||
self.memory.add_message(tool_msg)
|
||||
|
||||
|
||||
async def main():
|
||||
# Configure and run the agent
|
||||
agent = PlanningAgent(available_tools=ToolCollection(PlanningTool(), Terminate()))
|
||||
result = await agent.run("Help me plan a trip to the moon")
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
asyncio.run(main())
|
||||
+3
-17
@@ -3,7 +3,7 @@ from typing import List
|
||||
from pydantic import Field
|
||||
|
||||
from app.agent.toolcall import ToolCallAgent
|
||||
from app.prompt.swe import NEXT_STEP_TEMPLATE, SYSTEM_PROMPT
|
||||
from app.prompt.swe import SYSTEM_PROMPT
|
||||
from app.tool import Bash, StrReplaceEditor, Terminate, ToolCollection
|
||||
|
||||
|
||||
@@ -14,25 +14,11 @@ class SWEAgent(ToolCallAgent):
|
||||
description: str = "an autonomous AI programmer that interacts directly with the computer to solve tasks."
|
||||
|
||||
system_prompt: str = SYSTEM_PROMPT
|
||||
next_step_prompt: str = NEXT_STEP_TEMPLATE
|
||||
next_step_prompt: str = ""
|
||||
|
||||
available_tools: ToolCollection = ToolCollection(
|
||||
Bash(), StrReplaceEditor(), Terminate()
|
||||
)
|
||||
special_tool_names: List[str] = Field(default_factory=lambda: [Terminate().name])
|
||||
|
||||
max_steps: int = 30
|
||||
|
||||
bash: Bash = Field(default_factory=Bash)
|
||||
working_dir: str = "."
|
||||
|
||||
async def think(self) -> bool:
|
||||
"""Process current state and decide next action"""
|
||||
# Update working directory
|
||||
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
|
||||
)
|
||||
|
||||
return await super().think()
|
||||
max_steps: int = 20
|
||||
|
||||
+24
-8
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
@@ -187,14 +188,6 @@ class ToolCallAgent(ReActAgent):
|
||||
# Store the base64_image for later use in tool_message
|
||||
self._current_base64_image = result.base64_image
|
||||
|
||||
# Format result for display
|
||||
observation = (
|
||||
f"Observed output of cmd `{name}` executed:\n{str(result)}"
|
||||
if result
|
||||
else f"Cmd `{name}` completed with no output"
|
||||
)
|
||||
return observation
|
||||
|
||||
# Format result for display (standard case)
|
||||
observation = (
|
||||
f"Observed output of cmd `{name}` executed:\n{str(result)}"
|
||||
@@ -232,3 +225,26 @@ class ToolCallAgent(ReActAgent):
|
||||
def _is_special_tool(self, name: str) -> bool:
|
||||
"""Check if tool name is in special tools list"""
|
||||
return name.lower() in [n.lower() for n in self.special_tool_names]
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up resources used by the agent's tools."""
|
||||
logger.info(f"🧹 Cleaning up resources for agent '{self.name}'...")
|
||||
for tool_name, tool_instance in self.available_tools.tool_map.items():
|
||||
if hasattr(tool_instance, "cleanup") and asyncio.iscoroutinefunction(
|
||||
tool_instance.cleanup
|
||||
):
|
||||
try:
|
||||
logger.debug(f"🧼 Cleaning up tool: {tool_name}")
|
||||
await tool_instance.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"🚨 Error cleaning up tool '{tool_name}': {e}", exc_info=True
|
||||
)
|
||||
logger.info(f"✨ Cleanup complete for agent '{self.name}'.")
|
||||
|
||||
async def run(self, request: Optional[str] = None) -> str:
|
||||
"""Run the agent with cleanup when done."""
|
||||
try:
|
||||
return await super().run(request)
|
||||
finally:
|
||||
await self.cleanup()
|
||||
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
|
||||
import boto3
|
||||
|
||||
|
||||
# Global variables to track the current tool use ID across function calls
|
||||
# Tmp solution
|
||||
CURRENT_TOOLUSE_ID = None
|
||||
|
||||
|
||||
# Class to handle OpenAI-style response formatting
|
||||
class OpenAIResponse:
|
||||
def __init__(self, data):
|
||||
# Recursively convert nested dicts and lists to OpenAIResponse objects
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
value = OpenAIResponse(value)
|
||||
elif isinstance(value, list):
|
||||
value = [
|
||||
OpenAIResponse(item) if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
setattr(self, key, value)
|
||||
|
||||
def model_dump(self, *args, **kwargs):
|
||||
# Convert object to dict and add timestamp
|
||||
data = self.__dict__
|
||||
data["created_at"] = datetime.now().isoformat()
|
||||
return data
|
||||
|
||||
|
||||
# Main client class for interacting with Amazon Bedrock
|
||||
class BedrockClient:
|
||||
def __init__(self):
|
||||
# Initialize Bedrock client, you need to configure AWS env first
|
||||
try:
|
||||
self.client = boto3.client("bedrock-runtime")
|
||||
self.chat = Chat(self.client)
|
||||
except Exception as e:
|
||||
print(f"Error initializing Bedrock client: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Chat interface class
|
||||
class Chat:
|
||||
def __init__(self, client):
|
||||
self.completions = ChatCompletions(client)
|
||||
|
||||
|
||||
# Core class handling chat completions functionality
|
||||
class ChatCompletions:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
|
||||
def _convert_openai_tools_to_bedrock_format(self, tools):
|
||||
# Convert OpenAI function calling format to Bedrock tool format
|
||||
bedrock_tools = []
|
||||
for tool in tools:
|
||||
if tool.get("type") == "function":
|
||||
function = tool.get("function", {})
|
||||
bedrock_tool = {
|
||||
"toolSpec": {
|
||||
"name": function.get("name", ""),
|
||||
"description": function.get("description", ""),
|
||||
"inputSchema": {
|
||||
"json": {
|
||||
"type": "object",
|
||||
"properties": function.get("parameters", {}).get(
|
||||
"properties", {}
|
||||
),
|
||||
"required": function.get("parameters", {}).get(
|
||||
"required", []
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
bedrock_tools.append(bedrock_tool)
|
||||
return bedrock_tools
|
||||
|
||||
def _convert_openai_messages_to_bedrock_format(self, messages):
|
||||
# Convert OpenAI message format to Bedrock message format
|
||||
bedrock_messages = []
|
||||
system_prompt = []
|
||||
for message in messages:
|
||||
if message.get("role") == "system":
|
||||
system_prompt = [{"text": message.get("content")}]
|
||||
elif message.get("role") == "user":
|
||||
bedrock_message = {
|
||||
"role": message.get("role", "user"),
|
||||
"content": [{"text": message.get("content")}],
|
||||
}
|
||||
bedrock_messages.append(bedrock_message)
|
||||
elif message.get("role") == "assistant":
|
||||
bedrock_message = {
|
||||
"role": "assistant",
|
||||
"content": [{"text": message.get("content")}],
|
||||
}
|
||||
openai_tool_calls = message.get("tool_calls", [])
|
||||
if openai_tool_calls:
|
||||
bedrock_tool_use = {
|
||||
"toolUseId": openai_tool_calls[0]["id"],
|
||||
"name": openai_tool_calls[0]["function"]["name"],
|
||||
"input": json.loads(
|
||||
openai_tool_calls[0]["function"]["arguments"]
|
||||
),
|
||||
}
|
||||
bedrock_message["content"].append({"toolUse": bedrock_tool_use})
|
||||
global CURRENT_TOOLUSE_ID
|
||||
CURRENT_TOOLUSE_ID = openai_tool_calls[0]["id"]
|
||||
bedrock_messages.append(bedrock_message)
|
||||
elif message.get("role") == "tool":
|
||||
bedrock_message = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"toolResult": {
|
||||
"toolUseId": CURRENT_TOOLUSE_ID,
|
||||
"content": [{"text": message.get("content")}],
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
bedrock_messages.append(bedrock_message)
|
||||
else:
|
||||
raise ValueError(f"Invalid role: {message.get('role')}")
|
||||
return system_prompt, bedrock_messages
|
||||
|
||||
def _convert_bedrock_response_to_openai_format(self, bedrock_response):
|
||||
# Convert Bedrock response format to OpenAI format
|
||||
content = ""
|
||||
if bedrock_response.get("output", {}).get("message", {}).get("content"):
|
||||
content_array = bedrock_response["output"]["message"]["content"]
|
||||
content = "".join(item.get("text", "") for item in content_array)
|
||||
if content == "":
|
||||
content = "."
|
||||
|
||||
# Handle tool calls in response
|
||||
openai_tool_calls = []
|
||||
if bedrock_response.get("output", {}).get("message", {}).get("content"):
|
||||
for content_item in bedrock_response["output"]["message"]["content"]:
|
||||
if content_item.get("toolUse"):
|
||||
bedrock_tool_use = content_item["toolUse"]
|
||||
global CURRENT_TOOLUSE_ID
|
||||
CURRENT_TOOLUSE_ID = bedrock_tool_use["toolUseId"]
|
||||
openai_tool_call = {
|
||||
"id": CURRENT_TOOLUSE_ID,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": bedrock_tool_use["name"],
|
||||
"arguments": json.dumps(bedrock_tool_use["input"]),
|
||||
},
|
||||
}
|
||||
openai_tool_calls.append(openai_tool_call)
|
||||
|
||||
# Construct final OpenAI format response
|
||||
openai_format = {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"created": int(time.time()),
|
||||
"object": "chat.completion",
|
||||
"system_fingerprint": None,
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": bedrock_response.get("stopReason", "end_turn"),
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": content,
|
||||
"role": bedrock_response.get("output", {})
|
||||
.get("message", {})
|
||||
.get("role", "assistant"),
|
||||
"tool_calls": openai_tool_calls
|
||||
if openai_tool_calls != []
|
||||
else None,
|
||||
"function_call": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"completion_tokens": bedrock_response.get("usage", {}).get(
|
||||
"outputTokens", 0
|
||||
),
|
||||
"prompt_tokens": bedrock_response.get("usage", {}).get(
|
||||
"inputTokens", 0
|
||||
),
|
||||
"total_tokens": bedrock_response.get("usage", {}).get("totalTokens", 0),
|
||||
},
|
||||
}
|
||||
return OpenAIResponse(openai_format)
|
||||
|
||||
async def _invoke_bedrock(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
tools: Optional[List[dict]] = None,
|
||||
tool_choice: Literal["none", "auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
) -> OpenAIResponse:
|
||||
# Non-streaming invocation of Bedrock model
|
||||
(
|
||||
system_prompt,
|
||||
bedrock_messages,
|
||||
) = self._convert_openai_messages_to_bedrock_format(messages)
|
||||
response = self.client.converse(
|
||||
modelId=model,
|
||||
system=system_prompt,
|
||||
messages=bedrock_messages,
|
||||
inferenceConfig={"temperature": temperature, "maxTokens": max_tokens},
|
||||
toolConfig={"tools": tools} if tools else None,
|
||||
)
|
||||
openai_response = self._convert_bedrock_response_to_openai_format(response)
|
||||
return openai_response
|
||||
|
||||
async def _invoke_bedrock_stream(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
tools: Optional[List[dict]] = None,
|
||||
tool_choice: Literal["none", "auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
) -> OpenAIResponse:
|
||||
# Streaming invocation of Bedrock model
|
||||
(
|
||||
system_prompt,
|
||||
bedrock_messages,
|
||||
) = self._convert_openai_messages_to_bedrock_format(messages)
|
||||
response = self.client.converse_stream(
|
||||
modelId=model,
|
||||
system=system_prompt,
|
||||
messages=bedrock_messages,
|
||||
inferenceConfig={"temperature": temperature, "maxTokens": max_tokens},
|
||||
toolConfig={"tools": tools} if tools else None,
|
||||
)
|
||||
|
||||
# Initialize response structure
|
||||
bedrock_response = {
|
||||
"output": {"message": {"role": "", "content": []}},
|
||||
"stopReason": "",
|
||||
"usage": {},
|
||||
"metrics": {},
|
||||
}
|
||||
bedrock_response_text = ""
|
||||
bedrock_response_tool_input = ""
|
||||
|
||||
# Process streaming response
|
||||
stream = response.get("stream")
|
||||
if stream:
|
||||
for event in stream:
|
||||
if event.get("messageStart", {}).get("role"):
|
||||
bedrock_response["output"]["message"]["role"] = event[
|
||||
"messageStart"
|
||||
]["role"]
|
||||
if event.get("contentBlockDelta", {}).get("delta", {}).get("text"):
|
||||
bedrock_response_text += event["contentBlockDelta"]["delta"]["text"]
|
||||
print(
|
||||
event["contentBlockDelta"]["delta"]["text"], end="", flush=True
|
||||
)
|
||||
if event.get("contentBlockStop", {}).get("contentBlockIndex") == 0:
|
||||
bedrock_response["output"]["message"]["content"].append(
|
||||
{"text": bedrock_response_text}
|
||||
)
|
||||
if event.get("contentBlockStart", {}).get("start", {}).get("toolUse"):
|
||||
bedrock_tool_use = event["contentBlockStart"]["start"]["toolUse"]
|
||||
tool_use = {
|
||||
"toolUseId": bedrock_tool_use["toolUseId"],
|
||||
"name": bedrock_tool_use["name"],
|
||||
}
|
||||
bedrock_response["output"]["message"]["content"].append(
|
||||
{"toolUse": tool_use}
|
||||
)
|
||||
global CURRENT_TOOLUSE_ID
|
||||
CURRENT_TOOLUSE_ID = bedrock_tool_use["toolUseId"]
|
||||
if event.get("contentBlockDelta", {}).get("delta", {}).get("toolUse"):
|
||||
bedrock_response_tool_input += event["contentBlockDelta"]["delta"][
|
||||
"toolUse"
|
||||
]["input"]
|
||||
print(
|
||||
event["contentBlockDelta"]["delta"]["toolUse"]["input"],
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
if event.get("contentBlockStop", {}).get("contentBlockIndex") == 1:
|
||||
bedrock_response["output"]["message"]["content"][1]["toolUse"][
|
||||
"input"
|
||||
] = json.loads(bedrock_response_tool_input)
|
||||
print()
|
||||
openai_response = self._convert_bedrock_response_to_openai_format(
|
||||
bedrock_response
|
||||
)
|
||||
return openai_response
|
||||
|
||||
def create(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
stream: Optional[bool] = True,
|
||||
tools: Optional[List[dict]] = None,
|
||||
tool_choice: Literal["none", "auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
) -> OpenAIResponse:
|
||||
# Main entry point for chat completion
|
||||
bedrock_tools = []
|
||||
if tools is not None:
|
||||
bedrock_tools = self._convert_openai_tools_to_bedrock_format(tools)
|
||||
if stream:
|
||||
return self._invoke_bedrock_stream(
|
||||
model,
|
||||
messages,
|
||||
max_tokens,
|
||||
temperature,
|
||||
bedrock_tools,
|
||||
tool_choice,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
return self._invoke_bedrock(
|
||||
model,
|
||||
messages,
|
||||
max_tokens,
|
||||
temperature,
|
||||
bedrock_tools,
|
||||
tool_choice,
|
||||
**kwargs,
|
||||
)
|
||||
+111
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import threading
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
@@ -37,6 +38,32 @@ class ProxySettings(BaseModel):
|
||||
|
||||
class SearchSettings(BaseModel):
|
||||
engine: str = Field(default="Google", description="Search engine the llm to use")
|
||||
fallback_engines: List[str] = Field(
|
||||
default_factory=lambda: ["DuckDuckGo", "Baidu", "Bing"],
|
||||
description="Fallback search engines to try if the primary engine fails",
|
||||
)
|
||||
retry_delay: int = Field(
|
||||
default=60,
|
||||
description="Seconds to wait before retrying all engines again after they all fail",
|
||||
)
|
||||
max_retries: int = Field(
|
||||
default=3,
|
||||
description="Maximum number of times to retry all engines when all fail",
|
||||
)
|
||||
lang: str = Field(
|
||||
default="en",
|
||||
description="Language code for search results (e.g., en, zh, fr)",
|
||||
)
|
||||
country: str = Field(
|
||||
default="us",
|
||||
description="Country code for search results (e.g., us, cn, uk)",
|
||||
)
|
||||
|
||||
|
||||
class RunflowSettings(BaseModel):
|
||||
use_data_analysis_agent: bool = Field(
|
||||
default=False, description="Enable data analysis agent in run flow"
|
||||
)
|
||||
|
||||
|
||||
class BrowserSettings(BaseModel):
|
||||
@@ -78,6 +105,53 @@ class SandboxSettings(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class MCPServerConfig(BaseModel):
|
||||
"""Configuration for a single MCP server"""
|
||||
|
||||
type: str = Field(..., description="Server connection type (sse or stdio)")
|
||||
url: Optional[str] = Field(None, description="Server URL for SSE connections")
|
||||
command: Optional[str] = Field(None, description="Command for stdio connections")
|
||||
args: List[str] = Field(
|
||||
default_factory=list, description="Arguments for stdio command"
|
||||
)
|
||||
|
||||
|
||||
class MCPSettings(BaseModel):
|
||||
"""Configuration for MCP (Model Context Protocol)"""
|
||||
|
||||
server_reference: str = Field(
|
||||
"app.mcp.server", description="Module reference for the MCP server"
|
||||
)
|
||||
servers: Dict[str, MCPServerConfig] = Field(
|
||||
default_factory=dict, description="MCP server configurations"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_server_config(cls) -> Dict[str, MCPServerConfig]:
|
||||
"""Load MCP server configuration from JSON file"""
|
||||
config_path = PROJECT_ROOT / "config" / "mcp.json"
|
||||
|
||||
try:
|
||||
config_file = config_path if config_path.exists() else None
|
||||
if not config_file:
|
||||
return {}
|
||||
|
||||
with config_file.open() as f:
|
||||
data = json.load(f)
|
||||
servers = {}
|
||||
|
||||
for server_id, server_config in data.get("mcpServers", {}).items():
|
||||
servers[server_id] = MCPServerConfig(
|
||||
type=server_config["type"],
|
||||
url=server_config.get("url"),
|
||||
command=server_config.get("command"),
|
||||
args=server_config.get("args", []),
|
||||
)
|
||||
return servers
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to load MCP server config: {e}")
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
llm: Dict[str, LLMSettings]
|
||||
sandbox: Optional[SandboxSettings] = Field(
|
||||
@@ -89,6 +163,11 @@ class AppConfig(BaseModel):
|
||||
search_config: Optional[SearchSettings] = Field(
|
||||
None, description="Search configuration"
|
||||
)
|
||||
mcp_config: Optional[MCPSettings] = Field(None, description="MCP configuration")
|
||||
run_flow_config: Optional[RunflowSettings] = Field(
|
||||
None, description="Run flow configuration"
|
||||
)
|
||||
cloud_sandbox: Optional[dict] = Field(None, description="Cloud sandbox configuration")
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
@@ -191,6 +270,21 @@ class Config:
|
||||
else:
|
||||
sandbox_settings = SandboxSettings()
|
||||
|
||||
mcp_config = raw_config.get("mcp", {})
|
||||
mcp_settings = None
|
||||
if mcp_config:
|
||||
# Load server configurations from JSON
|
||||
mcp_config["servers"] = MCPSettings.load_server_config()
|
||||
mcp_settings = MCPSettings(**mcp_config)
|
||||
else:
|
||||
mcp_settings = MCPSettings(servers=MCPSettings.load_server_config())
|
||||
|
||||
run_flow_config = raw_config.get("runflow")
|
||||
if run_flow_config:
|
||||
run_flow_settings = RunflowSettings(**run_flow_config)
|
||||
else:
|
||||
run_flow_settings = RunflowSettings()
|
||||
cloud_sandbox_config = raw_config.get("cloud_sandbox", {})
|
||||
config_dict = {
|
||||
"llm": {
|
||||
"default": default_settings,
|
||||
@@ -202,6 +296,9 @@ class Config:
|
||||
"sandbox": sandbox_settings,
|
||||
"browser_config": browser_settings,
|
||||
"search_config": search_settings,
|
||||
"mcp_config": mcp_settings,
|
||||
"run_flow_config": run_flow_settings,
|
||||
"cloud_sandbox": cloud_sandbox_config,
|
||||
}
|
||||
|
||||
self._config = AppConfig(**config_dict)
|
||||
@@ -222,6 +319,20 @@ class Config:
|
||||
def search_config(self) -> Optional[SearchSettings]:
|
||||
return self._config.search_config
|
||||
|
||||
@property
|
||||
def mcp_config(self) -> MCPSettings:
|
||||
"""Get the MCP configuration"""
|
||||
return self._config.mcp_config
|
||||
|
||||
@property
|
||||
def run_flow_config(self) -> RunflowSettings:
|
||||
"""Get the Run Flow configuration"""
|
||||
return self._config.run_flow_config
|
||||
|
||||
@property
|
||||
def cloud_sandbox(self) -> dict:
|
||||
return getattr(self._config, "cloud_sandbox", {})
|
||||
|
||||
@property
|
||||
def workspace_root(self) -> Path:
|
||||
"""Get the workspace root directory"""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -7,10 +6,6 @@ from pydantic import BaseModel
|
||||
from app.agent.base import BaseAgent
|
||||
|
||||
|
||||
class FlowType(str, Enum):
|
||||
PLANNING = "planning"
|
||||
|
||||
|
||||
class BaseFlow(BaseModel, ABC):
|
||||
"""Base class for execution flows supporting multiple agents"""
|
||||
|
||||
@@ -60,32 +55,3 @@ class BaseFlow(BaseModel, ABC):
|
||||
@abstractmethod
|
||||
async def execute(self, input_text: str) -> str:
|
||||
"""Execute the flow with given input"""
|
||||
|
||||
|
||||
class PlanStepStatus(str, Enum):
|
||||
"""Enum class defining possible statuses of a plan step"""
|
||||
|
||||
NOT_STARTED = "not_started"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
@classmethod
|
||||
def get_all_statuses(cls) -> list[str]:
|
||||
"""Return a list of all possible step status values"""
|
||||
return [status.value for status in cls]
|
||||
|
||||
@classmethod
|
||||
def get_active_statuses(cls) -> list[str]:
|
||||
"""Return a list of values representing active statuses (not started or in progress)"""
|
||||
return [cls.NOT_STARTED.value, cls.IN_PROGRESS.value]
|
||||
|
||||
@classmethod
|
||||
def get_status_marks(cls) -> Dict[str, str]:
|
||||
"""Return a mapping of statuses to their marker symbols"""
|
||||
return {
|
||||
cls.COMPLETED.value: "[✓]",
|
||||
cls.IN_PROGRESS.value: "[→]",
|
||||
cls.BLOCKED.value: "[!]",
|
||||
cls.NOT_STARTED.value: "[ ]",
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Union
|
||||
|
||||
from app.agent.base import BaseAgent
|
||||
from app.flow.base import BaseFlow, FlowType
|
||||
from app.flow.base import BaseFlow
|
||||
from app.flow.planning import PlanningFlow
|
||||
|
||||
|
||||
class FlowType(str, Enum):
|
||||
PLANNING = "planning"
|
||||
|
||||
|
||||
class FlowFactory:
|
||||
"""Factory for creating different types of flows with support for multiple agents"""
|
||||
|
||||
|
||||
+52
-4
@@ -1,17 +1,47 @@
|
||||
import json
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.agent.base import BaseAgent
|
||||
from app.flow.base import BaseFlow, PlanStepStatus
|
||||
from app.flow.base import BaseFlow
|
||||
from app.llm import LLM
|
||||
from app.logger import logger
|
||||
from app.schema import AgentState, Message, ToolChoice
|
||||
from app.tool import PlanningTool
|
||||
|
||||
|
||||
class PlanStepStatus(str, Enum):
|
||||
"""Enum class defining possible statuses of a plan step"""
|
||||
|
||||
NOT_STARTED = "not_started"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
@classmethod
|
||||
def get_all_statuses(cls) -> list[str]:
|
||||
"""Return a list of all possible step status values"""
|
||||
return [status.value for status in cls]
|
||||
|
||||
@classmethod
|
||||
def get_active_statuses(cls) -> list[str]:
|
||||
"""Return a list of values representing active statuses (not started or in progress)"""
|
||||
return [cls.NOT_STARTED.value, cls.IN_PROGRESS.value]
|
||||
|
||||
@classmethod
|
||||
def get_status_marks(cls) -> Dict[str, str]:
|
||||
"""Return a mapping of statuses to their marker symbols"""
|
||||
return {
|
||||
cls.COMPLETED.value: "[✓]",
|
||||
cls.IN_PROGRESS.value: "[→]",
|
||||
cls.BLOCKED.value: "[!]",
|
||||
cls.NOT_STARTED.value: "[ ]",
|
||||
}
|
||||
|
||||
|
||||
class PlanningFlow(BaseFlow):
|
||||
"""A flow that manages planning and execution of tasks using agents."""
|
||||
|
||||
@@ -107,12 +137,30 @@ class PlanningFlow(BaseFlow):
|
||||
"""Create an initial plan based on the request using the flow's LLM and PlanningTool."""
|
||||
logger.info(f"Creating initial plan with ID: {self.active_plan_id}")
|
||||
|
||||
# Create a system message for plan creation
|
||||
system_message = Message.system_message(
|
||||
system_message_content = (
|
||||
"You are a planning assistant. Create a concise, actionable plan with clear steps. "
|
||||
"Focus on key milestones rather than detailed sub-steps. "
|
||||
"Optimize for clarity and efficiency."
|
||||
)
|
||||
agents_description = []
|
||||
for key in self.executor_keys:
|
||||
if key in self.agents:
|
||||
agents_description.append(
|
||||
{
|
||||
"name": key.upper(),
|
||||
"description": self.agents[key].description,
|
||||
}
|
||||
)
|
||||
if len(agents_description) > 1:
|
||||
# Add description of agents to select
|
||||
system_message_content += (
|
||||
f"\nNow we have {agents_description} agents. "
|
||||
f"The infomation of them are below: {json.dumps(agents_description)}\n"
|
||||
"When creating steps in the planning tool, please specify the agent names using the format '[agent_name]'."
|
||||
)
|
||||
|
||||
# Create a system message for plan creation
|
||||
system_message = Message.system_message(system_message_content)
|
||||
|
||||
# Create a user message with the request
|
||||
user_message = Message.user_message(
|
||||
@@ -240,7 +288,7 @@ class PlanningFlow(BaseFlow):
|
||||
YOUR CURRENT TASK:
|
||||
You are now working on step {self.current_step_index}: "{step_text}"
|
||||
|
||||
Please execute this step using the appropriate tools. When you're done, provide a summary of what you accomplished.
|
||||
Please only execute this current step using the appropriate tools. When you're done, provide a summary of what you accomplished.
|
||||
"""
|
||||
|
||||
# Use agent.run() to execute the step
|
||||
|
||||
+9
-12
@@ -10,7 +10,7 @@ from openai import (
|
||||
OpenAIError,
|
||||
RateLimitError,
|
||||
)
|
||||
from openai.types.chat.chat_completion_message import ChatCompletionMessage
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from tenacity import (
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
@@ -18,6 +18,7 @@ from tenacity import (
|
||||
wait_random_exponential,
|
||||
)
|
||||
|
||||
from app.bedrock import BedrockClient
|
||||
from app.config import LLMSettings, config
|
||||
from app.exceptions import TokenLimitExceeded
|
||||
from app.logger import logger # Assuming a logger is set up in your app
|
||||
@@ -87,16 +88,9 @@ class TokenCounter:
|
||||
width, height = image_item["dimensions"]
|
||||
return self._calculate_high_detail_tokens(width, height)
|
||||
|
||||
# Default values when dimensions aren't available or detail level is unknown
|
||||
if detail == "high":
|
||||
# Default to a 1024x1024 image calculation for high detail
|
||||
return self._calculate_high_detail_tokens(1024, 1024) # 765 tokens
|
||||
elif detail == "medium":
|
||||
# Default to a medium-sized image for medium detail
|
||||
return 1024 # This matches the original default
|
||||
else:
|
||||
# For unknown detail levels, use medium as default
|
||||
return 1024
|
||||
return (
|
||||
self._calculate_high_detail_tokens(1024, 1024) if detail == "high" else 1024
|
||||
)
|
||||
|
||||
def _calculate_high_detail_tokens(self, width: int, height: int) -> int:
|
||||
"""Calculate tokens for high detail images based on dimensions"""
|
||||
@@ -225,6 +219,8 @@ class LLM:
|
||||
api_key=self.api_key,
|
||||
api_version=self.api_version,
|
||||
)
|
||||
elif self.api_type == "aws":
|
||||
self.client = BedrockClient()
|
||||
else:
|
||||
self.client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
|
||||
@@ -732,8 +728,9 @@ class LLM:
|
||||
temperature if temperature is not None else self.temperature
|
||||
)
|
||||
|
||||
params["stream"] = False # Always use non-streaming for tool requests
|
||||
response: ChatCompletion = await self.client.chat.completions.create(
|
||||
**params, stream=False
|
||||
**params
|
||||
)
|
||||
|
||||
# Check if response is valid
|
||||
|
||||
+7
-23
@@ -1,30 +1,19 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO, handlers=[logging.StreamHandler(sys.stderr)])
|
||||
|
||||
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.logger import logger
|
||||
from app.tool.base import BaseTool
|
||||
from app.tool.bash import Bash
|
||||
from app.tool.browser_use_tool import BrowserUseTool
|
||||
@@ -45,11 +34,6 @@ class MCPServer:
|
||||
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
|
||||
|
||||
@@ -89,4 +89,6 @@ For browser interactions:
|
||||
|
||||
Consider both what's visible and what might be beyond the current viewport.
|
||||
Be methodical - remember your progress and what you've learned so far.
|
||||
|
||||
If you want to stop the interaction at any point, use the `terminate` tool/function call.
|
||||
"""
|
||||
|
||||
+3
-1
@@ -1,8 +1,10 @@
|
||||
SYSTEM_PROMPT = (
|
||||
"You are OpenManus, an all-capable AI assistant, aimed at solving any task presented by the user. You have various tools at your disposal that you can call upon to efficiently complete complex requests. Whether it's programming, information retrieval, file processing, or web browsing, you can handle it all."
|
||||
"You are OpenManus, an all-capable AI assistant, aimed at solving any task presented by the user. You have various tools at your disposal that you can call upon to efficiently complete complex requests. Whether it's programming, information retrieval, file processing, web browsing, or human interaction (only for extreme cases), you can handle it all."
|
||||
"The initial directory is: {directory}"
|
||||
)
|
||||
|
||||
NEXT_STEP_PROMPT = """
|
||||
Based on user needs, proactively select the most appropriate tool or combination of tools. For complex tasks, you can break down the problem and use different tools step by step to solve it. After using each tool, clearly explain the execution results and suggest the next steps.
|
||||
|
||||
If you want to stop the interaction at any point, use the `terminate` tool/function call.
|
||||
"""
|
||||
|
||||
@@ -20,9 +20,3 @@ Remember, you should always include a _SINGLE_ tool call/function call and then
|
||||
If you'd like to issue two commands at once, PLEASE DO NOT DO THAT! Please instead first submit just the first tool call, and then after receiving a response you'll be able to issue the second tool call.
|
||||
Note that the environment does NOT support interactive session commands (e.g. python, vim), so please do not invoke them.
|
||||
"""
|
||||
|
||||
NEXT_STEP_TEMPLATE = """{{observation}}
|
||||
(Open file: {{open_file}})
|
||||
(Current directory: {{working_dir}})
|
||||
bash-$
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
SYSTEM_PROMPT = """You are an AI agent designed to data analysis / visualization task. You have various tools at your disposal that you can call upon to efficiently complete complex requests.
|
||||
# Note:
|
||||
1. The workspace directory is: {directory}; Read / write file in workspace
|
||||
2. Generate analysis conclusion report in the end"""
|
||||
|
||||
NEXT_STEP_PROMPT = """Based on user needs, break down the problem and use different tools step by step to solve it.
|
||||
# Note
|
||||
1. Each step select the most appropriate tool proactively (ONLY ONE).
|
||||
2. After using each tool, clearly explain the execution results and suggest the next steps.
|
||||
3. When observation with Error, review and fix it."""
|
||||
@@ -170,6 +170,9 @@ class Memory(BaseModel):
|
||||
def add_messages(self, messages: List[Message]) -> None:
|
||||
"""Add multiple messages to memory"""
|
||||
self.messages.extend(messages)
|
||||
# Optional: Implement message limit
|
||||
if len(self.messages) > self.max_messages:
|
||||
self.messages = self.messages[-self.max_messages :]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all messages"""
|
||||
|
||||
@@ -6,6 +6,8 @@ from app.tool.planning import PlanningTool
|
||||
from app.tool.str_replace_editor import StrReplaceEditor
|
||||
from app.tool.terminate import Terminate
|
||||
from app.tool.tool_collection import ToolCollection
|
||||
from app.tool.web_search import WebSearch
|
||||
from app.tool.linux_sandbox import LinuxSandboxTool
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -14,7 +16,9 @@ __all__ = [
|
||||
"BrowserUseTool",
|
||||
"Terminate",
|
||||
"StrReplaceEditor",
|
||||
"WebSearch",
|
||||
"ToolCollection",
|
||||
"CreateChatCompletion",
|
||||
"PlanningTool",
|
||||
"LinuxSandboxTool",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from app.tool import BaseTool
|
||||
|
||||
|
||||
class AskHuman(BaseTool):
|
||||
"""Add a tool to ask human for help."""
|
||||
|
||||
name: str = "ask_human"
|
||||
description: str = "Use this tool to ask human for help."
|
||||
parameters: str = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inquire": {
|
||||
"type": "string",
|
||||
"description": "The question you want to ask human.",
|
||||
}
|
||||
},
|
||||
"required": ["inquire"],
|
||||
}
|
||||
|
||||
async def execute(self, inquire: str) -> str:
|
||||
return input(f"""Bot: {inquire}\n\nYou: """).strip()
|
||||
+75
-129
@@ -16,34 +16,21 @@ from app.tool.base import BaseTool, ToolResult
|
||||
from app.tool.web_search import WebSearch
|
||||
|
||||
|
||||
_BROWSER_DESCRIPTION = """
|
||||
Interact with a web browser to perform various actions such as navigation, element interaction, content extraction, and tab management. This tool provides a comprehensive set of browser automation capabilities:
|
||||
_BROWSER_DESCRIPTION = """\
|
||||
A powerful browser automation tool that allows interaction with web pages through various actions.
|
||||
* This tool provides commands for controlling a browser session, navigating web pages, and extracting information
|
||||
* It maintains state across calls, keeping the browser session alive until explicitly closed
|
||||
* Use this when you need to browse websites, fill forms, click buttons, extract content, or perform web searches
|
||||
* Each action requires specific parameters as defined in the tool's dependencies
|
||||
|
||||
Navigation:
|
||||
- 'go_to_url': Go to a specific URL in the current tab
|
||||
- 'go_back': Go back
|
||||
- 'refresh': Refresh the current page
|
||||
- 'web_search': Search the query in the current tab, the query should be a search query like humans search in web, concrete and not vague or super long. More the single most important items.
|
||||
Key capabilities include:
|
||||
* Navigation: Go to specific URLs, go back, search the web, or refresh pages
|
||||
* Interaction: Click elements, input text, select from dropdowns, send keyboard commands
|
||||
* Scrolling: Scroll up/down by pixel amount or scroll to specific text
|
||||
* Content extraction: Extract and analyze content from web pages based on specific goals
|
||||
* Tab management: Switch between tabs, open new tabs, or close tabs
|
||||
|
||||
Element Interaction:
|
||||
- 'click_element': Click an element by index
|
||||
- 'input_text': Input text into a form element
|
||||
- 'scroll_down'/'scroll_up': Scroll the page (with optional pixel amount)
|
||||
- 'scroll_to_text': If you dont find something which you want to interact with, scroll to it
|
||||
- 'send_keys': Send strings of special keys like Escape,Backspace, Insert, PageDown, Delete, Enter, Shortcuts such as `Control+o`, `Control+Shift+T` are supported as well. This gets used in keyboard.press.
|
||||
- 'get_dropdown_options': Get all options from a dropdown
|
||||
- 'select_dropdown_option': Select dropdown option for interactive element index by the text of the option you want to select
|
||||
|
||||
Content Extraction:
|
||||
- 'extract_content': Extract page content to retrieve specific information from the page, e.g. all company names, a specifc description, all information about, links with companies in structured format or simply links
|
||||
|
||||
Tab Management:
|
||||
- 'switch_tab': Switch to a specific tab
|
||||
- 'open_tab': Open a new tab with a URL
|
||||
- 'close_tab': Close the current tab
|
||||
|
||||
Utility:
|
||||
- 'wait': Wait for a specified number of seconds
|
||||
Note: When using element indices, refer to the numbered elements shown in the current browser state.
|
||||
"""
|
||||
|
||||
Context = TypeVar("Context")
|
||||
@@ -266,32 +253,19 @@ class BrowserUseTool(BaseTool, Generic[Context]):
|
||||
return ToolResult(
|
||||
error="Query is required for 'web_search' action"
|
||||
)
|
||||
search_results = await self.web_search_tool.execute(query)
|
||||
# Execute the web search and return results directly without browser navigation
|
||||
search_response = await self.web_search_tool.execute(
|
||||
query=query, fetch_content=True, num_results=1
|
||||
)
|
||||
# Navigate to the first search result
|
||||
first_search_result = search_response.results[0]
|
||||
url_to_navigate = first_search_result.url
|
||||
|
||||
if search_results:
|
||||
# Navigate to the first search result
|
||||
first_result = search_results[0]
|
||||
if isinstance(first_result, dict) and "url" in first_result:
|
||||
url_to_navigate = first_result["url"]
|
||||
elif isinstance(first_result, str):
|
||||
url_to_navigate = first_result
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"Invalid search result format: {first_result}"
|
||||
)
|
||||
page = await context.get_current_page()
|
||||
await page.goto(url_to_navigate)
|
||||
await page.wait_for_load_state()
|
||||
|
||||
page = await context.get_current_page()
|
||||
await page.goto(url_to_navigate)
|
||||
await page.wait_for_load_state()
|
||||
|
||||
return ToolResult(
|
||||
output=f"Searched for '{query}' and navigated to first result: {url_to_navigate}\nAll results:"
|
||||
+ "\n".join([str(r) for r in search_results])
|
||||
)
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"No search results found for '{query}'"
|
||||
)
|
||||
return search_response
|
||||
|
||||
# Element interaction actions
|
||||
elif action == "click_element":
|
||||
@@ -403,99 +377,71 @@ class BrowserUseTool(BaseTool, Generic[Context]):
|
||||
return ToolResult(
|
||||
error="Goal is required for 'extract_content' action"
|
||||
)
|
||||
|
||||
page = await context.get_current_page()
|
||||
try:
|
||||
# Get page content and convert to markdown for better processing
|
||||
html_content = await page.content()
|
||||
import markdownify
|
||||
|
||||
# Import markdownify here to avoid global import
|
||||
try:
|
||||
import markdownify
|
||||
content = markdownify.markdownify(await page.content())
|
||||
|
||||
content = markdownify.markdownify(html_content)
|
||||
except ImportError:
|
||||
# Fallback if markdownify is not available
|
||||
content = html_content
|
||||
|
||||
# Create prompt for LLM
|
||||
prompt_text = """
|
||||
prompt = f"""\
|
||||
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:
|
||||
{page}
|
||||
{content[:max_content_length]}
|
||||
"""
|
||||
# Format the prompt with the goal and content
|
||||
max_content_length = min(50000, len(content))
|
||||
formatted_prompt = prompt_text.format(
|
||||
goal=goal, page=content[:max_content_length]
|
||||
)
|
||||
messages = [{"role": "system", "content": prompt}]
|
||||
|
||||
# Create a proper message list for the LLM
|
||||
from app.schema import Message
|
||||
|
||||
messages = [Message.user_message(formatted_prompt)]
|
||||
|
||||
# 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"],
|
||||
# Define extraction function schema
|
||||
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",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text content extracted from the page",
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Additional metadata about the extracted content",
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Source of the extracted content",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"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",
|
||||
# Use LLM to extract content with required function calling
|
||||
response = await self.llm.ask_tool(
|
||||
messages,
|
||||
tools=[extraction_function],
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
if response and response.tool_calls:
|
||||
args = json.loads(response.tool_calls[0].function.arguments)
|
||||
extracted_content = args.get("extracted_content", {})
|
||||
return ToolResult(
|
||||
output=f"Extracted from page:\n{extracted_content}\n"
|
||||
)
|
||||
|
||||
# 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."
|
||||
|
||||
return ToolResult(output=msg)
|
||||
except Exception as e:
|
||||
# Provide a more helpful error message
|
||||
error_msg = f"Failed to extract content: {str(e)}"
|
||||
try:
|
||||
# Try to return a portion of the page content as fallback
|
||||
return ToolResult(
|
||||
output=f"{error_msg}\nHere's a portion of the page content:\n{content[:2000]}..."
|
||||
)
|
||||
except:
|
||||
# If all else fails, just return the error
|
||||
return ToolResult(error=error_msg)
|
||||
return ToolResult(output="No content was extracted from the page.")
|
||||
|
||||
# Tab management actions
|
||||
elif action == "switch_tab":
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
|
||||
|
||||
# Chart Visualization Tool
|
||||
|
||||
The chart visualization tool generates data processing code through Python and ultimately invokes [@visactor/vmind](https://github.com/VisActor/VMind) to obtain chart specifications. Chart rendering is implemented using [@visactor/vchart](https://github.com/VisActor/VChart).
|
||||
|
||||
## Installation (Mac / Linux)
|
||||
|
||||
1. Install node >= 18
|
||||
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
# Activate nvm, for example in Bash
|
||||
source ~/.bashrc
|
||||
# Then install the latest stable release of Node
|
||||
nvm install node
|
||||
# Activate usage, for example if the latest stable release is 22, then use 22
|
||||
nvm use 22
|
||||
```
|
||||
|
||||
2. Install dependencies
|
||||
|
||||
```bash
|
||||
# Navigate to the appropriate location in the current repository
|
||||
cd app/tool/chart_visualization
|
||||
npm install
|
||||
```
|
||||
|
||||
## Installation (Windows)
|
||||
1. Install nvm-windows
|
||||
|
||||
Download the latest version `nvm-setup.exe` from the [official GitHub page](https://github.com/coreybutler/nvm-windows?tab=readme-ov-file#readme) and install it.
|
||||
|
||||
2. Use nvm to install node
|
||||
|
||||
```powershell
|
||||
# Then install the latest stable release of Node
|
||||
nvm install node
|
||||
# Activate usage, for example if the latest stable release is 22, then use 22
|
||||
nvm use 22
|
||||
```
|
||||
|
||||
3. Install dependencies
|
||||
|
||||
```bash
|
||||
# Navigate to the appropriate location in the current repository
|
||||
cd app/tool/chart_visualization
|
||||
npm install
|
||||
```
|
||||
|
||||
## Tool
|
||||
### python_execute
|
||||
|
||||
Execute the necessary parts of data analysis (excluding data visualization) using Python code, including data processing, data summary, report generation, and some general Python script code.
|
||||
|
||||
#### Input
|
||||
```typescript
|
||||
{
|
||||
// Code type: data processing/data report/other general tasks
|
||||
code_type: "process" | "report" | "others"
|
||||
// Final execution code
|
||||
code: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### Output
|
||||
Python execution results, including the saving of intermediate files and print output results.
|
||||
|
||||
### visualization_preparation
|
||||
|
||||
A pre-tool for data visualization with two purposes,
|
||||
|
||||
#### Data -> Chart
|
||||
Used to extract the data needed for analysis (.csv) and the corresponding visualization description from the data, ultimately outputting a JSON configuration file.
|
||||
|
||||
#### Chart + Insight -> Chart
|
||||
Select existing charts and corresponding data insights, choose data insights to add to the chart in the form of data annotations, and finally generate a JSON configuration file.
|
||||
|
||||
#### Input
|
||||
```typescript
|
||||
{
|
||||
// Code type: data visualization or data insight addition
|
||||
code_type: "visualization" | "insight"
|
||||
// Python code used to produce the final JSON file
|
||||
code: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### Output
|
||||
A configuration file for data visualization, used for the `data_visualization tool`.
|
||||
|
||||
## data_visualization
|
||||
|
||||
Generate specific data visualizations based on the content of `visualization_preparation`.
|
||||
|
||||
### Input
|
||||
```typescript
|
||||
{
|
||||
// Configuration file path
|
||||
json_path: string;
|
||||
// Current purpose, data visualization or insight annotation addition
|
||||
tool_type: "visualization" | "insight";
|
||||
// Final product png or html; html supports vchart rendering and interaction
|
||||
output_type: 'png' | 'html'
|
||||
// Language, currently supports Chinese and English
|
||||
language: "zh" | "en"
|
||||
}
|
||||
```
|
||||
|
||||
## VMind Configuration
|
||||
|
||||
### LLM
|
||||
|
||||
VMind requires LLM invocation for intelligent chart generation. By default, it uses the `config.llm["default"]` configuration.
|
||||
|
||||
### Generation Settings
|
||||
|
||||
Main configurations include chart dimensions, theme, and generation method:
|
||||
### Generation Method
|
||||
Default: png. Currently supports automatic selection of `output_type` by LLM based on context.
|
||||
|
||||
### Dimensions
|
||||
Default dimensions are unspecified. For HTML output, charts fill the entire page by default. For PNG output, defaults to `1000*1000`.
|
||||
|
||||
### Theme
|
||||
Default theme: `'light'`. VChart supports multiple themes. See [Themes](https://www.visactor.io/vchart/guide/tutorial_docs/Theme/Theme_Extension).
|
||||
|
||||
## Test
|
||||
|
||||
Currently, three tasks of different difficulty levels are set for testing.
|
||||
|
||||
### Simple Chart Generation Task
|
||||
|
||||
Provide data and specific chart generation requirements, test results, execute the command:
|
||||
```bash
|
||||
python -m app.tool.chart_visualization.test.chart_demo
|
||||
```
|
||||
The results should be located under `workspace\visualization`, involving 9 different chart results.
|
||||
|
||||
### Simple Data Report Task
|
||||
|
||||
Provide simple raw data analysis requirements, requiring simple processing of the data, execute the command:
|
||||
```bash
|
||||
python -m app.tool.chart_visualization.test.report_demo
|
||||
```
|
||||
The results are also located under `workspace\visualization`.
|
||||
@@ -0,0 +1,114 @@
|
||||
# グラフ可視化ツール
|
||||
|
||||
グラフ可視化ツールは、Pythonを使用してデータ処理コードを生成し、最終的に[@visactor/vmind](https://github.com/VisActor/VMind)を呼び出してグラフのspec結果を得ます。グラフのレンダリングには[@visactor/vchart](https://github.com/VisActor/VChart)を使用します。
|
||||
|
||||
## インストール (Mac / Linux)
|
||||
|
||||
1. Node >= 18をインストール
|
||||
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
# nvmを有効化、例としてBashを使用
|
||||
source ~/.bashrc
|
||||
# その後、最新の安定版Nodeをインストール
|
||||
nvm install node
|
||||
# 使用を有効化、例えば最新の安定版が22の場合、use 22
|
||||
nvm use 22
|
||||
```
|
||||
|
||||
2. 依存関係をインストール
|
||||
|
||||
```bash
|
||||
cd app/tool/chart_visualization
|
||||
npm install
|
||||
```
|
||||
|
||||
## インストール (Windows)
|
||||
1. nvm-windowsをインストール
|
||||
|
||||
[GitHub公式サイト](https://github.com/coreybutler/nvm-windows?tab=readme-ov-file#readme)から最新バージョンの`nvm-setup.exe`をダウンロードしてインストール
|
||||
|
||||
2. nvmを使用してNodeをインストール
|
||||
|
||||
```powershell
|
||||
# その後、最新の安定版Nodeをインストール
|
||||
nvm install node
|
||||
# 使用を有効化、例えば最新の安定版が22の場合、use 22
|
||||
nvm use 22
|
||||
```
|
||||
|
||||
3. 依存関係をインストール
|
||||
|
||||
```bash
|
||||
# 現在のリポジトリで適切な位置に移動
|
||||
cd app/tool/chart_visualization
|
||||
npm install
|
||||
```
|
||||
|
||||
## ツール
|
||||
### python_execute
|
||||
|
||||
Pythonコードを使用してデータ分析(データ可視化を除く)に必要な部分を実行します。これにはデータ処理、データ要約、レポート生成、および一般的なPythonスクリプトコードが含まれます。
|
||||
|
||||
#### 入力
|
||||
```typescript
|
||||
{
|
||||
// コードタイプ:データ処理/データレポート/その他の一般的なタスク
|
||||
code_type: "process" | "report" | "others"
|
||||
// 最終実行コード
|
||||
code: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 出力
|
||||
Python実行結果、中間ファイルの保存とprint出力結果を含む
|
||||
|
||||
### visualization_preparation
|
||||
|
||||
データ可視化の準備ツールで、2つの用途があります。
|
||||
|
||||
#### Data -> Chart
|
||||
データから分析に必要なデータ(.csv)と対応する可視化の説明を抽出し、最終的にJSON設定ファイルを出力します。
|
||||
|
||||
#### Chart + Insight -> Chart
|
||||
既存のグラフと対応するデータインサイトを選択し、データインサイトをデータ注釈の形式でグラフに追加し、最終的にJSON設定ファイルを生成します。
|
||||
|
||||
#### 入力
|
||||
```typescript
|
||||
{
|
||||
// コードタイプ:データ可視化またはデータインサイト追加
|
||||
code_type: "visualization" | "insight"
|
||||
// 最終的なJSONファイルを生成するためのPythonコード
|
||||
code: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 出力
|
||||
データ可視化の設定ファイル、`data_visualization tool`で使用
|
||||
|
||||
## data_visualization
|
||||
|
||||
`visualization_preparation`の内容に基づいて具体的なデータ可視化を生成
|
||||
|
||||
### 入力
|
||||
```typescript
|
||||
{
|
||||
// 設定ファイルのパス
|
||||
json_path: string;
|
||||
// 現在の用途、データ可視化またはインサイト注釈追加
|
||||
tool_type: "visualization" | "insight";
|
||||
// 最終成果物pngまたはhtml;htmlではvchartのレンダリングとインタラクションをサポート
|
||||
output_type: 'png' | 'html'
|
||||
// 言語、現在は中国語と英語をサポート
|
||||
language: "zh" | "en"
|
||||
}
|
||||
```
|
||||
|
||||
## 出力
|
||||
最終的に'png'または'html'の形式でローカルに保存され、保存されたグラフのパスとグラフ内で発見されたデータインサイトを出力
|
||||
|
||||
## VMind設定
|
||||
|
||||
### LLM
|
||||
|
||||
VMind自体
|
||||
@@ -0,0 +1,128 @@
|
||||
# 차트 시각화 도구
|
||||
|
||||
차트 시각화 도구는 Python을 통해 데이터 처리 코드를 생성하고, 최종적으로 [@visactor/vmind](https://github.com/VisActor/VMind)를 호출하여 차트 사양을 얻습니다. 차트 렌더링은 [@visactor/vchart](https://github.com/VisActor/VChart)를 사용하여 구현됩니다.
|
||||
|
||||
## 설치 (Mac / Linux)
|
||||
|
||||
1. Node.js 18 이상 설치
|
||||
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
# nvm 활성화, 예를 들어 Bash
|
||||
source ~/.bashrc
|
||||
# 그런 다음 최신 안정 버전의 Node 설치
|
||||
nvm install node
|
||||
# 사용 활성화, 예를 들어 최신 안정 버전이 22인 경우 use 22
|
||||
nvm use 22
|
||||
```
|
||||
|
||||
2. 의존성 설치
|
||||
|
||||
```bash
|
||||
# 현재 저장소에서 해당 위치로 이동
|
||||
cd app/tool/chart_visualization
|
||||
npm install
|
||||
```
|
||||
|
||||
## 설치 (Windows)
|
||||
1. nvm-windows 설치
|
||||
|
||||
[공식 GitHub 페이지](https://github.com/coreybutler/nvm-windows?tab=readme-ov-file#readme)에서 최신 버전의 `nvm-setup.exe`를 다운로드하고 설치합니다.
|
||||
|
||||
2. nvm을 사용하여 Node.js 설치
|
||||
|
||||
```powershell
|
||||
# 그런 다음 최신 안정 버전의 Node 설치
|
||||
nvm install node
|
||||
# 사용 활성화, 예를 들어 최신 안정 버전이 22인 경우 use 22
|
||||
nvm use 22
|
||||
```
|
||||
|
||||
3. 의존성 설치
|
||||
|
||||
```bash
|
||||
# 현재 저장소에서 해당 위치로 이동
|
||||
cd app/tool/chart_visualization
|
||||
npm install
|
||||
```
|
||||
|
||||
## 도구
|
||||
### python_execute
|
||||
|
||||
Python 코드를 사용하여 데이터 분석의 필요한 부분(데이터 시각화 제외)을 실행합니다. 여기에는 데이터 처리, 데이터 요약, 보고서 생성 및 일부 일반적인 Python 스크립트 코드가 포함됩니다.
|
||||
|
||||
#### 입력
|
||||
```typescript
|
||||
{
|
||||
// 코드 유형: 데이터 처리/데이터 보고서/기타 일반 작업
|
||||
code_type: "process" | "report" | "others"
|
||||
// 최종 실행 코드
|
||||
code: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 출력
|
||||
Python 실행 결과, 중간 파일 저장 및 출력 결과 포함.
|
||||
|
||||
### visualization_preparation
|
||||
|
||||
데이터 시각화를 위한 사전 도구로 두 가지 목적이 있습니다.
|
||||
|
||||
#### 데이터 -> 차트
|
||||
분석에 필요한 데이터(.csv)와 해당 시각화 설명을 데이터에서 추출하여 최종적으로 JSON 구성 파일을 출력합니다.
|
||||
|
||||
#### 차트 + 인사이트 -> 차트
|
||||
기존 차트와 해당 데이터 인사이트를 선택하고, 데이터 주석 형태로 차트에 추가할 데이터 인사이트를 선택하여 최종적으로 JSON 구성 파일을 생성합니다.
|
||||
|
||||
#### 입력
|
||||
```typescript
|
||||
{
|
||||
// 코드 유형: 데이터 시각화 또는 데이터 인사이트 추가
|
||||
code_type: "visualization" | "insight"
|
||||
// 최종 JSON 파일을 생성하는 데 사용되는 Python 코드
|
||||
code: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 출력
|
||||
`data_visualization tool`에 사용되는 데이터 시각화를 위한 구성 파일.
|
||||
|
||||
## data_visualization
|
||||
|
||||
`visualization_preparation`의 내용을 기반으로 특정 데이터 시각화를 생성합니다.
|
||||
|
||||
### 입력
|
||||
```typescript
|
||||
{
|
||||
// 구성 파일 경로
|
||||
json_path: string;
|
||||
// 현재 목적, 데이터 시각화 또는 인사이트 주석 추가
|
||||
tool_type: "visualization" | "insight";
|
||||
// 최종 제품 png 또는 html; html은 vchart 렌더링 및 상호작용 지원
|
||||
output_type: 'png' | 'html'
|
||||
// 언어, 현재 중국어 및 영어 지원
|
||||
language: "zh" | "en"
|
||||
}
|
||||
```
|
||||
|
||||
## VMind 구성
|
||||
|
||||
### LLM
|
||||
|
||||
VMind는 지능형 차트 생성을 위해 LLM 호출이 필요합니다. 기본적으로 `config.llm["default"]` 구성을 사용합니다.
|
||||
|
||||
### 생성 설정
|
||||
|
||||
주요 구성에는 차트 크기, 테마 및 생성 방법이 포함됩니다.
|
||||
### 생성 방법
|
||||
기본값: png. 현재 LLM이 컨텍스트에 따라 `output_type`을 자동으로 선택하는 것을 지원합니다.
|
||||
|
||||
### 크기
|
||||
기본 크기는 지정되지 않았습니다. HTML 출력의 경우 차트는 기본적으로 전체 페이지를 채웁니다. PNG 출력의 경우 기본값은 `1000*1000`입니다.
|
||||
|
||||
### 테마
|
||||
기본 테마: `'light'`. VChart는 여러 테마를 지원합니다. [테마](https://www.visactor.io/vchart/guide/tutorial_docs/Theme/Theme_Extension)를 참조하세요.
|
||||
|
||||
## 테스트
|
||||
|
||||
현재, 서로 다른 난이도의
|
||||
@@ -0,0 +1,147 @@
|
||||
# 图表可视化工具
|
||||
|
||||
图表可视化工具,通过python生成数据处理代码,最终调用[@visactor/vmind](https://github.com/VisActor/VMind)得到图表的spec结果,图表渲染使用[@visactor/vchart](https://github.com/VisActor/VChart)
|
||||
|
||||
## 安装(Mac / Linux)
|
||||
|
||||
1. 安装node >= 18
|
||||
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
# 激活nvm,以Bash为例
|
||||
source ~/.bashrc
|
||||
# 然后安装 Node 最近一个稳定颁布
|
||||
nvm install node
|
||||
# 激活使用,例如最新一个稳定颁布为22,则use 22
|
||||
nvm use 22
|
||||
```
|
||||
|
||||
2. 安装依赖
|
||||
|
||||
```bash
|
||||
cd app/tool/chart_visualization
|
||||
npm install
|
||||
```
|
||||
|
||||
## 安装(Windows)
|
||||
1. 安装nvm-windows
|
||||
|
||||
从[github官网](https://github.com/coreybutler/nvm-windows?tab=readme-ov-file#readme)上下载最新版本`nvm-setup.exe`并且安装
|
||||
|
||||
2. 使用nvm安装node
|
||||
|
||||
```powershell
|
||||
# 然后安装 Node 最近一个稳定颁布
|
||||
nvm install node
|
||||
# 激活使用,例如最新一个稳定颁布为22,则use 22
|
||||
nvm use 22
|
||||
```
|
||||
|
||||
3. 安装依赖
|
||||
|
||||
```bash
|
||||
# 在当前仓库下定位到相应位置
|
||||
cd app/tool/chart_visualization
|
||||
npm install
|
||||
```
|
||||
## Tool
|
||||
### python_execute
|
||||
|
||||
用python代码执行数据分析(除数据可视化以外)中需要的部分,包括数据处理,数据总结摘要,报告生成以及一些通用python脚本代码
|
||||
|
||||
#### 输入
|
||||
```typescript
|
||||
{
|
||||
// 代码类型:数据处理/数据报告/其他通用任务
|
||||
code_type: "process" | "report" | "others"
|
||||
// 最终执行代码
|
||||
code: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 输出
|
||||
python执行结果,带有中间文件的保存和print输出结果
|
||||
|
||||
### visualization_preparation
|
||||
|
||||
数据可视化前置工具,有两种用途,
|
||||
|
||||
#### Data -〉 Chart
|
||||
用于从数据中提取需要分析的数据(.csv)和对应可视化的描述,最终输出一份json配置文件。
|
||||
|
||||
#### Chart + Insight -> Chart
|
||||
选取已有的图表和对应的数据洞察,挑选数据洞察以数据标注的形式增加到图表中,最终生成一份json配置文件。
|
||||
|
||||
#### 输入
|
||||
```typescript
|
||||
{
|
||||
// 代码类型:数据可视化 或者 数据洞察添加
|
||||
code_type: "visualization" | "insight"
|
||||
// 用于生产最终json文件的python代码
|
||||
code: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 输出
|
||||
数据可视化的配置文件,用于`data_visualization tool`
|
||||
|
||||
|
||||
## data_visualization
|
||||
|
||||
根据`visualization_preparation`的内容,生成具体的数据可视化
|
||||
|
||||
### 输入
|
||||
```typescript
|
||||
{
|
||||
// 配置文件路径
|
||||
json_path: string;
|
||||
// 当前用途,数据可视化或者洞察标注添加
|
||||
tool_type: "visualization" | "insight";
|
||||
// 最终产物png或者html;html下支持vchart渲染和交互
|
||||
output_type: 'png' | 'html'
|
||||
// 语言,目前支持中文和英文
|
||||
language: "zh" | "en"
|
||||
}
|
||||
```
|
||||
|
||||
## 输出
|
||||
最终以'png'或者'html'的形式保存在本地,输出保存的图表路径以及图表中发现的数据洞察
|
||||
|
||||
## VMind配置
|
||||
|
||||
### LLM
|
||||
|
||||
VMind本身也需要通过调用大模型得到智能图表生成结果,目前默认会使用`config.llm["default"]`配置
|
||||
|
||||
### 生成配置
|
||||
|
||||
主要生成配置包括图表的宽高、主题以及生成方式;
|
||||
### 生成方式
|
||||
默认为png,目前支持大模型根据上下文自己选择`output_type`
|
||||
|
||||
### 宽高
|
||||
目前默认不指定宽高,`html`下默认占满整个页面,'png'下默认为`1000 * 1000`
|
||||
|
||||
### 主题
|
||||
目前默认主题为`'light'`,VChart图表支持多种主题,详见[主题](https://www.visactor.io/vchart/guide/tutorial_docs/Theme/Theme_Extension)
|
||||
|
||||
|
||||
## 测试
|
||||
|
||||
当前设置了三种不同难度的任务用于测试
|
||||
|
||||
### 简单图表生成任务
|
||||
|
||||
给予数据和具体的图表生成需求,测试结果,执行命令:
|
||||
```bash
|
||||
python -m app.tool.chart_visualization.test.chart_demo
|
||||
```
|
||||
结果应位于`worksapce\visualization`下,涉及到9种不同的图表结果
|
||||
|
||||
### 简单数据报表任务
|
||||
|
||||
给予简单原始数据可分析需求,需要对数据进行简单加工处理,执行命令:
|
||||
```bash
|
||||
python -m app.tool.chart_visualization.test.report_demo
|
||||
```
|
||||
结果同样位于`worksapce\visualization`下
|
||||
@@ -0,0 +1,6 @@
|
||||
from app.tool.chart_visualization.chart_prepare import VisualizationPrepare
|
||||
from app.tool.chart_visualization.data_visualization import DataVisualization
|
||||
from app.tool.chart_visualization.python_execute import NormalPythonExecute
|
||||
|
||||
|
||||
__all__ = ["DataVisualization", "VisualizationPrepare", "NormalPythonExecute"]
|
||||
@@ -0,0 +1,38 @@
|
||||
from app.tool.chart_visualization.python_execute import NormalPythonExecute
|
||||
|
||||
|
||||
class VisualizationPrepare(NormalPythonExecute):
|
||||
"""A tool for Chart Generation Preparation"""
|
||||
|
||||
name: str = "visualization_preparation"
|
||||
description: str = "Using Python code to generates metadata of data_visualization tool. Outputs: 1) JSON Information. 2) Cleaned CSV data files (Optional)."
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code_type": {
|
||||
"description": "code type, visualization: csv -> chart; insight: choose insight into chart",
|
||||
"type": "string",
|
||||
"default": "visualization",
|
||||
"enum": ["visualization", "insight"],
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": """Python code for data_visualization prepare.
|
||||
## Visualization Type
|
||||
1. Data loading logic
|
||||
2. Csv Data and chart description generate
|
||||
2.1 Csv data (The data you want to visulazation, cleaning / transform from origin data, saved in .csv)
|
||||
2.2 Chart description of csv data (The chart title or description should be concise and clear. Examples: 'Product sales distribution', 'Monthly revenue trend'.)
|
||||
3. Save information in json file.( format: {"csvFilePath": string, "chartTitle": string}[])
|
||||
## Insight Type
|
||||
1. Select the insights from the data_visualization results that you want to add to the chart.
|
||||
2. Save information in json file.( format: {"chartPath": string, "insights_id": number[]}[])
|
||||
# Note
|
||||
1. You can generate one or multiple csv data with different visualization needs.
|
||||
2. Make each chart data esay, clean and different.
|
||||
3. Json file saving in utf-8 with path print: print(json_path)
|
||||
""",
|
||||
},
|
||||
},
|
||||
"required": ["code", "code_type"],
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Hashable
|
||||
|
||||
import pandas as pd
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from app.config import config
|
||||
from app.llm import LLM
|
||||
from app.logger import logger
|
||||
from app.tool.base import BaseTool
|
||||
|
||||
|
||||
class DataVisualization(BaseTool):
|
||||
name: str = "data_visualization"
|
||||
description: str = """Visualize statistical chart or Add insights in chart with JSON info from visualization_preparation tool. You can do steps as follows:
|
||||
1. Visualize statistical chart
|
||||
2. Choose insights into chart based on step 1 (Optional)
|
||||
Outputs:
|
||||
1. Charts (png/html)
|
||||
2. Charts Insights (.md)(Optional)"""
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"json_path": {
|
||||
"type": "string",
|
||||
"description": """file path of json info with ".json" in the end""",
|
||||
},
|
||||
"output_type": {
|
||||
"description": "Rendering format (html=interactive)",
|
||||
"type": "string",
|
||||
"default": "html",
|
||||
"enum": ["png", "html"],
|
||||
},
|
||||
"tool_type": {
|
||||
"description": "visualize chart or add insights",
|
||||
"type": "string",
|
||||
"default": "visualization",
|
||||
"enum": ["visualization", "insight"],
|
||||
},
|
||||
"language": {
|
||||
"description": "english(en) / chinese(zh)",
|
||||
"type": "string",
|
||||
"default": "en",
|
||||
"enum": ["zh", "en"],
|
||||
},
|
||||
},
|
||||
"required": ["code"],
|
||||
}
|
||||
llm: LLM = Field(default_factory=LLM, description="Language model instance")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def initialize_llm(self):
|
||||
"""Initialize llm with default settings if not provided."""
|
||||
if self.llm is None or not isinstance(self.llm, LLM):
|
||||
self.llm = LLM(config_name=self.name.lower())
|
||||
return self
|
||||
|
||||
def get_file_path(
|
||||
self,
|
||||
json_info: list[dict[str, str]],
|
||||
path_str: str,
|
||||
directory: str = None,
|
||||
) -> list[str]:
|
||||
res = []
|
||||
for item in json_info:
|
||||
if os.path.exists(item[path_str]):
|
||||
res.append(item[path_str])
|
||||
elif os.path.exists(
|
||||
os.path.join(f"{directory or config.workspace_root}", item[path_str])
|
||||
):
|
||||
res.append(
|
||||
os.path.join(
|
||||
f"{directory or config.workspace_root}", item[path_str]
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise Exception(f"No such file or directory: {item[path_str]}")
|
||||
return res
|
||||
|
||||
def success_output_template(self, result: list[dict[str, str]]) -> str:
|
||||
content = ""
|
||||
if len(result) == 0:
|
||||
return "Is EMPTY!"
|
||||
for item in result:
|
||||
content += f"""## {item['title']}\nChart saved in: {item['chart_path']}"""
|
||||
if "insight_path" in item and item["insight_path"] and "insight_md" in item:
|
||||
content += "\n" + item["insight_md"]
|
||||
else:
|
||||
content += "\n"
|
||||
return f"Chart Generated Successful!\n{content}"
|
||||
|
||||
async def data_visualization(
|
||||
self, json_info: list[dict[str, str]], output_type: str, language: str
|
||||
) -> str:
|
||||
data_list = []
|
||||
csv_file_path = self.get_file_path(json_info, "csvFilePath")
|
||||
for index, item in enumerate(json_info):
|
||||
df = pd.read_csv(csv_file_path[index], encoding="utf-8")
|
||||
df = df.astype(object)
|
||||
df = df.where(pd.notnull(df), None)
|
||||
data_dict_list = df.to_json(orient="records", force_ascii=False)
|
||||
|
||||
data_list.append(
|
||||
{
|
||||
"file_name": os.path.basename(csv_file_path[index]).replace(
|
||||
".csv", ""
|
||||
),
|
||||
"dict_data": data_dict_list,
|
||||
"chartTitle": item["chartTitle"],
|
||||
}
|
||||
)
|
||||
tasks = [
|
||||
self.invoke_vmind(
|
||||
dict_data=item["dict_data"],
|
||||
chart_description=item["chartTitle"],
|
||||
file_name=item["file_name"],
|
||||
output_type=output_type,
|
||||
task_type="visualization",
|
||||
language=language,
|
||||
)
|
||||
for item in data_list
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
error_list = []
|
||||
success_list = []
|
||||
for index, result in enumerate(results):
|
||||
csv_path = csv_file_path[index]
|
||||
if "error" in result and "chart_path" not in result:
|
||||
error_list.append(f"Error in {csv_path}: {result['error']}")
|
||||
else:
|
||||
success_list.append(
|
||||
{
|
||||
**result,
|
||||
"title": json_info[index]["chartTitle"],
|
||||
}
|
||||
)
|
||||
if len(error_list) > 0:
|
||||
return {
|
||||
"observation": f"# Error chart generated{'\n'.join(error_list)}\n{self.success_output_template(success_list)}",
|
||||
"success": False,
|
||||
}
|
||||
else:
|
||||
return {"observation": f"{self.success_output_template(success_list)}"}
|
||||
|
||||
async def add_insighs(
|
||||
self, json_info: list[dict[str, str]], output_type: str
|
||||
) -> str:
|
||||
data_list = []
|
||||
chart_file_path = self.get_file_path(
|
||||
json_info, "chartPath", os.path.join(config.workspace_root, "visualization")
|
||||
)
|
||||
for index, item in enumerate(json_info):
|
||||
if "insights_id" in item:
|
||||
data_list.append(
|
||||
{
|
||||
"file_name": os.path.basename(chart_file_path[index]).replace(
|
||||
f".{output_type}", ""
|
||||
),
|
||||
"insights_id": item["insights_id"],
|
||||
}
|
||||
)
|
||||
tasks = [
|
||||
self.invoke_vmind(
|
||||
insights_id=item["insights_id"],
|
||||
file_name=item["file_name"],
|
||||
output_type=output_type,
|
||||
task_type="insight",
|
||||
)
|
||||
for item in data_list
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
error_list = []
|
||||
success_list = []
|
||||
for index, result in enumerate(results):
|
||||
chart_path = chart_file_path[index]
|
||||
if "error" in result and "chart_path" not in result:
|
||||
error_list.append(f"Error in {chart_path}: {result['error']}")
|
||||
else:
|
||||
success_list.append(chart_path)
|
||||
success_template = (
|
||||
f"# Charts Update with Insights\n{','.join(success_list)}"
|
||||
if len(success_list) > 0
|
||||
else ""
|
||||
)
|
||||
if len(error_list) > 0:
|
||||
return {
|
||||
"observation": f"# Error in chart insights:{'\n'.join(error_list)}\n{success_template}",
|
||||
"success": False,
|
||||
}
|
||||
else:
|
||||
return {"observation": f"{success_template}"}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
json_path: str,
|
||||
output_type: str | None = "html",
|
||||
tool_type: str | None = "visualization",
|
||||
language: str | None = "en",
|
||||
) -> str:
|
||||
try:
|
||||
logger.info(f"📈 data_visualization with {json_path} in: {tool_type} ")
|
||||
with open(json_path, "r", encoding="utf-8") as file:
|
||||
json_info = json.load(file)
|
||||
if tool_type == "visualization":
|
||||
return await self.data_visualization(json_info, output_type, language)
|
||||
else:
|
||||
return await self.add_insighs(json_info, output_type)
|
||||
except Exception as e:
|
||||
return {
|
||||
"observation": f"Error: {e}",
|
||||
"success": False,
|
||||
}
|
||||
|
||||
async def invoke_vmind(
|
||||
self,
|
||||
file_name: str,
|
||||
output_type: str,
|
||||
task_type: str,
|
||||
insights_id: list[str] = None,
|
||||
dict_data: list[dict[Hashable, Any]] = None,
|
||||
chart_description: str = None,
|
||||
language: str = "en",
|
||||
):
|
||||
llm_config = {
|
||||
"base_url": self.llm.base_url,
|
||||
"model": self.llm.model,
|
||||
"api_key": self.llm.api_key,
|
||||
}
|
||||
vmind_params = {
|
||||
"llm_config": llm_config,
|
||||
"user_prompt": chart_description,
|
||||
"dataset": dict_data,
|
||||
"file_name": file_name,
|
||||
"output_type": output_type,
|
||||
"insights_id": insights_id,
|
||||
"task_type": task_type,
|
||||
"directory": str(config.workspace_root),
|
||||
"language": language,
|
||||
}
|
||||
# build async sub process
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"npx",
|
||||
"ts-node",
|
||||
"src/chartVisualize.ts",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=os.path.dirname(__file__),
|
||||
)
|
||||
input_json = json.dumps(vmind_params, ensure_ascii=False).encode("utf-8")
|
||||
try:
|
||||
stdout, stderr = await process.communicate(input_json)
|
||||
stdout_str = stdout.decode("utf-8")
|
||||
stderr_str = stderr.decode("utf-8")
|
||||
if process.returncode == 0:
|
||||
return json.loads(stdout_str)
|
||||
else:
|
||||
return {"error": f"Node.js Error: {stderr_str}"}
|
||||
except Exception as e:
|
||||
return {"error": f"Subprocess Error: {str(e)}"}
|
||||
+8739
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "chart_visualization",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@visactor/vchart": "^1.13.7",
|
||||
"@visactor/vmind": "2.0.5",
|
||||
"get-stdin": "^9.0.0",
|
||||
"puppeteer": "^24.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": ""
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
from app.config import config
|
||||
from app.tool.python_execute import PythonExecute
|
||||
|
||||
|
||||
class NormalPythonExecute(PythonExecute):
|
||||
"""A tool for executing Python code with timeout and safety restrictions."""
|
||||
|
||||
name: str = "python_execute"
|
||||
description: str = """Execute Python code for in-depth data analysis / data report(task conclusion) / other normal task without direct visualization."""
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code_type": {
|
||||
"description": "code type, data process / data report / others",
|
||||
"type": "string",
|
||||
"default": "process",
|
||||
"enum": ["process", "report", "others"],
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": """Python code to execute.
|
||||
# Note
|
||||
1. The code should generate a comprehensive text-based report containing dataset overview, column details, basic statistics, derived metrics, timeseries comparisons, outliers, and key insights.
|
||||
2. Use print() for all outputs so the analysis (including sections like 'Dataset Overview' or 'Preprocessing Results') is clearly visible and save it also
|
||||
3. Save any report / processed files / each analysis result in worksapce directory: {directory}
|
||||
4. Data reports need to be content-rich, including your overall analysis process and corresponding data visualization.
|
||||
5. You can invode this tool step-by-step to do data analysis from summary to in-depth with data report saved also""".format(
|
||||
directory=config.workspace_root
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["code"],
|
||||
}
|
||||
|
||||
async def execute(self, code: str, code_type: str | None = None, timeout=5):
|
||||
return await super().execute(code, timeout)
|
||||
@@ -0,0 +1,372 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import puppeteer from "puppeteer";
|
||||
import VMind, { ChartType, DataTable } from "@visactor/vmind";
|
||||
import { isString } from "@visactor/vutils";
|
||||
|
||||
enum AlgorithmType {
|
||||
OverallTrending = "overallTrend",
|
||||
AbnormalTrend = "abnormalTrend",
|
||||
PearsonCorrelation = "pearsonCorrelation",
|
||||
SpearmanCorrelation = "spearmanCorrelation",
|
||||
ExtremeValue = "extremeValue",
|
||||
MajorityValue = "majorityValue",
|
||||
StatisticsAbnormal = "statisticsAbnormal",
|
||||
StatisticsBase = "statisticsBase",
|
||||
DbscanOutlier = "dbscanOutlier",
|
||||
LOFOutlier = "lofOutlier",
|
||||
TurningPoint = "turningPoint",
|
||||
PageHinkley = "pageHinkley",
|
||||
DifferenceOutlier = "differenceOutlier",
|
||||
Volatility = "volatility",
|
||||
}
|
||||
|
||||
const getBase64 = async (spec: any, width?: number, height?: number) => {
|
||||
spec.animation = false;
|
||||
width && (spec.width = width);
|
||||
height && (spec.height = height);
|
||||
const browser = await puppeteer.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(getHtmlVChart(spec, width, height));
|
||||
|
||||
const dataUrl = await page.evaluate(() => {
|
||||
const canvas: any = document
|
||||
.getElementById("chart-container")
|
||||
?.querySelector("canvas");
|
||||
return canvas?.toDataURL("image/png");
|
||||
});
|
||||
|
||||
const base64Data = dataUrl.replace(/^data:image\/png;base64,/, "");
|
||||
await browser.close();
|
||||
return Buffer.from(base64Data, "base64");
|
||||
};
|
||||
|
||||
const serializeSpec = (spec: any) => {
|
||||
return JSON.stringify(spec, (key, value) => {
|
||||
if (typeof value === "function") {
|
||||
const funcStr = value
|
||||
.toString()
|
||||
.replace(/(\r\n|\n|\r)/gm, "")
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
return `__FUNCTION__${funcStr}`;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
function getHtmlVChart(spec: any, width?: number, height?: number) {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>VChart Demo</title>
|
||||
<script src="https://unpkg.com/@visactor/vchart/build/index.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chart-container" style="width: ${
|
||||
width ? `${width}px` : "100%"
|
||||
}; height: ${height ? `${height}px` : "100%"};"></div>
|
||||
<script>
|
||||
// parse spec with function
|
||||
function parseSpec(stringSpec) {
|
||||
return JSON.parse(stringSpec, (k, v) => {
|
||||
if (typeof v === 'string' && v.startsWith('__FUNCTION__')) {
|
||||
const funcBody = v.slice(12); // 移除标记
|
||||
try {
|
||||
return new Function('return (' + funcBody + ')')();
|
||||
} catch(e) {
|
||||
console.error('函数解析失败:', e);
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
const spec = parseSpec(\`${serializeSpec(spec)}\`);
|
||||
const chart = new VChart.VChart(spec, {
|
||||
dom: 'chart-container'
|
||||
});
|
||||
chart.renderSync();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* get file path saved string
|
||||
* @param isUpdate {boolean} default: false, update existed file when is true
|
||||
*/
|
||||
function getSavedPathName(
|
||||
directory: string,
|
||||
fileName: string,
|
||||
outputType: "html" | "png" | "json" | "md",
|
||||
isUpdate: boolean = false
|
||||
) {
|
||||
let newFileName = fileName;
|
||||
while (
|
||||
!isUpdate &&
|
||||
fs.existsSync(
|
||||
path.join(directory, "visualization", `${newFileName}.${outputType}`)
|
||||
)
|
||||
) {
|
||||
newFileName += "_new";
|
||||
}
|
||||
return path.join(directory, "visualization", `${newFileName}.${outputType}`);
|
||||
}
|
||||
|
||||
const readStdin = (): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf-8"); // 确保编码与 Python 端一致
|
||||
process.stdin.on("data", (chunk) => (input += chunk));
|
||||
process.stdin.on("end", () => resolve(input));
|
||||
});
|
||||
};
|
||||
|
||||
/** Save insights markdown in local, and return content && path */
|
||||
const setInsightTemplate = (
|
||||
path: string,
|
||||
title: string,
|
||||
insights: string[]
|
||||
) => {
|
||||
let res = "";
|
||||
if (insights.length) {
|
||||
res += `## ${title} Insights`;
|
||||
insights.forEach((insight, index) => {
|
||||
res += `\n${index + 1}. ${insight}`;
|
||||
});
|
||||
}
|
||||
if (res) {
|
||||
fs.writeFileSync(path, res, "utf-8");
|
||||
return { insight_path: path, insight_md: res };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
/** Save vmind result into local file, Return chart file path */
|
||||
async function saveChartRes(options: {
|
||||
spec: any;
|
||||
directory: string;
|
||||
outputType: "png" | "html";
|
||||
fileName: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isUpdate?: boolean;
|
||||
}) {
|
||||
const { directory, fileName, spec, outputType, width, height, isUpdate } =
|
||||
options;
|
||||
const specPath = getSavedPathName(directory, fileName, "json", isUpdate);
|
||||
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
||||
const savedPath = getSavedPathName(directory, fileName, outputType, isUpdate);
|
||||
if (outputType === "png") {
|
||||
const base64 = await getBase64(spec, width, height);
|
||||
fs.writeFileSync(savedPath, base64);
|
||||
} else {
|
||||
const html = getHtmlVChart(spec, width, height);
|
||||
fs.writeFileSync(savedPath, html, "utf-8");
|
||||
}
|
||||
return savedPath;
|
||||
}
|
||||
|
||||
async function generateChart(
|
||||
vmind: VMind,
|
||||
options: {
|
||||
dataset: string | DataTable;
|
||||
userPrompt: string;
|
||||
directory: string;
|
||||
outputType: "png" | "html";
|
||||
fileName: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
language?: "en" | "zh";
|
||||
}
|
||||
) {
|
||||
let res: {
|
||||
chart_path?: string;
|
||||
error?: string;
|
||||
insight_path?: string;
|
||||
insight_md?: string;
|
||||
} = {};
|
||||
const {
|
||||
dataset,
|
||||
userPrompt,
|
||||
directory,
|
||||
width,
|
||||
height,
|
||||
outputType,
|
||||
fileName,
|
||||
language,
|
||||
} = options;
|
||||
try {
|
||||
// Get chart spec and save in local file
|
||||
const jsonDataset = isString(dataset) ? JSON.parse(dataset) : dataset;
|
||||
const { spec, error, chartType } = await vmind.generateChart(
|
||||
userPrompt,
|
||||
undefined,
|
||||
jsonDataset,
|
||||
{
|
||||
enableDataQuery: false,
|
||||
theme: "light",
|
||||
}
|
||||
);
|
||||
if (error || !spec) {
|
||||
return {
|
||||
error: error || "Spec of Chart was Empty!",
|
||||
};
|
||||
}
|
||||
|
||||
spec.title = {
|
||||
text: userPrompt,
|
||||
};
|
||||
if (!fs.existsSync(path.join(directory, "visualization"))) {
|
||||
fs.mkdirSync(path.join(directory, "visualization"));
|
||||
}
|
||||
const specPath = getSavedPathName(directory, fileName, "json");
|
||||
res.chart_path = await saveChartRes({
|
||||
directory,
|
||||
spec,
|
||||
width,
|
||||
height,
|
||||
fileName,
|
||||
outputType,
|
||||
});
|
||||
|
||||
// get chart insights and save in local
|
||||
const insights = [];
|
||||
if (
|
||||
chartType &&
|
||||
[
|
||||
ChartType.BarChart,
|
||||
ChartType.LineChart,
|
||||
ChartType.AreaChart,
|
||||
ChartType.ScatterPlot,
|
||||
ChartType.DualAxisChart,
|
||||
].includes(chartType)
|
||||
) {
|
||||
const { insights: vmindInsights } = await vmind.getInsights(spec, {
|
||||
maxNum: 6,
|
||||
algorithms: [
|
||||
AlgorithmType.OverallTrending,
|
||||
AlgorithmType.AbnormalTrend,
|
||||
AlgorithmType.PearsonCorrelation,
|
||||
AlgorithmType.SpearmanCorrelation,
|
||||
AlgorithmType.StatisticsAbnormal,
|
||||
AlgorithmType.LOFOutlier,
|
||||
AlgorithmType.DbscanOutlier,
|
||||
AlgorithmType.MajorityValue,
|
||||
AlgorithmType.PageHinkley,
|
||||
AlgorithmType.TurningPoint,
|
||||
AlgorithmType.StatisticsBase,
|
||||
AlgorithmType.Volatility,
|
||||
],
|
||||
usePolish: false,
|
||||
language: language === "en" ? "english" : "chinese",
|
||||
});
|
||||
insights.push(...vmindInsights);
|
||||
}
|
||||
const insightsText = insights
|
||||
.map((insight) => insight.textContent?.plainText)
|
||||
.filter((insight) => !!insight) as string[];
|
||||
spec.insights = insights;
|
||||
fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
||||
res = {
|
||||
...res,
|
||||
...setInsightTemplate(
|
||||
getSavedPathName(directory, fileName, "md"),
|
||||
userPrompt,
|
||||
insightsText
|
||||
),
|
||||
};
|
||||
} catch (error: any) {
|
||||
res.error = error.toString();
|
||||
} finally {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateChartWithInsight(
|
||||
vmind: VMind,
|
||||
options: {
|
||||
directory: string;
|
||||
outputType: "png" | "html";
|
||||
fileName: string;
|
||||
insightsId: number[];
|
||||
}
|
||||
) {
|
||||
const { directory, outputType, fileName, insightsId } = options;
|
||||
let res: { error?: string; chart_path?: string } = {};
|
||||
try {
|
||||
const specPath = getSavedPathName(directory, fileName, "json", true);
|
||||
const spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
|
||||
// llm select index from 1
|
||||
const insights = (spec.insights || []).filter(
|
||||
(_insight: any, index: number) => insightsId.includes(index + 1)
|
||||
);
|
||||
const { newSpec, error } = await vmind.updateSpecByInsights(spec, insights);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
res.chart_path = await saveChartRes({
|
||||
spec: newSpec,
|
||||
directory,
|
||||
outputType,
|
||||
fileName,
|
||||
isUpdate: true,
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.error = error.toString();
|
||||
} finally {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeVMind() {
|
||||
const input = await readStdin();
|
||||
const inputData = JSON.parse(input);
|
||||
let res;
|
||||
const {
|
||||
llm_config,
|
||||
width,
|
||||
dataset = [],
|
||||
height,
|
||||
directory,
|
||||
user_prompt: userPrompt,
|
||||
output_type: outputType = "png",
|
||||
file_name: fileName,
|
||||
task_type: taskType = "visualization",
|
||||
insights_id: insightsId = [],
|
||||
language = "en",
|
||||
} = inputData;
|
||||
const { base_url: baseUrl, model, api_key: apiKey } = llm_config;
|
||||
const vmind = new VMind({
|
||||
url: `${baseUrl}/chat/completions`,
|
||||
model,
|
||||
headers: {
|
||||
"api-key": apiKey,
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
if (taskType === "visualization") {
|
||||
res = await generateChart(vmind, {
|
||||
dataset,
|
||||
userPrompt,
|
||||
directory,
|
||||
outputType,
|
||||
fileName,
|
||||
width,
|
||||
height,
|
||||
language,
|
||||
});
|
||||
} else if (taskType === "insight" && insightsId.length) {
|
||||
res = await updateChartWithInsight(vmind, {
|
||||
directory,
|
||||
fileName,
|
||||
outputType,
|
||||
insightsId,
|
||||
});
|
||||
}
|
||||
console.log(JSON.stringify(res));
|
||||
}
|
||||
|
||||
executeVMind();
|
||||
@@ -0,0 +1,191 @@
|
||||
import asyncio
|
||||
|
||||
from app.agent.data_analysis import DataAnalysis
|
||||
from app.logger import logger
|
||||
|
||||
|
||||
prefix = "Help me generate charts and save them locally, specifically:"
|
||||
tasks = [
|
||||
{
|
||||
"prompt": "Help me show the sales of different products in different regions",
|
||||
"data": """Product Name,Region,Sales
|
||||
Coke,South,2350
|
||||
Coke,East,1027
|
||||
Coke,West,1027
|
||||
Coke,North,1027
|
||||
Sprite,South,215
|
||||
Sprite,East,654
|
||||
Sprite,West,159
|
||||
Sprite,North,28
|
||||
Fanta,South,345
|
||||
Fanta,East,654
|
||||
Fanta,West,2100
|
||||
Fanta,North,1679
|
||||
Xingmu,South,1476
|
||||
Xingmu,East,830
|
||||
Xingmu,West,532
|
||||
Xingmu,North,498
|
||||
""",
|
||||
},
|
||||
{
|
||||
"prompt": "Show market share of each brand",
|
||||
"data": """Brand Name,Market Share,Average Price,Net Profit
|
||||
Apple,0.5,7068,314531
|
||||
Samsung,0.2,6059,362345
|
||||
Vivo,0.05,3406,234512
|
||||
Nokia,0.01,1064,-1345
|
||||
Xiaomi,0.1,4087,131345""",
|
||||
},
|
||||
{
|
||||
"prompt": "Please help me show the sales trend of each product",
|
||||
"data": """Date,Type,Value
|
||||
2023-01-01,Product A,52.9
|
||||
2023-01-01,Product B,63.6
|
||||
2023-01-01,Product C,11.2
|
||||
2023-01-02,Product A,45.7
|
||||
2023-01-02,Product B,89.1
|
||||
2023-01-02,Product C,21.4
|
||||
2023-01-03,Product A,67.2
|
||||
2023-01-03,Product B,82.4
|
||||
2023-01-03,Product C,31.7
|
||||
2023-01-04,Product A,80.7
|
||||
2023-01-04,Product B,55.1
|
||||
2023-01-04,Product C,21.1
|
||||
2023-01-05,Product A,65.6
|
||||
2023-01-05,Product B,78
|
||||
2023-01-05,Product C,31.3
|
||||
2023-01-06,Product A,75.6
|
||||
2023-01-06,Product B,89.1
|
||||
2023-01-06,Product C,63.5
|
||||
2023-01-07,Product A,67.3
|
||||
2023-01-07,Product B,77.2
|
||||
2023-01-07,Product C,43.7
|
||||
2023-01-08,Product A,96.1
|
||||
2023-01-08,Product B,97.6
|
||||
2023-01-08,Product C,59.9
|
||||
2023-01-09,Product A,96.1
|
||||
2023-01-09,Product B,100.6
|
||||
2023-01-09,Product C,66.8
|
||||
2023-01-10,Product A,101.6
|
||||
2023-01-10,Product B,108.3
|
||||
2023-01-10,Product C,56.9""",
|
||||
},
|
||||
{
|
||||
"prompt": "Show the popularity of search keywords",
|
||||
"data": """Keyword,Popularity
|
||||
Hot Word,1000
|
||||
Zao Le Wo Men,800
|
||||
Rao Jian Huo,400
|
||||
My Wish is World Peace,400
|
||||
Xiu Xiu Xiu,400
|
||||
Shenzhou 11,400
|
||||
Hundred Birds Facing the Wind,400
|
||||
China Women's Volleyball Team,400
|
||||
My Guan Na,400
|
||||
Leg Dong,400
|
||||
Hot Pot Hero,400
|
||||
Baby's Heart is Bitter,400
|
||||
Olympics,400
|
||||
Awesome My Brother,400
|
||||
Poetry and Distance,400
|
||||
Song Joong-ki,400
|
||||
PPAP,400
|
||||
Blue Thin Mushroom,400
|
||||
Rain Dew Evenly,400
|
||||
Friendship's Little Boat Says It Flips,400
|
||||
Beijing Slump,400
|
||||
Dedication,200
|
||||
Apple,200
|
||||
Dog Belt,200
|
||||
Old Driver,200
|
||||
Melon-Eating Crowd,200
|
||||
Zootopia,200
|
||||
City Will Play,200
|
||||
Routine,200
|
||||
Water Reverse,200
|
||||
Why Don't You Go to Heaven,200
|
||||
Snake Spirit Man,200
|
||||
Why Don't You Go to Heaven,200
|
||||
Samsung Explosion Gate,200
|
||||
Little Li Oscar,200
|
||||
Ugly People Need to Read More,200
|
||||
Boyfriend Power,200
|
||||
A Face of Confusion,200
|
||||
Descendants of the Sun,200""",
|
||||
},
|
||||
{
|
||||
"prompt": "Help me compare the performance of different electric vehicle brands using a scatter plot",
|
||||
"data": """Range,Charging Time,Brand Name,Average Price
|
||||
2904,46,Brand1,2350
|
||||
1231,146,Brand2,1027
|
||||
5675,324,Brand3,1242
|
||||
543,57,Brand4,6754
|
||||
326,234,Brand5,215
|
||||
1124,67,Brand6,654
|
||||
3426,81,Brand7,159
|
||||
2134,24,Brand8,28
|
||||
1234,52,Brand9,345
|
||||
2345,27,Brand10,654
|
||||
526,145,Brand11,2100
|
||||
234,93,Brand12,1679
|
||||
567,94,Brand13,1476
|
||||
789,45,Brand14,830
|
||||
469,75,Brand15,532
|
||||
5689,54,Brand16,498
|
||||
""",
|
||||
},
|
||||
{
|
||||
"prompt": "Show conversion rates for each process",
|
||||
"data": """Process,Conversion Rate,Month
|
||||
Step1,100,1
|
||||
Step2,80,1
|
||||
Step3,60,1
|
||||
Step4,40,1""",
|
||||
},
|
||||
{
|
||||
"prompt": "Show the difference in breakfast consumption between men and women",
|
||||
"data": """Day,Men-Breakfast,Women-Breakfast
|
||||
Monday,15,22
|
||||
Tuesday,12,10
|
||||
Wednesday,15,20
|
||||
Thursday,10,12
|
||||
Friday,13,15
|
||||
Saturday,10,15
|
||||
Sunday,12,14""",
|
||||
},
|
||||
{
|
||||
"prompt": "Help me show this person's performance in different aspects, is he a hexagonal warrior",
|
||||
"data": """dimension,performance
|
||||
Strength,5
|
||||
Speed,5
|
||||
Shooting,3
|
||||
Endurance,5
|
||||
Precision,5
|
||||
Growth,5""",
|
||||
},
|
||||
{
|
||||
"prompt": "Show data flow",
|
||||
"data": """Origin,Destination,value
|
||||
Node A,Node 1,10
|
||||
Node A,Node 2,5
|
||||
Node B,Node 2,8
|
||||
Node B,Node 3,2
|
||||
Node C,Node 2,4
|
||||
Node A,Node C,2
|
||||
Node C,Node 1,2""",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
for index, item in enumerate(tasks):
|
||||
logger.info(f"Begin task {index} / {len(tasks)}!")
|
||||
agent = DataAnalysis()
|
||||
await agent.run(
|
||||
f"{prefix},chart_description:{item['prompt']},Data:{item['data']}"
|
||||
)
|
||||
logger.info(f"Finish with {item['prompt']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,27 @@
|
||||
import asyncio
|
||||
|
||||
from app.agent.data_analysis import DataAnalysis
|
||||
|
||||
|
||||
# from app.agent.manus import Manus
|
||||
|
||||
|
||||
async def main():
|
||||
agent = DataAnalysis()
|
||||
# agent = Manus()
|
||||
await agent.run(
|
||||
"""Requirement:
|
||||
1. Analyze the following data and generate a graphical data report in HTML format. The final product should be a data report.
|
||||
Data:
|
||||
Month | Team A | Team B | Team C
|
||||
January | 1200 hours | 1350 hours | 1100 hours
|
||||
February | 1250 hours | 1400 hours | 1150 hours
|
||||
March | 1180 hours | 1300 hours | 1300 hours
|
||||
April | 1220 hours | 1280 hours | 1400 hours
|
||||
May | 1230 hours | 1320 hours | 1450 hours
|
||||
June | 1200 hours | 1250 hours | 1500 hours """
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
],
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
/* Language and Environment */
|
||||
"target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
"src/types"
|
||||
], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
/* JavaScript Support */
|
||||
"allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
"checkJs": false, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import os
|
||||
|
||||
import aiofiles
|
||||
|
||||
from app.config import WORKSPACE_ROOT
|
||||
from app.tool.base import BaseTool
|
||||
|
||||
|
||||
class FileSaver(BaseTool):
|
||||
name: str = "file_saver"
|
||||
description: str = """Save content to a local file at a specified path.
|
||||
Use this tool when you need to save text, code, or generated content to a file on the local filesystem.
|
||||
The tool accepts content and a file path, and saves the content to that location.
|
||||
"""
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "(required) The content to save to the file.",
|
||||
},
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "(required) The path where the file should be saved, including filename and extension.",
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"description": "(optional) The file opening mode. Default is 'w' for write. Use 'a' for append.",
|
||||
"enum": ["w", "a"],
|
||||
"default": "w",
|
||||
},
|
||||
},
|
||||
"required": ["content", "file_path"],
|
||||
}
|
||||
|
||||
async def execute(self, content: str, file_path: str, mode: str = "w") -> str:
|
||||
"""
|
||||
Save content to a file at the specified path.
|
||||
|
||||
Args:
|
||||
content (str): The content to save to the file.
|
||||
file_path (str): The path where the file should be saved.
|
||||
mode (str, optional): The file opening mode. Default is 'w' for write. Use 'a' for append.
|
||||
|
||||
Returns:
|
||||
str: A message indicating the result of the operation.
|
||||
"""
|
||||
try:
|
||||
# Place the generated file in the workspace directory
|
||||
if os.path.isabs(file_path):
|
||||
file_name = os.path.basename(file_path)
|
||||
full_path = os.path.join(WORKSPACE_ROOT, file_name)
|
||||
else:
|
||||
full_path = os.path.join(WORKSPACE_ROOT, file_path)
|
||||
|
||||
# Ensure the directory exists
|
||||
directory = os.path.dirname(full_path)
|
||||
if directory and not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
# Write directly to the file
|
||||
async with aiofiles.open(full_path, mode, encoding="utf-8") as file:
|
||||
await file.write(content)
|
||||
|
||||
return f"Content successfully saved to {full_path}"
|
||||
except Exception as e:
|
||||
return f"Error saving file: {str(e)}"
|
||||
@@ -0,0 +1,36 @@
|
||||
from typing import Optional
|
||||
from app.tool.base import BaseTool, ToolResult
|
||||
from app.config import config
|
||||
|
||||
class LinuxSandboxTool(BaseTool):
|
||||
"""A tool to start an e2b DesktopSandbox and return the VNC URL, using config.cloud_sandbox for credentials."""
|
||||
|
||||
name: str = "linux_sandbox"
|
||||
description: str = (
|
||||
"Starts an e2b DesktopSandbox and returns the VNC URL. "
|
||||
"API key and domain are read from config.cloud_sandbox."
|
||||
)
|
||||
parameters: dict = {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
async def execute(self, **kwargs) -> ToolResult:
|
||||
"""
|
||||
Start an e2b DesktopSandbox and return the VNC URL, using config.cloud_sandbox.
|
||||
"""
|
||||
try:
|
||||
from e2b_desktop import Sandbox as DesktopSandbox
|
||||
except ImportError:
|
||||
return ToolResult(error="e2b_desktop is not installed. Please install it via pip.")
|
||||
sandbox_config = getattr(config, "cloud_sandbox", None)
|
||||
if not sandbox_config or not sandbox_config.get("api_key") or not sandbox_config.get("domain"):
|
||||
return ToolResult(error="cloud_sandbox config missing or incomplete. Please set api_key and domain in config.cloud_sandbox.")
|
||||
try:
|
||||
desktop = DesktopSandbox(api_key=sandbox_config["api_key"], domain=sandbox_config["domain"])
|
||||
desktop.stream.start()
|
||||
url = desktop.stream.get_url()
|
||||
return ToolResult(output={
|
||||
"status": "started",
|
||||
"vnc_url": url,
|
||||
"domain": sandbox_config["domain"],
|
||||
})
|
||||
except Exception as e:
|
||||
return ToolResult(error=f"Failed to start e2b DesktopSandbox: {e}")
|
||||
+122
-43
@@ -1,10 +1,10 @@
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import List, Optional
|
||||
from typing import 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 mcp.types import ListToolsResult, TextContent
|
||||
|
||||
from app.logger import logger
|
||||
from app.tool.base import BaseTool, ToolResult
|
||||
@@ -15,6 +15,8 @@ class MCPClientTool(BaseTool):
|
||||
"""Represents a tool proxy that can be called on the MCP server from the client side."""
|
||||
|
||||
session: Optional[ClientSession] = None
|
||||
server_id: str = "" # Add server identifier
|
||||
original_name: str = ""
|
||||
|
||||
async def execute(self, **kwargs) -> ToolResult:
|
||||
"""Execute the tool by making a remote call to the MCP server."""
|
||||
@@ -22,7 +24,8 @@ class MCPClientTool(BaseTool):
|
||||
return ToolResult(error="Not connected to MCP server")
|
||||
|
||||
try:
|
||||
result = await self.session.call_tool(self.name, kwargs)
|
||||
logger.info(f"Executing tool: {self.original_name}")
|
||||
result = await self.session.call_tool(self.original_name, kwargs)
|
||||
content_str = ", ".join(
|
||||
item.text for item in result.content if isinstance(item, TextContent)
|
||||
)
|
||||
@@ -33,83 +36,159 @@ class MCPClientTool(BaseTool):
|
||||
|
||||
class MCPClients(ToolCollection):
|
||||
"""
|
||||
A collection of tools that connects to an MCP server and manages available tools through the Model Context Protocol.
|
||||
A collection of tools that connects to multiple MCP servers and manages available tools through the Model Context Protocol.
|
||||
"""
|
||||
|
||||
session: Optional[ClientSession] = None
|
||||
exit_stack: AsyncExitStack = None
|
||||
sessions: Dict[str, ClientSession] = {}
|
||||
exit_stacks: Dict[str, AsyncExitStack] = {}
|
||||
description: str = "MCP client 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:
|
||||
async def connect_sse(self, server_url: str, server_id: 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()
|
||||
|
||||
server_id = server_id or server_url
|
||||
|
||||
# Always ensure clean disconnection before new connection
|
||||
if server_id in self.sessions:
|
||||
await self.disconnect(server_id)
|
||||
|
||||
exit_stack = AsyncExitStack()
|
||||
self.exit_stacks[server_id] = exit_stack
|
||||
|
||||
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)
|
||||
)
|
||||
streams = await exit_stack.enter_async_context(streams_context)
|
||||
session = await exit_stack.enter_async_context(ClientSession(*streams))
|
||||
self.sessions[server_id] = session
|
||||
|
||||
await self._initialize_and_list_tools()
|
||||
await self._initialize_and_list_tools(server_id)
|
||||
|
||||
async def connect_stdio(self, command: str, args: List[str]) -> None:
|
||||
async def connect_stdio(
|
||||
self, command: str, args: List[str], server_id: 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_id = server_id or command
|
||||
|
||||
# Always ensure clean disconnection before new connection
|
||||
if server_id in self.sessions:
|
||||
await self.disconnect(server_id)
|
||||
|
||||
exit_stack = AsyncExitStack()
|
||||
self.exit_stacks[server_id] = exit_stack
|
||||
|
||||
server_params = StdioServerParameters(command=command, args=args)
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
stdio_transport = await 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)
|
||||
)
|
||||
session = await exit_stack.enter_async_context(ClientSession(read, write))
|
||||
self.sessions[server_id] = session
|
||||
|
||||
await self._initialize_and_list_tools()
|
||||
await self._initialize_and_list_tools(server_id)
|
||||
|
||||
async def _initialize_and_list_tools(self) -> None:
|
||||
async def _initialize_and_list_tools(self, server_id: str) -> None:
|
||||
"""Initialize session and populate tool map."""
|
||||
if not self.session:
|
||||
raise RuntimeError("Session not initialized.")
|
||||
session = self.sessions.get(server_id)
|
||||
if not session:
|
||||
raise RuntimeError(f"Session not initialized for server {server_id}")
|
||||
|
||||
await self.session.initialize()
|
||||
response = await self.session.list_tools()
|
||||
|
||||
# Clear existing tools
|
||||
self.tools = tuple()
|
||||
self.tool_map = {}
|
||||
await session.initialize()
|
||||
response = await session.list_tools()
|
||||
|
||||
# Create proper tool objects for each server tool
|
||||
for tool in response.tools:
|
||||
original_name = tool.name
|
||||
tool_name = f"mcp_{server_id}_{original_name}"
|
||||
tool_name = self._sanitize_tool_name(tool_name)
|
||||
|
||||
server_tool = MCPClientTool(
|
||||
name=tool.name,
|
||||
name=tool_name,
|
||||
description=tool.description,
|
||||
parameters=tool.inputSchema,
|
||||
session=self.session,
|
||||
session=session,
|
||||
server_id=server_id,
|
||||
original_name=original_name,
|
||||
)
|
||||
self.tool_map[tool.name] = server_tool
|
||||
self.tool_map[tool_name] = server_tool
|
||||
|
||||
# Update tools tuple
|
||||
self.tools = tuple(self.tool_map.values())
|
||||
logger.info(
|
||||
f"Connected to server with tools: {[tool.name for tool in response.tools]}"
|
||||
f"Connected to server {server_id} 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()
|
||||
def _sanitize_tool_name(self, name: str) -> str:
|
||||
"""Sanitize tool name to match MCPClientTool requirements."""
|
||||
import re
|
||||
|
||||
# Replace invalid characters with underscores
|
||||
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
|
||||
|
||||
# Remove consecutive underscores
|
||||
sanitized = re.sub(r"_+", "_", sanitized)
|
||||
|
||||
# Remove leading/trailing underscores
|
||||
sanitized = sanitized.strip("_")
|
||||
|
||||
# Truncate to 64 characters if needed
|
||||
if len(sanitized) > 64:
|
||||
sanitized = sanitized[:64]
|
||||
|
||||
return sanitized
|
||||
|
||||
async def list_tools(self) -> ListToolsResult:
|
||||
"""List all available tools."""
|
||||
tools_result = ListToolsResult(tools=[])
|
||||
for session in self.sessions.values():
|
||||
response = await session.list_tools()
|
||||
tools_result.tools += response.tools
|
||||
return tools_result
|
||||
|
||||
async def disconnect(self, server_id: str = "") -> None:
|
||||
"""Disconnect from a specific MCP server or all servers if no server_id provided."""
|
||||
if server_id:
|
||||
if server_id in self.sessions:
|
||||
try:
|
||||
exit_stack = self.exit_stacks.get(server_id)
|
||||
|
||||
# Close the exit stack which will handle session cleanup
|
||||
if exit_stack:
|
||||
try:
|
||||
await exit_stack.aclose()
|
||||
except RuntimeError as e:
|
||||
if "cancel scope" in str(e).lower():
|
||||
logger.warning(
|
||||
f"Cancel scope error during disconnect from {server_id}, continuing with cleanup: {e}"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
# Clean up references
|
||||
self.sessions.pop(server_id, None)
|
||||
self.exit_stacks.pop(server_id, None)
|
||||
|
||||
# Remove tools associated with this server
|
||||
self.tool_map = {
|
||||
k: v
|
||||
for k, v in self.tool_map.items()
|
||||
if v.server_id != server_id
|
||||
}
|
||||
self.tools = tuple(self.tool_map.values())
|
||||
logger.info(f"Disconnected from MCP server {server_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting from server {server_id}: {e}")
|
||||
else:
|
||||
# Disconnect from all servers in a deterministic order
|
||||
for sid in sorted(list(self.sessions.keys())):
|
||||
await self.disconnect(sid)
|
||||
self.tool_map = {}
|
||||
logger.info("Disconnected from MCP server")
|
||||
self.tools = tuple()
|
||||
logger.info("Disconnected from all MCP servers")
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
from typing import Dict
|
||||
from app.tool.base import BaseTool
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import sys
|
||||
from io import StringIO
|
||||
from app.config import config
|
||||
|
||||
class SandboxPythonExecute(BaseTool):
|
||||
"""A tool for executing Python code either locally (with timeout) or in a sandboxed environment using e2b_code_interpreter."""
|
||||
|
||||
name: str = "python_execute"
|
||||
description: str = (
|
||||
"Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results. "
|
||||
"Set mode='sandbox' to run in a secure sandbox (e2b), otherwise runs locally."
|
||||
)
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "The Python code to execute.",
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["local", "sandbox"],
|
||||
"description": "Execution mode: 'local' (default) or 'sandbox' (e2b sandbox)",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Execution timeout in seconds (default: 5 for local, 10 for sandbox)",
|
||||
},
|
||||
},
|
||||
"required": ["code"],
|
||||
}
|
||||
|
||||
def _run_code(self, code: str, result_dict: dict, safe_globals: dict) -> None:
|
||||
original_stdout = sys.stdout
|
||||
try:
|
||||
output_buffer = StringIO()
|
||||
sys.stdout = output_buffer
|
||||
exec(code, safe_globals, safe_globals)
|
||||
result_dict["observation"] = output_buffer.getvalue()
|
||||
result_dict["success"] = True
|
||||
except Exception as e:
|
||||
result_dict["observation"] = str(e)
|
||||
result_dict["success"] = False
|
||||
finally:
|
||||
sys.stdout = original_stdout
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
code: str,
|
||||
timeout: int = None,
|
||||
mode: str = "local",
|
||||
) -> Dict:
|
||||
"""
|
||||
Executes the provided Python code in the selected environment.
|
||||
Args:
|
||||
code (str): The Python code to execute.
|
||||
timeout (int): Execution timeout in seconds.
|
||||
mode (str): 'local' or 'sandbox'.
|
||||
Returns:
|
||||
Dict: Contains 'observation' with execution output or error message and 'success' status.
|
||||
"""
|
||||
if mode == "sandbox":
|
||||
# Use e2b_code_interpreter Sandbox
|
||||
try:
|
||||
from e2b_code_interpreter import Sandbox
|
||||
except ImportError:
|
||||
return {"observation": "e2b_code_interpreter not installed.", "success": False}
|
||||
# Get sandbox config from app.config.config
|
||||
sandbox_config = getattr(config, "cloud_sandbox", {})
|
||||
# You can pass config to Sandbox() if needed, e.g. Sandbox(api_key=sandbox_config.get("api_key"))
|
||||
sbx = Sandbox(**sandbox_config) if sandbox_config else Sandbox()
|
||||
try:
|
||||
# Default timeout for sandbox is 10s if not set
|
||||
effective_timeout = timeout if timeout is not None else 10
|
||||
async def run():
|
||||
execution = sbx.run_code(code)
|
||||
logs = execution.logs if hasattr(execution, 'logs') else str(execution)
|
||||
success = execution.error is None if hasattr(execution, 'error') else True
|
||||
observation = logs
|
||||
if not success:
|
||||
observation = f"Error: {getattr(execution, 'error', 'Unknown error')}\n{logs}"
|
||||
return {"observation": observation, "success": success}
|
||||
result = await asyncio.wait_for(run(), timeout=effective_timeout)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return {"observation": f"Execution timeout after {effective_timeout} seconds", "success": False}
|
||||
except Exception as e:
|
||||
return {"observation": str(e), "success": False}
|
||||
finally:
|
||||
sbx.kill()
|
||||
else:
|
||||
# Local execution (default)
|
||||
effective_timeout = timeout if timeout is not None else 5
|
||||
with multiprocessing.Manager() as manager:
|
||||
result = manager.dict({"observation": "", "success": False})
|
||||
if isinstance(__builtins__, dict):
|
||||
safe_globals = {"__builtins__": __builtins__}
|
||||
else:
|
||||
safe_globals = {"__builtins__": __builtins__.__dict__.copy()}
|
||||
proc = multiprocessing.Process(
|
||||
target=self._run_code, args=(code, result, safe_globals)
|
||||
)
|
||||
proc.start()
|
||||
proc.join(effective_timeout)
|
||||
if proc.is_alive():
|
||||
proc.terminate()
|
||||
proc.join(1)
|
||||
return {
|
||||
"observation": f"Execution timeout after {effective_timeout} seconds",
|
||||
"success": False,
|
||||
}
|
||||
return dict(result)
|
||||
@@ -1,9 +1,54 @@
|
||||
from typing import List
|
||||
|
||||
from baidusearch.baidusearch import search
|
||||
|
||||
from app.tool.search.base import WebSearchEngine
|
||||
from app.tool.search.base import SearchItem, WebSearchEngine
|
||||
|
||||
|
||||
class BaiduSearchEngine(WebSearchEngine):
|
||||
def perform_search(self, query, num_results=10, *args, **kwargs):
|
||||
"""Baidu search engine."""
|
||||
return search(query, num_results=num_results)
|
||||
def perform_search(
|
||||
self, query: str, num_results: int = 10, *args, **kwargs
|
||||
) -> List[SearchItem]:
|
||||
"""
|
||||
Baidu search engine.
|
||||
|
||||
Returns results formatted according to SearchItem model.
|
||||
"""
|
||||
raw_results = search(query, num_results=num_results)
|
||||
|
||||
# Convert raw results to SearchItem format
|
||||
results = []
|
||||
for i, item in enumerate(raw_results):
|
||||
if isinstance(item, str):
|
||||
# If it's just a URL
|
||||
results.append(
|
||||
SearchItem(title=f"Baidu Result {i+1}", url=item, description=None)
|
||||
)
|
||||
elif isinstance(item, dict):
|
||||
# If it's a dictionary with details
|
||||
results.append(
|
||||
SearchItem(
|
||||
title=item.get("title", f"Baidu Result {i+1}"),
|
||||
url=item.get("url", ""),
|
||||
description=item.get("abstract", None),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Try to get attributes directly
|
||||
try:
|
||||
results.append(
|
||||
SearchItem(
|
||||
title=getattr(item, "title", f"Baidu Result {i+1}"),
|
||||
url=getattr(item, "url", ""),
|
||||
description=getattr(item, "abstract", None),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to a basic result
|
||||
results.append(
|
||||
SearchItem(
|
||||
title=f"Baidu Result {i+1}", url=str(item), description=None
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
+27
-4
@@ -1,9 +1,32 @@
|
||||
class WebSearchEngine(object):
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchItem(BaseModel):
|
||||
"""Represents a single search result item"""
|
||||
|
||||
title: str = Field(description="The title of the search result")
|
||||
url: str = Field(description="The URL of the search result")
|
||||
description: Optional[str] = Field(
|
||||
default=None, description="A description or snippet of the search result"
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of a search result item."""
|
||||
return f"{self.title} - {self.url}"
|
||||
|
||||
|
||||
class WebSearchEngine(BaseModel):
|
||||
"""Base class for web search engines."""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
def perform_search(
|
||||
self, query: str, num_results: int = 10, *args, **kwargs
|
||||
) -> list[dict]:
|
||||
) -> List[SearchItem]:
|
||||
"""
|
||||
Perform a web search and return a list of URLs.
|
||||
Perform a web search and return a list of search items.
|
||||
|
||||
Args:
|
||||
query (str): The search query to submit to the search engine.
|
||||
@@ -12,6 +35,6 @@ class WebSearchEngine(object):
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
List: A list of dict matching the search query.
|
||||
List[SearchItem]: A list of SearchItem objects matching the search query.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from typing import List
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.logger import logger
|
||||
from app.tool.search.base import WebSearchEngine
|
||||
from app.tool.search.base import SearchItem, WebSearchEngine
|
||||
|
||||
|
||||
ABSTRACT_MAX_LENGTH = 300
|
||||
@@ -36,7 +36,7 @@ BING_SEARCH_URL = "https://www.bing.com/search?q="
|
||||
|
||||
|
||||
class BingSearchEngine(WebSearchEngine):
|
||||
session: requests.Session = None
|
||||
session: Optional[requests.Session] = None
|
||||
|
||||
def __init__(self, **data):
|
||||
"""Initialize the BingSearch tool with a requests session."""
|
||||
@@ -44,21 +44,16 @@ class BingSearchEngine(WebSearchEngine):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(HEADERS)
|
||||
|
||||
def _search_sync(self, query: str, num_results: int = 10) -> List[str]:
|
||||
def _search_sync(self, query: str, num_results: int = 10) -> List[SearchItem]:
|
||||
"""
|
||||
Synchronous Bing search implementation to retrieve a list of URLs matching a query.
|
||||
Synchronous Bing search implementation to retrieve search results.
|
||||
|
||||
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.
|
||||
query (str): The search query to submit to Bing.
|
||||
num_results (int, optional): Maximum number of results 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.
|
||||
List[SearchItem]: A list of search items with title, URL, and description.
|
||||
"""
|
||||
if not query:
|
||||
return []
|
||||
@@ -72,25 +67,21 @@ class BingSearchEngine(WebSearchEngine):
|
||||
next_url, rank_start=len(list_result), first=first
|
||||
)
|
||||
if data:
|
||||
list_result.extend([item["url"] for item in data])
|
||||
list_result.extend(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:
|
||||
def _parse_html(
|
||||
self, url: str, rank_start: int = 0, first: int = 1
|
||||
) -> Tuple[List[SearchItem], str]:
|
||||
"""
|
||||
Parse Bing search result HTML synchronously to extract search results and the next page URL.
|
||||
Parse Bing search result HTML 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.
|
||||
tuple: (List of SearchItem objects, next page URL or None)
|
||||
"""
|
||||
try:
|
||||
res = self.session.get(url=url)
|
||||
@@ -120,13 +111,14 @@ class BingSearchEngine(WebSearchEngine):
|
||||
abstract = abstract[:ABSTRACT_MAX_LENGTH]
|
||||
|
||||
rank_start += 1
|
||||
|
||||
# Create a SearchItem object
|
||||
list_data.append(
|
||||
{
|
||||
"title": title,
|
||||
"abstract": abstract,
|
||||
"url": url,
|
||||
"rank": rank_start,
|
||||
}
|
||||
SearchItem(
|
||||
title=title or f"Bing Result {rank_start}",
|
||||
url=url,
|
||||
description=abstract,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -141,6 +133,12 @@ class BingSearchEngine(WebSearchEngine):
|
||||
logger.warning(f"Error parsing HTML: {e}")
|
||||
return [], None
|
||||
|
||||
def perform_search(self, query, num_results=10, *args, **kwargs):
|
||||
"""Bing search engine."""
|
||||
def perform_search(
|
||||
self, query: str, num_results: int = 10, *args, **kwargs
|
||||
) -> List[SearchItem]:
|
||||
"""
|
||||
Bing search engine.
|
||||
|
||||
Returns results formatted according to SearchItem model.
|
||||
"""
|
||||
return self._search_sync(query, num_results=num_results)
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
from typing import List
|
||||
|
||||
from duckduckgo_search import DDGS
|
||||
|
||||
from app.tool.search.base import WebSearchEngine
|
||||
from app.tool.search.base import SearchItem, WebSearchEngine
|
||||
|
||||
|
||||
class DuckDuckGoSearchEngine(WebSearchEngine):
|
||||
async def perform_search(self, query, num_results=10, *args, **kwargs):
|
||||
"""DuckDuckGo search engine."""
|
||||
return DDGS.text(query, num_results=num_results)
|
||||
def perform_search(
|
||||
self, query: str, num_results: int = 10, *args, **kwargs
|
||||
) -> List[SearchItem]:
|
||||
"""
|
||||
DuckDuckGo search engine.
|
||||
|
||||
Returns results formatted according to SearchItem model.
|
||||
"""
|
||||
raw_results = DDGS().text(query, max_results=num_results)
|
||||
|
||||
results = []
|
||||
for i, item in enumerate(raw_results):
|
||||
if isinstance(item, str):
|
||||
# If it's just a URL
|
||||
results.append(
|
||||
SearchItem(
|
||||
title=f"DuckDuckGo Result {i + 1}", url=item, description=None
|
||||
)
|
||||
)
|
||||
elif isinstance(item, dict):
|
||||
# Extract data from the dictionary
|
||||
results.append(
|
||||
SearchItem(
|
||||
title=item.get("title", f"DuckDuckGo Result {i + 1}"),
|
||||
url=item.get("href", ""),
|
||||
description=item.get("body", None),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Try to extract attributes directly
|
||||
try:
|
||||
results.append(
|
||||
SearchItem(
|
||||
title=getattr(item, "title", f"DuckDuckGo Result {i + 1}"),
|
||||
url=getattr(item, "href", ""),
|
||||
description=getattr(item, "body", None),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Fallback
|
||||
results.append(
|
||||
SearchItem(
|
||||
title=f"DuckDuckGo Result {i + 1}",
|
||||
url=str(item),
|
||||
description=None,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
from typing import List
|
||||
|
||||
from googlesearch import search
|
||||
|
||||
from app.tool.search.base import WebSearchEngine
|
||||
from app.tool.search.base import SearchItem, WebSearchEngine
|
||||
|
||||
|
||||
class GoogleSearchEngine(WebSearchEngine):
|
||||
def perform_search(self, query, num_results=10, *args, **kwargs):
|
||||
"""Google search engine."""
|
||||
return search(query, num_results=num_results)
|
||||
def perform_search(
|
||||
self, query: str, num_results: int = 10, *args, **kwargs
|
||||
) -> List[SearchItem]:
|
||||
"""
|
||||
Google search engine.
|
||||
|
||||
Returns results formatted according to SearchItem model.
|
||||
"""
|
||||
raw_results = search(query, num_results=num_results, advanced=True)
|
||||
|
||||
results = []
|
||||
for i, item in enumerate(raw_results):
|
||||
if isinstance(item, str):
|
||||
# If it's just a URL
|
||||
results.append(
|
||||
{"title": f"Google Result {i+1}", "url": item, "description": ""}
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
SearchItem(
|
||||
title=item.title, url=item.url, description=item.description
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shlex
|
||||
from typing import Optional
|
||||
|
||||
from app.tool.base import BaseTool, CLIResult
|
||||
|
||||
|
||||
class Terminal(BaseTool):
|
||||
name: str = "execute_command"
|
||||
description: str = """Request to execute a CLI command on the system.
|
||||
Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task.
|
||||
You must tailor your command to the user's system and provide a clear explanation of what the command does.
|
||||
Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run.
|
||||
Commands will be executed in the current working directory.
|
||||
Note: You MUST append a `sleep 0.05` to the end of the command for commands that will complete in under 50ms, as this will circumvent a known issue with the terminal tool where it will sometimes not return the output when the command completes too quickly.
|
||||
"""
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "(required) The CLI command to execute. This should be valid for the current operating system. Ensure the command is properly formatted and does not contain any harmful instructions.",
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
}
|
||||
process: Optional[asyncio.subprocess.Process] = None
|
||||
current_path: str = os.getcwd()
|
||||
lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
async def execute(self, command: str) -> CLIResult:
|
||||
"""
|
||||
Execute a terminal command asynchronously with persistent context.
|
||||
|
||||
Args:
|
||||
command (str): The terminal command to execute.
|
||||
|
||||
Returns:
|
||||
str: The output, and error of the command execution.
|
||||
"""
|
||||
# Split the command by & to handle multiple commands
|
||||
commands = [cmd.strip() for cmd in command.split("&") if cmd.strip()]
|
||||
final_output = CLIResult(output="", error="")
|
||||
|
||||
for cmd in commands:
|
||||
sanitized_command = self._sanitize_command(cmd)
|
||||
|
||||
# Handle 'cd' command internally
|
||||
if sanitized_command.lstrip().startswith("cd "):
|
||||
result = await self._handle_cd_command(sanitized_command)
|
||||
else:
|
||||
async with self.lock:
|
||||
try:
|
||||
self.process = await asyncio.create_subprocess_shell(
|
||||
sanitized_command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.current_path,
|
||||
)
|
||||
stdout, stderr = await self.process.communicate()
|
||||
result = CLIResult(
|
||||
output=stdout.decode().strip(),
|
||||
error=stderr.decode().strip(),
|
||||
)
|
||||
except Exception as e:
|
||||
result = CLIResult(output="", error=str(e))
|
||||
finally:
|
||||
self.process = None
|
||||
|
||||
# Combine outputs
|
||||
if result.output:
|
||||
final_output.output += (
|
||||
(result.output + "\n") if final_output.output else result.output
|
||||
)
|
||||
if result.error:
|
||||
final_output.error += (
|
||||
(result.error + "\n") if final_output.error else result.error
|
||||
)
|
||||
|
||||
# Remove trailing newlines
|
||||
final_output.output = final_output.output.rstrip()
|
||||
final_output.error = final_output.error.rstrip()
|
||||
return final_output
|
||||
|
||||
async def execute_in_env(self, env_name: str, command: str) -> CLIResult:
|
||||
"""
|
||||
Execute a terminal command asynchronously within a specified Conda environment.
|
||||
|
||||
Args:
|
||||
env_name (str): The name of the Conda environment.
|
||||
command (str): The terminal command to execute within the environment.
|
||||
|
||||
Returns:
|
||||
str: The output, and error of the command execution.
|
||||
"""
|
||||
sanitized_command = self._sanitize_command(command)
|
||||
|
||||
# Construct the command to run within the Conda environment
|
||||
# Using 'conda run -n env_name command' to execute without activating
|
||||
conda_command = f"conda run -n {shlex.quote(env_name)} {sanitized_command}"
|
||||
|
||||
return await self.execute(conda_command)
|
||||
|
||||
async def _handle_cd_command(self, command: str) -> CLIResult:
|
||||
"""
|
||||
Handle 'cd' commands to change the current path.
|
||||
|
||||
Args:
|
||||
command (str): The 'cd' command to process.
|
||||
|
||||
Returns:
|
||||
TerminalOutput: The result of the 'cd' command.
|
||||
"""
|
||||
try:
|
||||
parts = shlex.split(command)
|
||||
if len(parts) < 2:
|
||||
new_path = os.path.expanduser("~")
|
||||
else:
|
||||
new_path = os.path.expanduser(parts[1])
|
||||
|
||||
# Handle relative paths
|
||||
if not os.path.isabs(new_path):
|
||||
new_path = os.path.join(self.current_path, new_path)
|
||||
|
||||
new_path = os.path.abspath(new_path)
|
||||
|
||||
if os.path.isdir(new_path):
|
||||
self.current_path = new_path
|
||||
return CLIResult(
|
||||
output=f"Changed directory to {self.current_path}", error=""
|
||||
)
|
||||
else:
|
||||
return CLIResult(output="", error=f"No such directory: {new_path}")
|
||||
except Exception as e:
|
||||
return CLIResult(output="", error=str(e))
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_command(command: str) -> str:
|
||||
"""
|
||||
Sanitize the command for safe execution.
|
||||
|
||||
Args:
|
||||
command (str): The command to sanitize.
|
||||
|
||||
Returns:
|
||||
str: The sanitized command.
|
||||
"""
|
||||
# Example sanitization: restrict certain dangerous commands
|
||||
dangerous_commands = ["rm", "sudo", "shutdown", "reboot"]
|
||||
try:
|
||||
parts = shlex.split(command)
|
||||
if any(cmd in dangerous_commands for cmd in parts):
|
||||
raise ValueError("Use of dangerous commands is restricted.")
|
||||
except Exception:
|
||||
# If shlex.split fails, try basic string comparison
|
||||
if any(cmd in command for cmd in dangerous_commands):
|
||||
raise ValueError("Use of dangerous commands is restricted.")
|
||||
|
||||
# Additional sanitization logic can be added here
|
||||
return command
|
||||
|
||||
async def close(self):
|
||||
"""Close the persistent shell process if it exists."""
|
||||
async with self.lock:
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(self.process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
self.process.kill()
|
||||
await self.process.wait()
|
||||
finally:
|
||||
self.process = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Enter the asynchronous context manager."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Exit the asynchronous context manager and close the process."""
|
||||
await self.close()
|
||||
@@ -2,6 +2,7 @@
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from app.exceptions import ToolError
|
||||
from app.logger import logger
|
||||
from app.tool.base import BaseTool, ToolFailure, ToolResult
|
||||
|
||||
|
||||
@@ -48,11 +49,23 @@ class ToolCollection:
|
||||
return self.tool_map.get(name)
|
||||
|
||||
def add_tool(self, tool: BaseTool):
|
||||
"""Add a single tool to the collection.
|
||||
|
||||
If a tool with the same name already exists, it will be skipped and a warning will be logged.
|
||||
"""
|
||||
if tool.name in self.tool_map:
|
||||
logger.warning(f"Tool {tool.name} already exists in collection, skipping")
|
||||
return self
|
||||
|
||||
self.tools += (tool,)
|
||||
self.tool_map[tool.name] = tool
|
||||
return self
|
||||
|
||||
def add_tools(self, *tools: BaseTool):
|
||||
"""Add multiple tools to the collection.
|
||||
|
||||
If any tool has a name conflict with an existing tool, it will be skipped and a warning will be logged.
|
||||
"""
|
||||
for tool in tools:
|
||||
self.add_tool(tool)
|
||||
return self
|
||||
|
||||
+357
-40
@@ -1,10 +1,14 @@
|
||||
import asyncio
|
||||
from typing import List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from app.config import config
|
||||
from app.tool.base import BaseTool
|
||||
from app.logger import logger
|
||||
from app.tool.base import BaseTool, ToolResult
|
||||
from app.tool.search import (
|
||||
BaiduSearchEngine,
|
||||
BingSearchEngine,
|
||||
@@ -12,13 +16,150 @@ from app.tool.search import (
|
||||
GoogleSearchEngine,
|
||||
WebSearchEngine,
|
||||
)
|
||||
from app.tool.search.base import SearchItem
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
"""Represents a single search result returned by a search engine."""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
position: int = Field(description="Position in search results")
|
||||
url: str = Field(description="URL of the search result")
|
||||
title: str = Field(default="", description="Title of the search result")
|
||||
description: str = Field(
|
||||
default="", description="Description or snippet of the search result"
|
||||
)
|
||||
source: str = Field(description="The search engine that provided this result")
|
||||
raw_content: Optional[str] = Field(
|
||||
default=None, description="Raw content from the search result page if available"
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of a search result."""
|
||||
return f"{self.title} ({self.url})"
|
||||
|
||||
|
||||
class SearchMetadata(BaseModel):
|
||||
"""Metadata about the search operation."""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
total_results: int = Field(description="Total number of results found")
|
||||
language: str = Field(description="Language code used for the search")
|
||||
country: str = Field(description="Country code used for the search")
|
||||
|
||||
|
||||
class SearchResponse(ToolResult):
|
||||
"""Structured response from the web search tool, inheriting ToolResult."""
|
||||
|
||||
query: str = Field(description="The search query that was executed")
|
||||
results: List[SearchResult] = Field(
|
||||
default_factory=list, description="List of search results"
|
||||
)
|
||||
metadata: Optional[SearchMetadata] = Field(
|
||||
default=None, description="Metadata about the search"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def populate_output(self) -> "SearchResponse":
|
||||
"""Populate output or error fields based on search results."""
|
||||
if self.error:
|
||||
return self
|
||||
|
||||
result_text = [f"Search results for '{self.query}':"]
|
||||
|
||||
for i, result in enumerate(self.results, 1):
|
||||
# Add title with position number
|
||||
title = result.title.strip() or "No title"
|
||||
result_text.append(f"\n{i}. {title}")
|
||||
|
||||
# Add URL with proper indentation
|
||||
result_text.append(f" URL: {result.url}")
|
||||
|
||||
# Add description if available
|
||||
if result.description.strip():
|
||||
result_text.append(f" Description: {result.description}")
|
||||
|
||||
# Add content preview if available
|
||||
if result.raw_content:
|
||||
content_preview = result.raw_content[:1000].replace("\n", " ").strip()
|
||||
if len(result.raw_content) > 1000:
|
||||
content_preview += "..."
|
||||
result_text.append(f" Content: {content_preview}")
|
||||
|
||||
# Add metadata at the bottom if available
|
||||
if self.metadata:
|
||||
result_text.extend(
|
||||
[
|
||||
f"\nMetadata:",
|
||||
f"- Total results: {self.metadata.total_results}",
|
||||
f"- Language: {self.metadata.language}",
|
||||
f"- Country: {self.metadata.country}",
|
||||
]
|
||||
)
|
||||
|
||||
self.output = "\n".join(result_text)
|
||||
return self
|
||||
|
||||
|
||||
class WebContentFetcher:
|
||||
"""Utility class for fetching web content."""
|
||||
|
||||
@staticmethod
|
||||
async def fetch_content(url: str, timeout: int = 10) -> Optional[str]:
|
||||
"""
|
||||
Fetch and extract the main content from a webpage.
|
||||
|
||||
Args:
|
||||
url: The URL to fetch content from
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Extracted text content or None if fetching fails
|
||||
"""
|
||||
headers = {
|
||||
"WebSearch": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
}
|
||||
|
||||
try:
|
||||
# Use asyncio to run requests in a thread pool
|
||||
response = await asyncio.get_event_loop().run_in_executor(
|
||||
None, lambda: requests.get(url, headers=headers, timeout=timeout)
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
f"Failed to fetch content from {url}: HTTP {response.status_code}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Parse HTML with BeautifulSoup
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# Remove script and style elements
|
||||
for script in soup(["script", "style", "header", "footer", "nav"]):
|
||||
script.extract()
|
||||
|
||||
# Get text content
|
||||
text = soup.get_text(separator="\n", strip=True)
|
||||
|
||||
# Clean up whitespace and limit size (100KB max)
|
||||
text = " ".join(text.split())
|
||||
return text[:10000] if text else None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error fetching content from {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class WebSearch(BaseTool):
|
||||
"""Search the web for information using various search engines."""
|
||||
|
||||
name: str = "web_search"
|
||||
description: str = """Perform a web search and return a list of relevant links.
|
||||
This function attempts to use the primary search engine API to get up-to-date results.
|
||||
If an error occurs, it falls back to an alternative search engine."""
|
||||
description: str = """Search the web for real-time information about any topic.
|
||||
This tool returns comprehensive search results with relevant information, URLs, titles, and descriptions.
|
||||
If the primary search engine fails, it automatically falls back to alternative engines."""
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -28,8 +169,23 @@ class WebSearch(BaseTool):
|
||||
},
|
||||
"num_results": {
|
||||
"type": "integer",
|
||||
"description": "(optional) The number of search results to return. Default is 10.",
|
||||
"default": 10,
|
||||
"description": "(optional) The number of search results to return. Default is 5.",
|
||||
"default": 5,
|
||||
},
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"description": "(optional) Language code for search results (default: en).",
|
||||
"default": "en",
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": "(optional) Country code for search results (default: us).",
|
||||
"default": "us",
|
||||
},
|
||||
"fetch_content": {
|
||||
"type": "boolean",
|
||||
"description": "(optional) Whether to fetch full content from result pages. Default is false.",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
@@ -40,62 +196,223 @@ class WebSearch(BaseTool):
|
||||
"duckduckgo": DuckDuckGoSearchEngine(),
|
||||
"bing": BingSearchEngine(),
|
||||
}
|
||||
content_fetcher: WebContentFetcher = WebContentFetcher()
|
||||
|
||||
async def execute(self, query: str, num_results: int = 10) -> List[str]:
|
||||
async def execute(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 5,
|
||||
lang: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
fetch_content: bool = False,
|
||||
) -> SearchResponse:
|
||||
"""
|
||||
Execute a Web search and return a list of URLs.
|
||||
Execute a Web search and return detailed search results.
|
||||
|
||||
Args:
|
||||
query (str): The search query to submit to the search engine.
|
||||
num_results (int, optional): The number of search results to return. Default is 10.
|
||||
query: The search query to submit to the search engine
|
||||
num_results: The number of search results to return (default: 5)
|
||||
lang: Language code for search results (default from config)
|
||||
country: Country code for search results (default from config)
|
||||
fetch_content: Whether to fetch content from result pages (default: False)
|
||||
|
||||
Returns:
|
||||
List[str]: A list of URLs matching the search query.
|
||||
A structured response containing search results and metadata
|
||||
"""
|
||||
# Get settings from config
|
||||
retry_delay = (
|
||||
getattr(config.search_config, "retry_delay", 60)
|
||||
if config.search_config
|
||||
else 60
|
||||
)
|
||||
max_retries = (
|
||||
getattr(config.search_config, "max_retries", 3)
|
||||
if config.search_config
|
||||
else 3
|
||||
)
|
||||
|
||||
# Use config values for lang and country if not specified
|
||||
if lang is None:
|
||||
lang = (
|
||||
getattr(config.search_config, "lang", "en")
|
||||
if config.search_config
|
||||
else "en"
|
||||
)
|
||||
|
||||
if country is None:
|
||||
country = (
|
||||
getattr(config.search_config, "country", "us")
|
||||
if config.search_config
|
||||
else "us"
|
||||
)
|
||||
|
||||
search_params = {"lang": lang, "country": country}
|
||||
|
||||
# Try searching with retries when all engines fail
|
||||
for retry_count in range(max_retries + 1):
|
||||
results = await self._try_all_engines(query, num_results, search_params)
|
||||
|
||||
if results:
|
||||
# Fetch content if requested
|
||||
if fetch_content:
|
||||
results = await self._fetch_content_for_results(results)
|
||||
|
||||
# Return a successful structured response
|
||||
return SearchResponse(
|
||||
status="success",
|
||||
query=query,
|
||||
results=results,
|
||||
metadata=SearchMetadata(
|
||||
total_results=len(results),
|
||||
language=lang,
|
||||
country=country,
|
||||
),
|
||||
)
|
||||
|
||||
if retry_count < max_retries:
|
||||
# All engines failed, wait and retry
|
||||
logger.warning(
|
||||
f"All search engines failed. Waiting {retry_delay} seconds before retry {retry_count + 1}/{max_retries}..."
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
logger.error(
|
||||
f"All search engines failed after {max_retries} retries. Giving up."
|
||||
)
|
||||
|
||||
# Return an error response
|
||||
return SearchResponse(
|
||||
query=query,
|
||||
error="All search engines failed to return results after multiple retries.",
|
||||
results=[],
|
||||
)
|
||||
|
||||
async def _try_all_engines(
|
||||
self, query: str, num_results: int, search_params: Dict[str, Any]
|
||||
) -> List[SearchResult]:
|
||||
"""Try all search engines in the configured order."""
|
||||
engine_order = self._get_engine_order()
|
||||
failed_engines = []
|
||||
|
||||
for engine_name in engine_order:
|
||||
engine = self._search_engine[engine_name]
|
||||
try:
|
||||
links = await self._perform_search_with_engine(
|
||||
engine, query, num_results
|
||||
logger.info(f"🔎 Attempting search with {engine_name.capitalize()}...")
|
||||
search_items = await self._perform_search_with_engine(
|
||||
engine, query, num_results, search_params
|
||||
)
|
||||
|
||||
if not search_items:
|
||||
continue
|
||||
|
||||
if failed_engines:
|
||||
logger.info(
|
||||
f"Search successful with {engine_name.capitalize()} after trying: {', '.join(failed_engines)}"
|
||||
)
|
||||
if links:
|
||||
return links
|
||||
except Exception as e:
|
||||
print(f"Search engine '{engine_name}' failed with error: {e}")
|
||||
|
||||
# Transform search items into structured results
|
||||
return [
|
||||
SearchResult(
|
||||
position=i + 1,
|
||||
url=item.url,
|
||||
title=item.title
|
||||
or f"Result {i+1}", # Ensure we always have a title
|
||||
description=item.description or "",
|
||||
source=engine_name,
|
||||
)
|
||||
for i, item in enumerate(search_items)
|
||||
]
|
||||
|
||||
if failed_engines:
|
||||
logger.error(f"All search engines failed: {', '.join(failed_engines)}")
|
||||
return []
|
||||
|
||||
async def _fetch_content_for_results(
|
||||
self, results: List[SearchResult]
|
||||
) -> List[SearchResult]:
|
||||
"""Fetch and add web content to search results."""
|
||||
if not results:
|
||||
return []
|
||||
|
||||
# Create tasks for each result
|
||||
tasks = [self._fetch_single_result_content(result) for result in results]
|
||||
|
||||
# Type annotation to help type checker
|
||||
fetched_results = await asyncio.gather(*tasks)
|
||||
|
||||
# Explicit validation of return type
|
||||
return [
|
||||
(
|
||||
result
|
||||
if isinstance(result, SearchResult)
|
||||
else SearchResult(**result.dict())
|
||||
)
|
||||
for result in fetched_results
|
||||
]
|
||||
|
||||
async def _fetch_single_result_content(self, result: SearchResult) -> SearchResult:
|
||||
"""Fetch content for a single search result."""
|
||||
if result.url:
|
||||
content = await self.content_fetcher.fetch_content(result.url)
|
||||
if content:
|
||||
result.raw_content = content
|
||||
return result
|
||||
|
||||
def _get_engine_order(self) -> List[str]:
|
||||
"""
|
||||
Determines the order in which to try search engines.
|
||||
Preferred engine is first (based on configuration), followed by the remaining engines.
|
||||
"""Determines the order in which to try search engines."""
|
||||
preferred = (
|
||||
getattr(config.search_config, "engine", "google").lower()
|
||||
if config.search_config
|
||||
else "google"
|
||||
)
|
||||
fallbacks = (
|
||||
[engine.lower() for engine in config.search_config.fallback_engines]
|
||||
if config.search_config
|
||||
and hasattr(config.search_config, "fallback_engines")
|
||||
else []
|
||||
)
|
||||
|
||||
Returns:
|
||||
List[str]: Ordered list of search engine names.
|
||||
"""
|
||||
preferred = "google"
|
||||
if config.search_config and config.search_config.engine:
|
||||
preferred = config.search_config.engine.lower()
|
||||
# Start with preferred engine, then fallbacks, then remaining engines
|
||||
engine_order = [preferred] if preferred in self._search_engine else []
|
||||
engine_order.extend(
|
||||
[
|
||||
fb
|
||||
for fb in fallbacks
|
||||
if fb in self._search_engine and fb not in engine_order
|
||||
]
|
||||
)
|
||||
engine_order.extend([e for e in self._search_engine if e not in engine_order])
|
||||
|
||||
engine_order = []
|
||||
if preferred in self._search_engine:
|
||||
engine_order.append(preferred)
|
||||
for key in self._search_engine:
|
||||
if key not in engine_order:
|
||||
engine_order.append(key)
|
||||
return engine_order
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=10),
|
||||
stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10)
|
||||
)
|
||||
async def _perform_search_with_engine(
|
||||
self,
|
||||
engine: WebSearchEngine,
|
||||
query: str,
|
||||
num_results: int,
|
||||
) -> List[str]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: list(engine.perform_search(query, num_results=num_results))
|
||||
search_params: Dict[str, Any],
|
||||
) -> List[SearchItem]:
|
||||
"""Execute search with the given engine and parameters."""
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: list(
|
||||
engine.perform_search(
|
||||
query,
|
||||
num_results=num_results,
|
||||
lang=search_params.get("lang"),
|
||||
country=search_params.get("country"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
web_search = WebSearch()
|
||||
search_response = asyncio.run(
|
||||
web_search.execute(
|
||||
query="Python programming", fetch_content=True, num_results=1
|
||||
)
|
||||
)
|
||||
print(search_response.to_tool_result())
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Global LLM configuration
|
||||
[llm]
|
||||
model = "claude-3-7-sonnet-latest" # The LLM model to use
|
||||
base_url = "https://api.anthropic.com/v1/" # API endpoint URL
|
||||
api_key = "YOUR_API_KEY" # Your API key
|
||||
max_tokens = 8192 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness
|
||||
|
||||
|
||||
# Optional configuration for specific LLM models
|
||||
[llm.vision]
|
||||
model = "claude-3-7-sonnet-20250219" # The vision model to use
|
||||
base_url = "https://api.anthropic.com/v1/" # API endpoint URL for vision model
|
||||
api_key = "YOUR_API_KEY" # Your API key for vision model
|
||||
max_tokens = 8192 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness for vision model
|
||||
@@ -0,0 +1,18 @@
|
||||
# Global LLM configuration
|
||||
[llm] #AZURE OPENAI:
|
||||
api_type= 'azure'
|
||||
model = "gpt-4o-mini" # The LLM model to use
|
||||
base_url = "{YOUR_AZURE_ENDPOINT.rstrip('/')}/openai/deployments/{AZURE_DEPLOYMENT_ID}" # API endpoint URL
|
||||
api_key = "YOUR_API_KEY" # Your API key
|
||||
max_tokens = 8096 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness
|
||||
api_version="AZURE API VERSION" #"2024-08-01-preview" # Azure Openai version if AzureOpenai
|
||||
|
||||
|
||||
# Optional configuration for specific LLM models
|
||||
[llm.vision]
|
||||
model = "gpt-4o" # The vision model to use
|
||||
base_url = "{YOUR_AZURE_ENDPOINT.rstrip('/')}/openai/deployments/{AZURE_DEPLOYMENT_ID}"
|
||||
api_key = "YOUR_API_KEY" # Your API key for vision model
|
||||
max_tokens = 8192 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness for vision model
|
||||
@@ -0,0 +1,16 @@
|
||||
# Global LLM configuration
|
||||
[llm]
|
||||
model = "gemini-2.0-flash" # The LLM model to use
|
||||
base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" # API endpoint URL
|
||||
api_key = "YOUR_API_KEY" # Your API key
|
||||
temperature = 0.0 # Controls randomness
|
||||
max_tokens = 8096 # Maximum number of tokens in the response
|
||||
|
||||
|
||||
# Optional configuration for specific LLM models for Google
|
||||
[llm.vision]
|
||||
model = "gemini-2.0-flash-exp" # The vision model to use
|
||||
base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" # API endpoint URL for vision model
|
||||
api_key = "YOUR_API_KEY" # Your API key for vision model
|
||||
max_tokens = 8192 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness for vision model
|
||||
@@ -0,0 +1,17 @@
|
||||
# Global LLM configuration
|
||||
[llm] #OLLAMA:
|
||||
api_type = 'ollama'
|
||||
model = "llama3.2" # The LLM model to use
|
||||
base_url = "http://localhost:11434/v1" # API endpoint URL
|
||||
api_key = "ollama" # Your API key
|
||||
max_tokens = 4096 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness
|
||||
|
||||
|
||||
[llm.vision] #OLLAMA VISION:
|
||||
api_type = 'ollama'
|
||||
model = "llama3.2-vision" # The vision model to use
|
||||
base_url = "http://localhost:11434/v1" # API endpoint URL for vision model
|
||||
api_key = "ollama" # Your API key for vision model
|
||||
max_tokens = 4096 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness for vision model
|
||||
@@ -0,0 +1,17 @@
|
||||
# Global LLM configuration
|
||||
[llm] #PPIO:
|
||||
api_type = 'ppio'
|
||||
model = "deepseek/deepseek-v3-0324" # The LLM model to use
|
||||
base_url = "https://api.ppinfra.com/v3/openai" # API endpoint URL
|
||||
api_key = "your ppio api key" # Your API key
|
||||
max_tokens = 16000 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness
|
||||
|
||||
|
||||
[llm.vision] #PPIO VISION:
|
||||
api_type = 'ppio'
|
||||
model = "qwen/qwen2.5-vl-72b-instruct" # The vision model to use
|
||||
base_url = "https://api.ppinfra.com/v3/openai" # API endpoint URL for vision model
|
||||
api_key = "your ppio api key" # Your API key for vision model
|
||||
max_tokens = 96000 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness for vision model
|
||||
+47
-12
@@ -1,15 +1,23 @@
|
||||
# Global LLM configuration
|
||||
[llm]
|
||||
model = "claude-3-7-sonnet-20250219" # The LLM model to use
|
||||
base_url = "https://api.anthropic.com/v1/" # API endpoint URL
|
||||
api_key = "YOUR_API_KEY" # Your API key
|
||||
max_tokens = 8192 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness
|
||||
model = "claude-3-7-sonnet-20250219" # The LLM model to use
|
||||
base_url = "https://api.anthropic.com/v1/" # API endpoint URL
|
||||
api_key = "YOUR_API_KEY" # Your API key
|
||||
max_tokens = 8192 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness
|
||||
|
||||
# [llm] # Amazon Bedrock
|
||||
# api_type = "aws" # Required
|
||||
# model = "us.anthropic.claude-3-7-sonnet-20250219-v1:0" # Bedrock supported modelID
|
||||
# base_url = "bedrock-runtime.us-west-2.amazonaws.com" # Not used now
|
||||
# max_tokens = 8192
|
||||
# temperature = 1.0
|
||||
# api_key = "bear" # Required but not used for Bedrock
|
||||
|
||||
# [llm] #AZURE OPENAI:
|
||||
# api_type= 'azure'
|
||||
# model = "YOUR_MODEL_NAME" #"gpt-4o-mini"
|
||||
# base_url = "{YOUR_AZURE_ENDPOINT.rstrip('/')}/openai/deployments/{AZURE_DEPOLYMENT_ID}"
|
||||
# base_url = "{YOUR_AZURE_ENDPOINT.rstrip('/')}/openai/deployments/{AZURE_DEPLOYMENT_ID}"
|
||||
# api_key = "AZURE API KEY"
|
||||
# max_tokens = 8096
|
||||
# temperature = 0.0
|
||||
@@ -25,11 +33,11 @@ temperature = 0.0 # Controls randomness
|
||||
|
||||
# Optional configuration for specific LLM models
|
||||
[llm.vision]
|
||||
model = "claude-3-7-sonnet-20250219" # The vision model to use
|
||||
base_url = "https://api.anthropic.com/v1/" # API endpoint URL for vision model
|
||||
api_key = "YOUR_API_KEY" # Your API key for vision model
|
||||
max_tokens = 8192 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness for vision model
|
||||
model = "claude-3-7-sonnet-20250219" # The vision model to use
|
||||
base_url = "https://api.anthropic.com/v1/" # API endpoint URL for vision model
|
||||
api_key = "YOUR_API_KEY" # Your API key for vision model
|
||||
max_tokens = 8192 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness for vision model
|
||||
|
||||
# [llm.vision] #OLLAMA VISION:
|
||||
# api_type = 'ollama'
|
||||
@@ -63,8 +71,19 @@ temperature = 0.0 # Controls randomness for vision mod
|
||||
|
||||
# Optional configuration, Search settings.
|
||||
# [search]
|
||||
# Search engine for agent to use. Default is "Google", can be set to "Baidu" or "DuckDuckGo".
|
||||
# Search engine for agent to use. Default is "Google", can be set to "Baidu" or "DuckDuckGo" or "Bing".
|
||||
#engine = "Google"
|
||||
# Fallback engine order. Default is ["DuckDuckGo", "Baidu", "Bing"] - will try in this order after primary engine fails.
|
||||
#fallback_engines = ["DuckDuckGo", "Baidu", "Bing"]
|
||||
# Seconds to wait before retrying all engines again when they all fail due to rate limits. Default is 60.
|
||||
#retry_delay = 60
|
||||
# Maximum number of times to retry all engines when all fail. Default is 3.
|
||||
#max_retries = 3
|
||||
# Language code for search results. Options: "en" (English), "zh" (Chinese), etc.
|
||||
#lang = "en"
|
||||
# Country code for search results. Options: "us" (United States), "cn" (China), etc.
|
||||
#country = "us"
|
||||
|
||||
|
||||
## Sandbox configuration
|
||||
#[sandbox]
|
||||
@@ -75,3 +94,19 @@ temperature = 0.0 # Controls randomness for vision mod
|
||||
#cpu_limit = 2.0
|
||||
#timeout = 300
|
||||
#network_enabled = true
|
||||
|
||||
# [cloud_sandbox]
|
||||
# e2b_api_key = "YOUR_E2B_API_KEY"
|
||||
# domain = "YOUR_E2B_DOMAIN"
|
||||
|
||||
|
||||
# MCP (Model Context Protocol) configuration
|
||||
[mcp]
|
||||
server_reference = "app.mcp.server" # default server module reference
|
||||
|
||||
# Optional Runflow configuration
|
||||
# Your can add additional agents into run-flow workflow to solve different-type tasks.
|
||||
[runflow]
|
||||
use_data_analysis_agent = false # The Data Analysi Agent to solve various data analysis tasks
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"server1": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:8000/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
OpenManus benchmark system for standardized agent evaluation.
|
||||
"""
|
||||
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
@@ -11,6 +11,6 @@ The Model we use is `claude3.5`.
|
||||
I need a 7-day Japan itinerary for April 15-23 from Seattle, with a $2500-5000 budget for my fiancée and me. We love historical sites, hidden gems, and Japanese culture (kendo, tea ceremonies, Zen meditation). We want to see Nara's deer and explore cities on foot. I plan to propose during this trip and need a special location recommendation. Please provide a detailed itinerary and a simple HTML travel handbook with maps, attraction descriptions, essential Japanese phrases, and travel tips we can reference throughout our journey.
|
||||
```
|
||||
**preview**:
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
@@ -1,3 +1,4 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from app.agent.manus import Manus
|
||||
@@ -5,9 +6,18 @@ from app.logger import logger
|
||||
|
||||
|
||||
async def main():
|
||||
agent = Manus()
|
||||
# Parse command line arguments
|
||||
parser = argparse.ArgumentParser(description="Run Manus agent with a prompt")
|
||||
parser.add_argument(
|
||||
"--prompt", type=str, required=False, help="Input prompt for the agent"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Create and initialize Manus agent
|
||||
agent = await Manus.create()
|
||||
try:
|
||||
prompt = input("Enter your prompt: ")
|
||||
# Use command line prompt if provided, otherwise ask for input
|
||||
prompt = args.prompt if args.prompt else input("Enter your prompt: ")
|
||||
if not prompt.strip():
|
||||
logger.warning("Empty prompt provided.")
|
||||
return
|
||||
@@ -17,6 +27,9 @@ async def main():
|
||||
logger.info("Request processing completed.")
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Operation interrupted.")
|
||||
finally:
|
||||
# Ensure agent resources are cleaned up before exiting
|
||||
await agent.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# 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 a2a-sdk==0.2.5
|
||||
|
||||
|
||||
|
||||
## Setup & Running
|
||||
|
||||
1. Run A2A Server:
|
||||
|
||||
```bash
|
||||
cd OpenManus
|
||||
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):
|
||||
|
||||
```bash
|
||||
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 CLI or Register A2A Agent Server in UI
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
**Get Agent Card**
|
||||
|
||||
Request:
|
||||
|
||||
```
|
||||
curl http://localhost:10000/.well-known/agent.json
|
||||
|
||||
```
|
||||
|
||||
|
||||
```
|
||||
Response:
|
||||
|
||||
{
|
||||
"capabilities": {
|
||||
"pushNotifications": true,
|
||||
"streaming": false
|
||||
},
|
||||
"defaultInputModes": [
|
||||
"text",
|
||||
"text/plain"
|
||||
],
|
||||
"defaultOutputModes": [
|
||||
"text",
|
||||
"text/plain"
|
||||
],
|
||||
"description": "A versatile agent that can solve various tasks using multiple tools including MCP-based tools",
|
||||
"name": "Manus Agent",
|
||||
"skills": [
|
||||
{
|
||||
"description": "Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.",
|
||||
"examples": [
|
||||
"Execute Python code:'''python \n Print('Hello World') \n '''"
|
||||
],
|
||||
"id": "Python Execute",
|
||||
"name": "Python Execute Tool",
|
||||
"tags": [
|
||||
"Execute Python Code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"examples": [
|
||||
"go_to 'https://www.google.com'"
|
||||
],
|
||||
"id": "Browser use",
|
||||
"name": "Browser use Tool",
|
||||
"tags": [
|
||||
"Use Browser"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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 `<response clipped>`\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",
|
||||
"examples": [
|
||||
"Replace 'old' with 'new' in 'file.txt'"
|
||||
],
|
||||
"id": "Replace String",
|
||||
"name": "Str_replace Tool",
|
||||
"tags": [
|
||||
"Operate Files"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Use this tool to ask human for help.",
|
||||
"examples": [
|
||||
"Ask human: 'What time is it?'"
|
||||
],
|
||||
"id": "Ask human",
|
||||
"name": "Ask human Tool",
|
||||
"tags": [
|
||||
"Ask human for help"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"examples": [
|
||||
"terminate"
|
||||
],
|
||||
"id": "terminate",
|
||||
"name": "terminate Tool",
|
||||
"tags": [
|
||||
"terminate task"
|
||||
]
|
||||
}
|
||||
],
|
||||
"url": "http://localhost:10000/",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
**Send Task**
|
||||
|
||||
Request:
|
||||
|
||||
```
|
||||
curl --location 'http://localhost:10000' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"id":130,
|
||||
"jsonrpc":"2.0",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "",
|
||||
"role": "user",
|
||||
"parts": [{"text":"什么是快乐星球"}]
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```
|
||||
{
|
||||
"id": 130,
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactId": "2f9d0af8-c7da-4f88-9c8c-3033836322b8",
|
||||
"description": "",
|
||||
"name": "task_cf64d3c9-1e08-4948-a620-76900aa204cf",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "Step 1: “快乐星球”是一个流行的网络用语,源自中国儿童科幻电视剧《快乐星球》。这部剧讲述了一群孩子在一个虚构的“快乐星球”上经历的冒险故事,主题围绕着友谊、成长和科学幻想。后来,“快乐星球”逐渐成为一种网络梗,用来形容一种无忧无虑、充满快乐的理想状态。\n\n如果你对这个词的具体含义、出处或者相关的文化背景有更多兴趣,可以告诉我,我可以为你提供更详细的信息!\nStep 2: Observed output of cmd `terminate` executed:\nThe interaction has been completed with status: success"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Learn More
|
||||
|
||||
- [A2A Protocol Documentation](https://google.github.io/A2A/#/documentation)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Manus Agent with A2A Protocol
|
||||
|
||||
这是一个将A2A协议(https://google.github.io/A2A/#/documentation)与OpenManus结合的一个尝试,当前仅支持非流式
|
||||
|
||||
## Prerequisites
|
||||
- conda activate 'Your OpenManus python env'
|
||||
- pip install a2a-sdk==0.2.5
|
||||
|
||||
|
||||
|
||||
## Setup & Running
|
||||
|
||||
1. 运行A2A Server:
|
||||
|
||||
```bash
|
||||
cd OpenManus
|
||||
python -m protocol.a2a.app.main
|
||||
```
|
||||
|
||||
2. 拉取A2A官方库并运行A2A Client,有两种使用A2A客户端的方式——CLI以及在前端页面注册Agent服务。(详情参考https://github.com/google/A2A):
|
||||
|
||||
```bash
|
||||
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发送任务或者在A2A前端页面上将其注册
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
**获得Agent Card**
|
||||
|
||||
Request:
|
||||
|
||||
```
|
||||
curl http://localhost:10000/.well-known/agent.json
|
||||
|
||||
```
|
||||
|
||||
|
||||
```
|
||||
Response:
|
||||
|
||||
{
|
||||
"capabilities": {
|
||||
"pushNotifications": true,
|
||||
"streaming": false
|
||||
},
|
||||
"defaultInputModes": [
|
||||
"text",
|
||||
"text/plain"
|
||||
],
|
||||
"defaultOutputModes": [
|
||||
"text",
|
||||
"text/plain"
|
||||
],
|
||||
"description": "A versatile agent that can solve various tasks using multiple tools including MCP-based tools",
|
||||
"name": "Manus Agent",
|
||||
"skills": [
|
||||
{
|
||||
"description": "Executes Python code string. Note: Only print outputs are visible, function return values are not captured. Use print statements to see results.",
|
||||
"examples": [
|
||||
"Execute Python code:'''python \n Print('Hello World') \n '''"
|
||||
],
|
||||
"id": "Python Execute",
|
||||
"name": "Python Execute Tool",
|
||||
"tags": [
|
||||
"Execute Python Code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"examples": [
|
||||
"go_to 'https://www.google.com'"
|
||||
],
|
||||
"id": "Browser use",
|
||||
"name": "Browser use Tool",
|
||||
"tags": [
|
||||
"Use Browser"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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 `<response clipped>`\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",
|
||||
"examples": [
|
||||
"Replace 'old' with 'new' in 'file.txt'"
|
||||
],
|
||||
"id": "Replace String",
|
||||
"name": "Str_replace Tool",
|
||||
"tags": [
|
||||
"Operate Files"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Use this tool to ask human for help.",
|
||||
"examples": [
|
||||
"Ask human: 'What time is it?'"
|
||||
],
|
||||
"id": "Ask human",
|
||||
"name": "Ask human Tool",
|
||||
"tags": [
|
||||
"Ask human for help"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"examples": [
|
||||
"terminate"
|
||||
],
|
||||
"id": "terminate",
|
||||
"name": "terminate Tool",
|
||||
"tags": [
|
||||
"terminate task"
|
||||
]
|
||||
}
|
||||
],
|
||||
"url": "http://localhost:10000/",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
**发送任务**
|
||||
|
||||
Request:
|
||||
|
||||
```
|
||||
curl --location 'http://localhost:10000' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"id":130,
|
||||
"jsonrpc":"2.0",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "",
|
||||
"role": "user",
|
||||
"parts": [{"text":"什么是快乐星球"}]
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```
|
||||
{
|
||||
"id": 130,
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactId": "2f9d0af8-c7da-4f88-9c8c-3033836322b8",
|
||||
"description": "",
|
||||
"name": "task_cf64d3c9-1e08-4948-a620-76900aa204cf",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "Step 1: “快乐星球”是一个流行的网络用语,源自中国儿童科幻电视剧《快乐星球》。这部剧讲述了一群孩子在一个虚构的“快乐星球”上经历的冒险故事,主题围绕着友谊、成长和科学幻想。后来,“快乐星球”逐渐成为一种网络梗,用来形容一种无忧无虑、充满快乐的理想状态。\n\n如果你对这个词的具体含义、出处或者相关的文化背景有更多兴趣,可以告诉我,我可以为你提供更详细的信息!\nStep 2: Observed output of cmd `terminate` executed:\nThe interaction has been completed with status: success"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Learn More
|
||||
|
||||
- [A2A Protocol Documentation](https://google.github.io/A2A/#/documentation)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import httpx
|
||||
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)
|
||||
|
||||
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: ClassVar[List[str]] = ["text", "text/plain"]
|
||||
@@ -0,0 +1,74 @@
|
||||
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 .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())
|
||||
@@ -0,0 +1,134 @@
|
||||
import httpx
|
||||
import argparse
|
||||
|
||||
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 .agent_executor import ManusExecutor
|
||||
|
||||
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
|
||||
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(streaming=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,
|
||||
)
|
||||
|
||||
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 = A2AStarletteApplication(
|
||||
agent_card=agent_card, http_handler=request_handler
|
||||
)
|
||||
|
||||
logger.info(f"Starting server on {host}:{port}")
|
||||
return server.build()
|
||||
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
|
||||
|
||||
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:
|
||||
logger.error(f"An error occurred while starting the server: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 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)
|
||||
+14
-6
@@ -4,30 +4,38 @@ tenacity~=9.0.0
|
||||
pyyaml~=6.0.2
|
||||
loguru~=0.7.3
|
||||
numpy
|
||||
datasets~=3.2.0
|
||||
datasets~=3.4.1
|
||||
fastapi~=0.115.11
|
||||
tiktoken~=0.9.0
|
||||
|
||||
html2text~=2024.2.26
|
||||
gymnasium~=1.0.0
|
||||
pillow~=10.4.0
|
||||
gymnasium~=1.1.1
|
||||
pillow~=11.1.0
|
||||
browsergym~=0.13.3
|
||||
uvicorn~=0.34.0
|
||||
unidiff~=0.7.5
|
||||
browser-use~=0.1.40
|
||||
googlesearch-python~=1.3.0
|
||||
baidusearch~=1.0.3
|
||||
duckduckgo_search~=7.5.1
|
||||
duckduckgo_search~=7.5.3
|
||||
|
||||
aiofiles~=24.1.0
|
||||
pydantic_core~=2.27.2
|
||||
colorama~=0.4.6
|
||||
playwright~=1.50.0
|
||||
playwright~=1.51.0
|
||||
|
||||
docker~=7.1.0
|
||||
pytest~=8.3.5
|
||||
pytest-asyncio~=0.25.3
|
||||
|
||||
mcp~=1.4.1
|
||||
mcp~=1.5.0
|
||||
httpx>=0.27.0
|
||||
tomli>=2.0.0
|
||||
|
||||
boto3~=1.37.18
|
||||
|
||||
requests~=2.32.3
|
||||
beautifulsoup4~=4.13.3
|
||||
|
||||
huggingface-hub~=0.29.2
|
||||
setuptools~=75.8.0
|
||||
|
||||
+5
-3
@@ -1,9 +1,10 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from app.agent.data_analysis import DataAnalysis
|
||||
from app.agent.manus import Manus
|
||||
from app.flow.base import FlowType
|
||||
from app.flow.flow_factory import FlowFactory
|
||||
from app.config import config
|
||||
from app.flow.flow_factory import FlowFactory, FlowType
|
||||
from app.logger import logger
|
||||
|
||||
|
||||
@@ -11,7 +12,8 @@ async def run_flow():
|
||||
agents = {
|
||||
"manus": Manus(),
|
||||
}
|
||||
|
||||
if config.run_flow_config.use_data_analysis_agent:
|
||||
agents["data_analysis"] = DataAnalysis()
|
||||
try:
|
||||
prompt = input("Enter your prompt: ")
|
||||
|
||||
|
||||
+15
-6
@@ -13,10 +13,14 @@ class MCPRunner:
|
||||
|
||||
def __init__(self):
|
||||
self.root_path = config.root_path
|
||||
self.server_script = self.root_path / "app" / "mcp" / "server.py"
|
||||
self.server_reference = config.mcp_config.server_reference
|
||||
self.agent = MCPAgent()
|
||||
|
||||
async def initialize(self, connection_type: str, server_url: str = None) -> None:
|
||||
async def initialize(
|
||||
self,
|
||||
connection_type: str,
|
||||
server_url: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the MCP agent with the appropriate connection."""
|
||||
logger.info(f"Initializing MCPAgent with {connection_type} connection...")
|
||||
|
||||
@@ -24,7 +28,7 @@ class MCPRunner:
|
||||
await self.agent.initialize(
|
||||
connection_type="stdio",
|
||||
command=sys.executable,
|
||||
args=[str(self.server_script)],
|
||||
args=["-m", self.server_reference],
|
||||
)
|
||||
else: # sse
|
||||
await self.agent.initialize(connection_type="sse", server_url=server_url)
|
||||
@@ -47,9 +51,14 @@ class MCPRunner:
|
||||
|
||||
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."
|
||||
)
|
||||
prompt = input("Enter your prompt: ")
|
||||
if not prompt.strip():
|
||||
logger.warning("Empty prompt provided.")
|
||||
return
|
||||
|
||||
logger.warning("Processing your request...")
|
||||
await self.agent.run(prompt)
|
||||
logger.info("Request processing completed.")
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up agent resources."""
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# coding: utf-8
|
||||
# A shortcut to launch OpenManus MCP server, where its introduction also solves other import issues.
|
||||
from app.mcp.server import MCPServer, parse_args
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
|
||||
# Create and run server (maintaining original flow)
|
||||
server = MCPServer()
|
||||
server.run(transport=args.transport)
|
||||
@@ -12,7 +12,7 @@ setup(
|
||||
description="A versatile agent that can solve various tasks using multiple tools",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/mannaandpoem/OpenManus",
|
||||
url="https://github.com/FoundationAgents/OpenManus",
|
||||
packages=find_packages(),
|
||||
install_requires=[
|
||||
"pydantic~=2.10.4",
|
||||
@@ -21,10 +21,10 @@ setup(
|
||||
"pyyaml~=6.0.2",
|
||||
"loguru~=0.7.3",
|
||||
"numpy",
|
||||
"datasets~=3.2.0",
|
||||
"datasets>=3.2,<3.5",
|
||||
"html2text~=2024.2.26",
|
||||
"gymnasium~=1.0.0",
|
||||
"pillow~=10.4.0",
|
||||
"gymnasium>=1.0,<1.2",
|
||||
"pillow>=10.4,<11.2",
|
||||
"browsergym~=0.13.3",
|
||||
"uvicorn~=0.34.0",
|
||||
"unidiff~=0.7.5",
|
||||
|
||||
Reference in New Issue
Block a user