Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52a13f2a57 | |||
| 211a245c95 | |||
| 872d5bb59e | |||
| 37e7f9c8f9 | |||
| 67d6c1c00d | |||
| efccc9fcc9 | |||
| c26bc9262e | |||
| 52cf406467 | |||
| 20c7207bda | |||
| decd25ab34 | |||
| 5e7aba3e4b | |||
| 3e604acb93 | |||
| 23f2e85ae9 | |||
| 9cb3bd16c4 | |||
| 802e212ef2 | |||
| f08ed23914 | |||
| a010fe7048 | |||
| 386aeb46b6 | |||
| 66f4ae046e | |||
| f99dfac5bb | |||
| ac66e8fd44 | |||
| 08cd0443b6 | |||
| 50f56f5708 | |||
| 4a28a7912c | |||
| 0246513495 | |||
| c94422748a | |||
| 3301d54625 | |||
| 0a6207d26f | |||
| 398ae434ed | |||
| 5731b63984 | |||
| 6fb48f4dbe | |||
| c016d5627c | |||
| be1e22c79c | |||
| e4c4d4a00f | |||
| c37a145edc | |||
| cc283546c0 | |||
| 7ce31d1c34 | |||
| 904dec240b | |||
| 17e1becb9f | |||
| 41e12eb1de | |||
| fb30c432f3 | |||
| 686ace2b09 | |||
| d7e3c14b60 | |||
| 2a6287422d | |||
| 9513686155 | |||
| a6a45d7e02 | |||
| 527fd6483b | |||
| fd5b070cb4 | |||
| 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 | |||
| b8bd822b39 | |||
| 49d1e99728 | |||
| cecedb62bd | |||
| 0a4dce5522 | |||
| 76d62a6ab3 | |||
| d3313a6b39 | |||
| f9ad4362f6 | |||
| 6a2ff78ffd | |||
| b0e9384502 | |||
| 96c23f1f56 | |||
| 628aaf00d1 | |||
| 722d5c787d | |||
| 8f89685f02 | |||
| 449ab5e25a | |||
| f21c7a2e55 | |||
| 14bf016134 | |||
| 0d6e3e5457 | |||
| 802274bd3a | |||
| 56e4c8a0d7 | |||
| a0a535e2f9 |
@@ -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
|
||||
|
||||
+13
-15
@@ -16,24 +16,22 @@ repos:
|
||||
rev: v2.0.1
|
||||
hooks:
|
||||
- id: autoflake
|
||||
args: [
|
||||
--remove-all-unused-imports,
|
||||
--ignore-init-module-imports,
|
||||
--expand-star-imports,
|
||||
--remove-duplicate-keys,
|
||||
--remove-unused-variables,
|
||||
--recursive,
|
||||
--in-place,
|
||||
--exclude=__init__.py,
|
||||
]
|
||||
args:
|
||||
[
|
||||
--remove-all-unused-imports,
|
||||
--ignore-init-module-imports,
|
||||
--expand-star-imports,
|
||||
--remove-duplicate-keys,
|
||||
--remove-unused-variables,
|
||||
--recursive,
|
||||
--in-place,
|
||||
--exclude=__init__.py,
|
||||
]
|
||||
files: \.py$
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
args: [
|
||||
"--profile", "black",
|
||||
"--filter-files",
|
||||
"--lines-after-imports=2",
|
||||
]
|
||||
args:
|
||||
["--profile", "black", "--filter-files", "--lines-after-imports=2"]
|
||||
|
||||
+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
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
|
||||
English | [中文](README_zh.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 +43,7 @@ conda activate open_manus
|
||||
2. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
git clone https://github.com/FoundationAgents/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
@@ -63,7 +64,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
2. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
git clone https://github.com/FoundationAgents/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
@@ -136,6 +137,17 @@ For unstable multi-agent version, you also can run:
|
||||
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.
|
||||
@@ -153,12 +165,16 @@ Join our networking group on Feishu and share your experience with other develop
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#mannaandpoem/OpenManus&Date)
|
||||
[](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!
|
||||
Thanks to [anthropic-computer-use](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo), [browser-use](https://github.com/browser-use/browser-use) and [crawl4ai](https://github.com/unclecode/crawl4ai) 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).
|
||||
|
||||
@@ -169,11 +185,11 @@ OpenManus is built by contributors from MetaGPT. Huge thanks to this agent commu
|
||||
## Cite
|
||||
```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 and Bang Liu and Yuyu Luo and Chenglin Wu},
|
||||
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},
|
||||
}
|
||||
```
|
||||
|
||||
+23
-8
@@ -4,11 +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
|
||||
|
||||
@@ -42,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
|
||||
```
|
||||
|
||||
@@ -63,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
|
||||
```
|
||||
|
||||
@@ -136,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を作成するか、プルリクエストを提出してください。
|
||||
@@ -153,7 +167,7 @@ Feishuのネットワーキンググループに参加して、他の開発者
|
||||
|
||||
## スター履歴
|
||||
|
||||
[](https://star-history.com/#mannaandpoem/OpenManus&Date)
|
||||
[](https://star-history.com/#FoundationAgents/OpenManus&Date)
|
||||
|
||||
## 謝辞
|
||||
|
||||
@@ -169,10 +183,11 @@ 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},
|
||||
}
|
||||
```
|
||||
|
||||
+21
-8
@@ -4,11 +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
|
||||
|
||||
@@ -42,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
|
||||
```
|
||||
|
||||
@@ -63,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
|
||||
```
|
||||
|
||||
@@ -136,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)
|
||||
|
||||
## 기여 방법
|
||||
|
||||
모든 친절한 제안과 유용한 기여를 환영합니다! 이슈를 생성하거나 풀 리퀘스트를 제출해 주세요.
|
||||
@@ -153,7 +166,7 @@ Feishu 네트워킹 그룹에 참여하여 다른 개발자들과 경험을 공
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#mannaandpoem/OpenManus&Date)
|
||||
[](https://star-history.com/#FoundationAgents/OpenManus&Date)
|
||||
|
||||
## 감사의 글
|
||||
|
||||
@@ -169,11 +182,11 @@ 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},
|
||||
}
|
||||
```
|
||||
|
||||
+26
-10
@@ -4,11 +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
|
||||
|
||||
@@ -43,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
|
||||
```
|
||||
|
||||
@@ -64,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
|
||||
```
|
||||
|
||||
@@ -137,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。
|
||||
@@ -155,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容器
|
||||
|
||||
## 致谢
|
||||
|
||||
@@ -168,15 +185,14 @@ python run_flow.py
|
||||
|
||||
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},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.logger import logger
|
||||
from app.prompt.browser import NEXT_STEP_PROMPT, SYSTEM_PROMPT
|
||||
from app.schema import Message, ToolChoice
|
||||
from app.tool import BrowserUseTool, Terminate, ToolCollection
|
||||
from app.tool.sandbox.sb_browser_tool import SandboxBrowserTool
|
||||
|
||||
|
||||
# Avoid circular import if BrowserAgent needs BrowserContextHelper
|
||||
@@ -22,6 +23,10 @@ class BrowserContextHelper:
|
||||
|
||||
async def get_browser_state(self) -> Optional[dict]:
|
||||
browser_tool = self.agent.available_tools.get_tool(BrowserUseTool().name)
|
||||
if not browser_tool:
|
||||
browser_tool = self.agent.available_tools.get_tool(
|
||||
SandboxBrowserTool().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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
)
|
||||
+106
-12
@@ -1,24 +1,25 @@
|
||||
from typing import Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
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.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(ToolCallAgent):
|
||||
"""A versatile general-purpose agent."""
|
||||
"""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
|
||||
@@ -26,24 +27,122 @@ class Manus(ToolCallAgent):
|
||||
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."""
|
||||
if not self._initialized:
|
||||
await self.initialize_mcp_servers()
|
||||
self._initialized = True
|
||||
|
||||
original_prompt = self.next_step_prompt
|
||||
recent_messages = self.memory.messages[-3:] if self.memory.messages else []
|
||||
browser_in_use = any(
|
||||
@@ -64,8 +163,3 @@ class Manus(ToolCallAgent):
|
||||
self.next_step_prompt = original_prompt
|
||||
|
||||
return result
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up Manus agent resources."""
|
||||
if self.browser_context_helper:
|
||||
await self.browser_context_helper.cleanup_browser()
|
||||
|
||||
+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")
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
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.daytona.sandbox import create_sandbox, delete_sandbox
|
||||
from app.daytona.tool_base import SandboxToolsBase
|
||||
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.mcp import MCPClients, MCPClientTool
|
||||
from app.tool.sandbox.sb_browser_tool import SandboxBrowserTool
|
||||
from app.tool.sandbox.sb_files_tool import SandboxFilesTool
|
||||
from app.tool.sandbox.sb_shell_tool import SandboxShellTool
|
||||
from app.tool.sandbox.sb_vision_tool import SandboxVisionTool
|
||||
|
||||
|
||||
class SandboxManus(ToolCallAgent):
|
||||
"""A versatile general-purpose agent with support for both local and MCP tools."""
|
||||
|
||||
name: str = "SandboxManus"
|
||||
description: str = "A versatile agent that can solve various tasks using multiple sandbox-tools including MCP-based tools"
|
||||
|
||||
system_prompt: str = SYSTEM_PROMPT.format(directory=config.workspace_root)
|
||||
next_step_prompt: str = NEXT_STEP_PROMPT
|
||||
|
||||
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(),
|
||||
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
|
||||
sandbox_link: Optional[dict[str, dict[str, str]]] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def initialize_helper(self) -> "SandboxManus":
|
||||
"""Initialize basic components synchronously."""
|
||||
self.browser_context_helper = BrowserContextHelper(self)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
async def create(cls, **kwargs) -> "SandboxManus":
|
||||
"""Factory method to create and properly initialize a Manus instance."""
|
||||
instance = cls(**kwargs)
|
||||
await instance.initialize_mcp_servers()
|
||||
await instance.initialize_sandbox_tools()
|
||||
instance._initialized = True
|
||||
return instance
|
||||
|
||||
async def initialize_sandbox_tools(
|
||||
self,
|
||||
password: str = config.daytona.VNC_password,
|
||||
) -> None:
|
||||
try:
|
||||
# 创建新沙箱
|
||||
if password:
|
||||
sandbox = create_sandbox(password=password)
|
||||
self.sandbox = sandbox
|
||||
else:
|
||||
raise ValueError("password must be provided")
|
||||
vnc_link = sandbox.get_preview_link(6080)
|
||||
website_link = sandbox.get_preview_link(8080)
|
||||
vnc_url = vnc_link.url if hasattr(vnc_link, "url") else str(vnc_link)
|
||||
website_url = (
|
||||
website_link.url if hasattr(website_link, "url") else str(website_link)
|
||||
)
|
||||
|
||||
# Get the actual sandbox_id from the created sandbox
|
||||
actual_sandbox_id = sandbox.id if hasattr(sandbox, "id") else "new_sandbox"
|
||||
if not self.sandbox_link:
|
||||
self.sandbox_link = {}
|
||||
self.sandbox_link[actual_sandbox_id] = {
|
||||
"vnc": vnc_url,
|
||||
"website": website_url,
|
||||
}
|
||||
logger.info(f"VNC URL: {vnc_url}")
|
||||
logger.info(f"Website URL: {website_url}")
|
||||
SandboxToolsBase._urls_printed = True
|
||||
sb_tools = [
|
||||
SandboxBrowserTool(sandbox),
|
||||
SandboxFilesTool(sandbox),
|
||||
SandboxShellTool(sandbox),
|
||||
SandboxVisionTool(sandbox),
|
||||
]
|
||||
self.available_tools.add_tools(*sb_tools)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing sandbox tools: {e}")
|
||||
raise
|
||||
|
||||
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 delete_sandbox(self, sandbox_id: str) -> None:
|
||||
"""Delete a sandbox by ID."""
|
||||
try:
|
||||
await delete_sandbox(sandbox_id)
|
||||
logger.info(f"Sandbox {sandbox_id} deleted successfully")
|
||||
if sandbox_id in self.sandbox_link:
|
||||
del self.sandbox_link[sandbox_id]
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting sandbox {sandbox_id}: {e}")
|
||||
raise e
|
||||
|
||||
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()
|
||||
await self.delete_sandbox(self.sandbox.id if self.sandbox else "unknown")
|
||||
self._initialized = False
|
||||
|
||||
async def think(self) -> bool:
|
||||
"""Process current state and decide next actions with appropriate context."""
|
||||
if not self._initialized:
|
||||
await self.initialize_mcp_servers()
|
||||
self._initialized = True
|
||||
|
||||
original_prompt = self.next_step_prompt
|
||||
recent_messages = self.memory.messages[-3:] if self.memory.messages else []
|
||||
browser_in_use = any(
|
||||
tc.function.name == SandboxBrowserTool().name
|
||||
for msg in recent_messages
|
||||
if msg.tool_calls
|
||||
for tc in msg.tool_calls
|
||||
)
|
||||
|
||||
if browser_in_use:
|
||||
self.next_step_prompt = (
|
||||
await self.browser_context_helper.format_next_step_prompt()
|
||||
)
|
||||
|
||||
result = await super().think()
|
||||
|
||||
# Restore original prompt
|
||||
self.next_step_prompt = original_prompt
|
||||
|
||||
return result
|
||||
@@ -188,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)}"
|
||||
|
||||
+95
-1
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import threading
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
@@ -59,6 +60,12 @@ class SearchSettings(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class RunflowSettings(BaseModel):
|
||||
use_data_analysis_agent: bool = Field(
|
||||
default=False, description="Enable data analysis agent in run flow"
|
||||
)
|
||||
|
||||
|
||||
class BrowserSettings(BaseModel):
|
||||
headless: bool = Field(False, description="Whether to run browser in headless mode")
|
||||
disable_security: bool = Field(
|
||||
@@ -98,12 +105,70 @@ class SandboxSettings(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class DaytonaSettings(BaseModel):
|
||||
daytona_api_key: str
|
||||
daytona_server_url: Optional[str] = Field(
|
||||
"https://app.daytona.io/api", description=""
|
||||
)
|
||||
daytona_target: Optional[str] = Field("us", description="enum ['eu', 'us']")
|
||||
sandbox_image_name: Optional[str] = Field("whitezxj/sandbox:0.1.0", description="")
|
||||
sandbox_entrypoint: Optional[str] = Field(
|
||||
"/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf",
|
||||
description="",
|
||||
)
|
||||
# sandbox_id: Optional[str] = Field(
|
||||
# None, description="ID of the daytona sandbox to use, if any"
|
||||
# )
|
||||
VNC_password: Optional[str] = Field(
|
||||
"123456", description="VNC password for the vnc service in sandbox"
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
@@ -118,6 +183,12 @@ class AppConfig(BaseModel):
|
||||
None, description="Search configuration"
|
||||
)
|
||||
mcp_config: Optional[MCPSettings] = Field(None, description="MCP configuration")
|
||||
run_flow_config: Optional[RunflowSettings] = Field(
|
||||
None, description="Run flow configuration"
|
||||
)
|
||||
daytona_config: Optional[DaytonaSettings] = Field(
|
||||
None, description="Daytona configuration"
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
@@ -219,14 +290,26 @@ class Config:
|
||||
sandbox_settings = SandboxSettings(**sandbox_config)
|
||||
else:
|
||||
sandbox_settings = SandboxSettings()
|
||||
daytona_config = raw_config.get("daytona", {})
|
||||
if daytona_config:
|
||||
daytona_settings = DaytonaSettings(**daytona_config)
|
||||
else:
|
||||
daytona_settings = DaytonaSettings()
|
||||
|
||||
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()
|
||||
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()
|
||||
config_dict = {
|
||||
"llm": {
|
||||
"default": default_settings,
|
||||
@@ -239,6 +322,8 @@ class Config:
|
||||
"browser_config": browser_settings,
|
||||
"search_config": search_settings,
|
||||
"mcp_config": mcp_settings,
|
||||
"run_flow_config": run_flow_settings,
|
||||
"daytona_config": daytona_settings,
|
||||
}
|
||||
|
||||
self._config = AppConfig(**config_dict)
|
||||
@@ -251,6 +336,10 @@ class Config:
|
||||
def sandbox(self) -> SandboxSettings:
|
||||
return self._config.sandbox
|
||||
|
||||
@property
|
||||
def daytona(self) -> DaytonaSettings:
|
||||
return self._config.daytona_config
|
||||
|
||||
@property
|
||||
def browser_config(self) -> Optional[BrowserSettings]:
|
||||
return self._config.browser_config
|
||||
@@ -264,6 +353,11 @@ class Config:
|
||||
"""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 workspace_root(self) -> Path:
|
||||
"""Get the workspace root directory"""
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Agent with Daytona sandbox
|
||||
|
||||
|
||||
|
||||
|
||||
## Prerequisites
|
||||
- conda activate 'Your OpenManus python env'
|
||||
- pip install daytona==0.21.8 structlog==25.4.0
|
||||
|
||||
|
||||
|
||||
## Setup & Running
|
||||
|
||||
1. daytona config :
|
||||
```bash
|
||||
cd OpenManus
|
||||
cp config/config.example-daytona.toml config/config.toml
|
||||
```
|
||||
2. get daytona apikey :
|
||||
goto https://app.daytona.io/dashboard/keys and create your apikey
|
||||
|
||||
3. set your apikey in config.toml
|
||||
```toml
|
||||
# daytona config
|
||||
[daytona]
|
||||
daytona_api_key = ""
|
||||
#daytona_server_url = "https://app.daytona.io/api"
|
||||
#daytona_target = "us" #Daytona is currently available in the following regions:United States (us)、Europe (eu)
|
||||
#sandbox_image_name = "whitezxj/sandbox:0.1.0" #If you don't use this default image,sandbox tools may be useless
|
||||
#sandbox_entrypoint = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf" #If you change this entrypoint,server in sandbox may be useless
|
||||
#VNC_password = #The password you set to log in sandbox by VNC,it will be 123456 if you don't set
|
||||
```
|
||||
2. Run :
|
||||
|
||||
```bash
|
||||
cd OpenManus
|
||||
python sandbox_main.py
|
||||
```
|
||||
|
||||
3. Send tasks to Agent
|
||||
You can sent tasks to Agent by terminate,agent will use sandbox tools to handle your tasks.
|
||||
|
||||
4. See results
|
||||
If agent use sb_browser_use tool, you can see the operations by VNC link, The VNC link will print in the termination,e.g.:https://6080-sandbox-123456.h7890.daytona.work.
|
||||
If agent use sb_shell tool, you can see the results by terminate of sandbox in https://app.daytona.io/dashboard/sandboxes.
|
||||
Agent can use sb_files tool to operate files to sandbox.
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
You can send task e.g.:"帮我在https://hk.trip.com/travel-guide/guidebook/nanjing-9696/?ishideheader=true&isHideNavBar=YES&disableFontScaling=1&catalogId=514634&locale=zh-HK查询相关信息上制定一份南京旅游攻略,并在工作区保存为index.html"
|
||||
|
||||
Then you can see the agent's browser action in VNC link(https://6080-sandbox-123456.h7890.proxy.daytona.work) and you can see the html made by agent in Website URL(https://8080-sandbox-123456.h7890.proxy.daytona.work).
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Daytona Documentation](https://www.daytona.io/docs/)
|
||||
@@ -0,0 +1,165 @@
|
||||
import time
|
||||
|
||||
from daytona import (
|
||||
CreateSandboxFromImageParams,
|
||||
Daytona,
|
||||
DaytonaConfig,
|
||||
Resources,
|
||||
Sandbox,
|
||||
SandboxState,
|
||||
SessionExecuteRequest,
|
||||
)
|
||||
|
||||
from app.config import config
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
# load_dotenv()
|
||||
daytona_settings = config.daytona
|
||||
logger.info("Initializing Daytona sandbox configuration")
|
||||
daytona_config = DaytonaConfig(
|
||||
api_key=daytona_settings.daytona_api_key,
|
||||
server_url=daytona_settings.daytona_server_url,
|
||||
target=daytona_settings.daytona_target,
|
||||
)
|
||||
|
||||
if daytona_config.api_key:
|
||||
logger.info("Daytona API key configured successfully")
|
||||
else:
|
||||
logger.warning("No Daytona API key found in environment variables")
|
||||
|
||||
if daytona_config.server_url:
|
||||
logger.info(f"Daytona server URL set to: {daytona_config.server_url}")
|
||||
else:
|
||||
logger.warning("No Daytona server URL found in environment variables")
|
||||
|
||||
if daytona_config.target:
|
||||
logger.info(f"Daytona target set to: {daytona_config.target}")
|
||||
else:
|
||||
logger.warning("No Daytona target found in environment variables")
|
||||
|
||||
daytona = Daytona(daytona_config)
|
||||
logger.info("Daytona client initialized")
|
||||
|
||||
|
||||
async def get_or_start_sandbox(sandbox_id: str):
|
||||
"""Retrieve a sandbox by ID, check its state, and start it if needed."""
|
||||
|
||||
logger.info(f"Getting or starting sandbox with ID: {sandbox_id}")
|
||||
|
||||
try:
|
||||
sandbox = daytona.get(sandbox_id)
|
||||
|
||||
# Check if sandbox needs to be started
|
||||
if (
|
||||
sandbox.state == SandboxState.ARCHIVED
|
||||
or sandbox.state == SandboxState.STOPPED
|
||||
):
|
||||
logger.info(f"Sandbox is in {sandbox.state} state. Starting...")
|
||||
try:
|
||||
daytona.start(sandbox)
|
||||
# Wait a moment for the sandbox to initialize
|
||||
# sleep(5)
|
||||
# Refresh sandbox state after starting
|
||||
sandbox = daytona.get(sandbox_id)
|
||||
|
||||
# Start supervisord in a session when restarting
|
||||
start_supervisord_session(sandbox)
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting sandbox: {e}")
|
||||
raise e
|
||||
|
||||
logger.info(f"Sandbox {sandbox_id} is ready")
|
||||
return sandbox
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving or starting sandbox: {str(e)}")
|
||||
raise e
|
||||
|
||||
|
||||
def start_supervisord_session(sandbox: Sandbox):
|
||||
"""Start supervisord in a session."""
|
||||
session_id = "supervisord-session"
|
||||
try:
|
||||
logger.info(f"Creating session {session_id} for supervisord")
|
||||
sandbox.process.create_session(session_id)
|
||||
|
||||
# Execute supervisord command
|
||||
sandbox.process.execute_session_command(
|
||||
session_id,
|
||||
SessionExecuteRequest(
|
||||
command="exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf",
|
||||
var_async=True,
|
||||
),
|
||||
)
|
||||
time.sleep(25) # Wait a bit to ensure supervisord starts properly
|
||||
logger.info(f"Supervisord started in session {session_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting supervisord session: {str(e)}")
|
||||
raise e
|
||||
|
||||
|
||||
def create_sandbox(password: str, project_id: str = None):
|
||||
"""Create a new sandbox with all required services configured and running."""
|
||||
|
||||
logger.info("Creating new Daytona sandbox environment")
|
||||
logger.info("Configuring sandbox with browser-use image and environment variables")
|
||||
|
||||
labels = None
|
||||
if project_id:
|
||||
logger.info(f"Using sandbox_id as label: {project_id}")
|
||||
labels = {"id": project_id}
|
||||
|
||||
params = CreateSandboxFromImageParams(
|
||||
image=daytona_settings.sandbox_image_name,
|
||||
public=True,
|
||||
labels=labels,
|
||||
env_vars={
|
||||
"CHROME_PERSISTENT_SESSION": "true",
|
||||
"RESOLUTION": "1024x768x24",
|
||||
"RESOLUTION_WIDTH": "1024",
|
||||
"RESOLUTION_HEIGHT": "768",
|
||||
"VNC_PASSWORD": password,
|
||||
"ANONYMIZED_TELEMETRY": "false",
|
||||
"CHROME_PATH": "",
|
||||
"CHROME_USER_DATA": "",
|
||||
"CHROME_DEBUGGING_PORT": "9222",
|
||||
"CHROME_DEBUGGING_HOST": "localhost",
|
||||
"CHROME_CDP": "",
|
||||
},
|
||||
resources=Resources(
|
||||
cpu=2,
|
||||
memory=4,
|
||||
disk=5,
|
||||
),
|
||||
auto_stop_interval=15,
|
||||
auto_archive_interval=24 * 60,
|
||||
)
|
||||
|
||||
# Create the sandbox
|
||||
sandbox = daytona.create(params)
|
||||
logger.info(f"Sandbox created with ID: {sandbox.id}")
|
||||
|
||||
# Start supervisord in a session for new sandbox
|
||||
start_supervisord_session(sandbox)
|
||||
|
||||
logger.info(f"Sandbox environment successfully initialized")
|
||||
return sandbox
|
||||
|
||||
|
||||
async def delete_sandbox(sandbox_id: str):
|
||||
"""Delete a sandbox by its ID."""
|
||||
logger.info(f"Deleting sandbox with ID: {sandbox_id}")
|
||||
|
||||
try:
|
||||
# Get the sandbox
|
||||
sandbox = daytona.get(sandbox_id)
|
||||
|
||||
# Delete the sandbox
|
||||
daytona.delete(sandbox)
|
||||
|
||||
logger.info(f"Successfully deleted sandbox {sandbox_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting sandbox {sandbox_id}: {str(e)}")
|
||||
raise e
|
||||
@@ -0,0 +1,138 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, ClassVar, Dict, Optional
|
||||
|
||||
from daytona import Daytona, DaytonaConfig, Sandbox, SandboxState
|
||||
from pydantic import Field
|
||||
|
||||
from app.config import config
|
||||
from app.daytona.sandbox import create_sandbox, start_supervisord_session
|
||||
from app.tool.base import BaseTool
|
||||
from app.utils.files_utils import clean_path
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
# load_dotenv()
|
||||
daytona_settings = config.daytona
|
||||
daytona_config = DaytonaConfig(
|
||||
api_key=daytona_settings.daytona_api_key,
|
||||
server_url=daytona_settings.daytona_server_url,
|
||||
target=daytona_settings.daytona_target,
|
||||
)
|
||||
daytona = Daytona(daytona_config)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThreadMessage:
|
||||
"""
|
||||
Represents a message to be added to a thread.
|
||||
"""
|
||||
|
||||
type: str
|
||||
content: Dict[str, Any]
|
||||
is_llm_message: bool = False
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
timestamp: Optional[float] = field(
|
||||
default_factory=lambda: datetime.now().timestamp()
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert the message to a dictionary for API calls"""
|
||||
return {
|
||||
"type": self.type,
|
||||
"content": self.content,
|
||||
"is_llm_message": self.is_llm_message,
|
||||
"metadata": self.metadata or {},
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
|
||||
class SandboxToolsBase(BaseTool):
|
||||
"""Base class for all sandbox tools that provides project-based sandbox access."""
|
||||
|
||||
# Class variable to track if sandbox URLs have been printed
|
||||
_urls_printed: ClassVar[bool] = False
|
||||
|
||||
# Required fields
|
||||
project_id: Optional[str] = None
|
||||
# thread_manager: Optional[ThreadManager] = None
|
||||
|
||||
# Private fields (not part of the model schema)
|
||||
_sandbox: Optional[Sandbox] = None
|
||||
_sandbox_id: Optional[str] = None
|
||||
_sandbox_pass: Optional[str] = None
|
||||
workspace_path: str = Field(default="/workspace", exclude=True)
|
||||
_sessions: dict[str, str] = {}
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True # Allow non-pydantic types like ThreadManager
|
||||
underscore_attrs_are_private = True
|
||||
|
||||
async def _ensure_sandbox(self) -> Sandbox:
|
||||
"""Ensure we have a valid sandbox instance, retrieving it from the project if needed."""
|
||||
if self._sandbox is None:
|
||||
# Get or start the sandbox
|
||||
try:
|
||||
self._sandbox = create_sandbox(password=config.daytona.VNC_password)
|
||||
# Log URLs if not already printed
|
||||
if not SandboxToolsBase._urls_printed:
|
||||
vnc_link = self._sandbox.get_preview_link(6080)
|
||||
website_link = self._sandbox.get_preview_link(8080)
|
||||
|
||||
vnc_url = (
|
||||
vnc_link.url if hasattr(vnc_link, "url") else str(vnc_link)
|
||||
)
|
||||
website_url = (
|
||||
website_link.url
|
||||
if hasattr(website_link, "url")
|
||||
else str(website_link)
|
||||
)
|
||||
|
||||
print("\033[95m***")
|
||||
print(f"VNC URL: {vnc_url}")
|
||||
print(f"Website URL: {website_url}")
|
||||
print("***\033[0m")
|
||||
SandboxToolsBase._urls_printed = True
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving or starting sandbox: {str(e)}")
|
||||
raise e
|
||||
else:
|
||||
if (
|
||||
self._sandbox.state == SandboxState.ARCHIVED
|
||||
or self._sandbox.state == SandboxState.STOPPED
|
||||
):
|
||||
logger.info(f"Sandbox is in {self._sandbox.state} state. Starting...")
|
||||
try:
|
||||
daytona.start(self._sandbox)
|
||||
# Wait a moment for the sandbox to initialize
|
||||
# sleep(5)
|
||||
# Refresh sandbox state after starting
|
||||
|
||||
# Start supervisord in a session when restarting
|
||||
start_supervisord_session(self._sandbox)
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting sandbox: {e}")
|
||||
raise e
|
||||
return self._sandbox
|
||||
|
||||
@property
|
||||
def sandbox(self) -> Sandbox:
|
||||
"""Get the sandbox instance, ensuring it exists."""
|
||||
if self._sandbox is None:
|
||||
raise RuntimeError("Sandbox not initialized. Call _ensure_sandbox() first.")
|
||||
return self._sandbox
|
||||
|
||||
@property
|
||||
def sandbox_id(self) -> str:
|
||||
"""Get the sandbox ID, ensuring it exists."""
|
||||
if self._sandbox_id is None:
|
||||
raise RuntimeError(
|
||||
"Sandbox ID not initialized. Call _ensure_sandbox() first."
|
||||
)
|
||||
return self._sandbox_id
|
||||
|
||||
def clean_path(self, path: str) -> str:
|
||||
"""Clean and normalize a path to be relative to /workspace."""
|
||||
cleaned_path = clean_path(path, self.workspace_path)
|
||||
logger.debug(f"Cleaned path: {path} -> {cleaned_path}")
|
||||
return cleaned_path
|
||||
+21
-3
@@ -137,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(
|
||||
@@ -270,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
|
||||
|
||||
+3
-10
@@ -88,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"""
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
SYSTEM_PROMPT = """You are an assistant focused on Chain of Thought reasoning. For each question, please follow these steps:
|
||||
|
||||
1. Break down the problem: Divide complex problems into smaller, more manageable parts
|
||||
2. Think step by step: Think through each part in detail, showing your reasoning process
|
||||
3. Synthesize conclusions: Integrate the thinking from each part into a complete solution
|
||||
4. Provide an answer: Give a final concise answer
|
||||
|
||||
Your response should follow this format:
|
||||
Thinking: [Detailed thought process, including problem decomposition, reasoning for each step, and analysis]
|
||||
Answer: [Final answer based on the thought process, clear and concise]
|
||||
|
||||
Remember, the thinking process is more important than the final answer, as it demonstrates how you reached your conclusion.
|
||||
"""
|
||||
|
||||
NEXT_STEP_PROMPT = "Please continue your thinking based on the conversation above. If you've reached a conclusion, provide your final answer."
|
||||
+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.
|
||||
"""
|
||||
|
||||
@@ -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."""
|
||||
@@ -1,8 +1,8 @@
|
||||
from app.tool.base import BaseTool
|
||||
from app.tool.bash import Bash
|
||||
from app.tool.browser_use_tool import BrowserUseTool
|
||||
from app.tool.crawl4ai import Crawl4aiTool
|
||||
from app.tool.create_chat_completion import CreateChatCompletion
|
||||
from app.tool.deep_research import DeepResearch
|
||||
from app.tool.planning import PlanningTool
|
||||
from app.tool.str_replace_editor import StrReplaceEditor
|
||||
from app.tool.terminate import Terminate
|
||||
@@ -14,11 +14,11 @@ __all__ = [
|
||||
"BaseTool",
|
||||
"Bash",
|
||||
"BrowserUseTool",
|
||||
"DeepResearch",
|
||||
"Terminate",
|
||||
"StrReplaceEditor",
|
||||
"WebSearch",
|
||||
"ToolCollection",
|
||||
"CreateChatCompletion",
|
||||
"PlanningTool",
|
||||
"Crawl4aiTool",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
+124
-23
@@ -1,35 +1,38 @@
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.utils.logger import logger
|
||||
|
||||
class BaseTool(ABC, BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
parameters: Optional[dict] = None
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
# class BaseTool(ABC, BaseModel):
|
||||
# name: str
|
||||
# description: str
|
||||
# parameters: Optional[dict] = None
|
||||
|
||||
async def __call__(self, **kwargs) -> Any:
|
||||
"""Execute the tool with given parameters."""
|
||||
return await self.execute(**kwargs)
|
||||
# class Config:
|
||||
# arbitrary_types_allowed = True
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, **kwargs) -> Any:
|
||||
"""Execute the tool with given parameters."""
|
||||
# async def __call__(self, **kwargs) -> Any:
|
||||
# """Execute the tool with given parameters."""
|
||||
# return await self.execute(**kwargs)
|
||||
|
||||
def to_param(self) -> Dict:
|
||||
"""Convert tool to function call format."""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
},
|
||||
}
|
||||
# @abstractmethod
|
||||
# async def execute(self, **kwargs) -> Any:
|
||||
# """Execute the tool with given parameters."""
|
||||
|
||||
# def to_param(self) -> Dict:
|
||||
# """Convert tool to function call format."""
|
||||
# return {
|
||||
# "type": "function",
|
||||
# "function": {
|
||||
# "name": self.name,
|
||||
# "description": self.description,
|
||||
# "parameters": self.parameters,
|
||||
# },
|
||||
# }
|
||||
|
||||
|
||||
class ToolResult(BaseModel):
|
||||
@@ -72,6 +75,104 @@ class ToolResult(BaseModel):
|
||||
return type(self)(**{**self.dict(), **kwargs})
|
||||
|
||||
|
||||
class BaseTool(ABC, BaseModel):
|
||||
"""Consolidated base class for all tools combining BaseModel and Tool functionality.
|
||||
|
||||
Provides:
|
||||
- Pydantic model validation
|
||||
- Schema registration
|
||||
- Standardized result handling
|
||||
- Abstract execution interface
|
||||
|
||||
Attributes:
|
||||
name (str): Tool name
|
||||
description (str): Tool description
|
||||
parameters (dict): Tool parameters schema
|
||||
_schemas (Dict[str, List[ToolSchema]]): Registered method schemas
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: Optional[dict] = None
|
||||
# _schemas: Dict[str, List[ToolSchema]] = {}
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
underscore_attrs_are_private = False
|
||||
|
||||
# def __init__(self, **data):
|
||||
# """Initialize tool with model validation and schema registration."""
|
||||
# super().__init__(**data)
|
||||
# logger.debug(f"Initializing tool class: {self.__class__.__name__}")
|
||||
# self._register_schemas()
|
||||
|
||||
# def _register_schemas(self):
|
||||
# """Register schemas from all decorated methods."""
|
||||
# for name, method in inspect.getmembers(self, predicate=inspect.ismethod):
|
||||
# if hasattr(method, 'tool_schemas'):
|
||||
# self._schemas[name] = method.tool_schemas
|
||||
# logger.debug(f"Registered schemas for method '{name}' in {self.__class__.__name__}")
|
||||
|
||||
async def __call__(self, **kwargs) -> Any:
|
||||
"""Execute the tool with given parameters."""
|
||||
return await self.execute(**kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, **kwargs) -> Any:
|
||||
"""Execute the tool with given parameters."""
|
||||
|
||||
def to_param(self) -> Dict:
|
||||
"""Convert tool to function call format.
|
||||
|
||||
Returns:
|
||||
Dictionary with tool metadata in OpenAI function calling format
|
||||
"""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
},
|
||||
}
|
||||
|
||||
# def get_schemas(self) -> Dict[str, List[ToolSchema]]:
|
||||
# """Get all registered tool schemas.
|
||||
|
||||
# Returns:
|
||||
# Dict mapping method names to their schema definitions
|
||||
# """
|
||||
# return self._schemas
|
||||
|
||||
def success_response(self, data: Union[Dict[str, Any], str]) -> ToolResult:
|
||||
"""Create a successful tool result.
|
||||
|
||||
Args:
|
||||
data: Result data (dictionary or string)
|
||||
|
||||
Returns:
|
||||
ToolResult with success=True and formatted output
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
text = data
|
||||
else:
|
||||
text = json.dumps(data, indent=2)
|
||||
logger.debug(f"Created success response for {self.__class__.__name__}")
|
||||
return ToolResult(output=text)
|
||||
|
||||
def fail_response(self, msg: str) -> ToolResult:
|
||||
"""Create a failed tool result.
|
||||
|
||||
Args:
|
||||
msg: Error message describing the failure
|
||||
|
||||
Returns:
|
||||
ToolResult with success=False and error message
|
||||
"""
|
||||
logger.debug(f"Tool {self.__class__.__name__} returned failed result: {msg}")
|
||||
return ToolResult(error=msg)
|
||||
|
||||
|
||||
class CLIResult(ToolResult):
|
||||
"""A ToolResult that can be rendered as a CLI output."""
|
||||
|
||||
|
||||
@@ -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. */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Literal, Optional
|
||||
|
||||
import aiohttp
|
||||
from pydantic import Field
|
||||
|
||||
from app.daytona.tool_base import Sandbox, SandboxToolsBase
|
||||
from app.tool.base import ToolResult
|
||||
|
||||
|
||||
KEYBOARD_KEYS = [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"i",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
"m",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"q",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"v",
|
||||
"w",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"enter",
|
||||
"esc",
|
||||
"backspace",
|
||||
"tab",
|
||||
"space",
|
||||
"delete",
|
||||
"ctrl",
|
||||
"alt",
|
||||
"shift",
|
||||
"win",
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
"f1",
|
||||
"f2",
|
||||
"f3",
|
||||
"f4",
|
||||
"f5",
|
||||
"f6",
|
||||
"f7",
|
||||
"f8",
|
||||
"f9",
|
||||
"f10",
|
||||
"f11",
|
||||
"f12",
|
||||
"ctrl+c",
|
||||
"ctrl+v",
|
||||
"ctrl+x",
|
||||
"ctrl+z",
|
||||
"ctrl+a",
|
||||
"ctrl+s",
|
||||
"alt+tab",
|
||||
"alt+f4",
|
||||
"ctrl+alt+delete",
|
||||
]
|
||||
MOUSE_BUTTONS = ["left", "right", "middle"]
|
||||
_COMPUTER_USE_DESCRIPTION = """\
|
||||
A comprehensive computer automation tool that allows interaction with the desktop environment.
|
||||
* This tool provides commands for controlling mouse, keyboard, and taking screenshots
|
||||
* It maintains state including current mouse position
|
||||
* Use this when you need to automate desktop applications, fill forms, or perform GUI interactions
|
||||
Key capabilities include:
|
||||
* Mouse Control: Move, click, drag, scroll
|
||||
* Keyboard Input: Type text, press keys or key combinations
|
||||
* Screenshots: Capture and save screen images
|
||||
* Waiting: Pause execution for specified duration
|
||||
"""
|
||||
|
||||
|
||||
class ComputerUseTool(SandboxToolsBase):
|
||||
"""Computer automation tool for controlling the desktop environment."""
|
||||
|
||||
name: str = "computer_use"
|
||||
description: str = _COMPUTER_USE_DESCRIPTION
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"move_to",
|
||||
"click",
|
||||
"scroll",
|
||||
"typing",
|
||||
"press",
|
||||
"wait",
|
||||
"mouse_down",
|
||||
"mouse_up",
|
||||
"drag_to",
|
||||
"hotkey",
|
||||
"screenshot",
|
||||
],
|
||||
"description": "The computer action to perform",
|
||||
},
|
||||
"x": {"type": "number", "description": "X coordinate for mouse actions"},
|
||||
"y": {"type": "number", "description": "Y coordinate for mouse actions"},
|
||||
"button": {
|
||||
"type": "string",
|
||||
"enum": MOUSE_BUTTONS,
|
||||
"description": "Mouse button for click/drag actions",
|
||||
"default": "left",
|
||||
},
|
||||
"num_clicks": {
|
||||
"type": "integer",
|
||||
"description": "Number of clicks",
|
||||
"enum": [1, 2, 3],
|
||||
"default": 1,
|
||||
},
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"description": "Scroll amount (positive for up, negative for down)",
|
||||
"minimum": -10,
|
||||
"maximum": 10,
|
||||
},
|
||||
"text": {"type": "string", "description": "Text to type"},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"enum": KEYBOARD_KEYS,
|
||||
"description": "Key to press",
|
||||
},
|
||||
"keys": {
|
||||
"type": "string",
|
||||
"enum": KEYBOARD_KEYS,
|
||||
"description": "Key combination to press",
|
||||
},
|
||||
"duration": {
|
||||
"type": "number",
|
||||
"description": "Duration in seconds to wait",
|
||||
"default": 0.5,
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
"dependencies": {
|
||||
"move_to": ["x", "y"],
|
||||
"click": [],
|
||||
"scroll": ["amount"],
|
||||
"typing": ["text"],
|
||||
"press": ["key"],
|
||||
"wait": [],
|
||||
"mouse_down": [],
|
||||
"mouse_up": [],
|
||||
"drag_to": ["x", "y"],
|
||||
"hotkey": ["keys"],
|
||||
"screenshot": [],
|
||||
},
|
||||
}
|
||||
session: Optional[aiohttp.ClientSession] = Field(default=None, exclude=True)
|
||||
mouse_x: int = Field(default=0, exclude=True)
|
||||
mouse_y: int = Field(default=0, exclude=True)
|
||||
api_base_url: Optional[str] = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(self, sandbox: Optional[Sandbox] = None, **data):
|
||||
"""Initialize with optional sandbox."""
|
||||
super().__init__(**data)
|
||||
if sandbox is not None:
|
||||
self._sandbox = sandbox # 直接操作基类的私有属性
|
||||
self.api_base_url = sandbox.get_preview_link(8000).url
|
||||
logging.info(
|
||||
f"Initialized ComputerUseTool with API URL: {self.api_base_url}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_with_sandbox(cls, sandbox: Sandbox) -> "ComputerUseTool":
|
||||
"""Factory method to create a tool with sandbox."""
|
||||
return cls(sandbox=sandbox) # 通过构造函数初始化
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create aiohttp session for API requests."""
|
||||
if self.session is None or self.session.closed:
|
||||
self.session = aiohttp.ClientSession()
|
||||
return self.session
|
||||
|
||||
async def _api_request(
|
||||
self, method: str, endpoint: str, data: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
"""Send request to automation service API."""
|
||||
try:
|
||||
session = await self._get_session()
|
||||
url = f"{self.api_base_url}/api{endpoint}"
|
||||
logging.debug(f"API request: {method} {url} {data}")
|
||||
if method.upper() == "GET":
|
||||
async with session.get(url) as response:
|
||||
result = await response.json()
|
||||
else: # POST
|
||||
async with session.post(url, json=data) as response:
|
||||
result = await response.json()
|
||||
logging.debug(f"API response: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logging.error(f"API request failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
action: Literal[
|
||||
"move_to",
|
||||
"click",
|
||||
"scroll",
|
||||
"typing",
|
||||
"press",
|
||||
"wait",
|
||||
"mouse_down",
|
||||
"mouse_up",
|
||||
"drag_to",
|
||||
"hotkey",
|
||||
"screenshot",
|
||||
],
|
||||
x: Optional[float] = None,
|
||||
y: Optional[float] = None,
|
||||
button: str = "left",
|
||||
num_clicks: int = 1,
|
||||
amount: Optional[int] = None,
|
||||
text: Optional[str] = None,
|
||||
key: Optional[str] = None,
|
||||
keys: Optional[str] = None,
|
||||
duration: float = 0.5,
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
"""
|
||||
Execute a specified computer automation action.
|
||||
Args:
|
||||
action: The action to perform
|
||||
x: X coordinate for mouse actions
|
||||
y: Y coordinate for mouse actions
|
||||
button: Mouse button for click/drag actions
|
||||
num_clicks: Number of clicks to perform
|
||||
amount: Scroll amount (positive for up, negative for down)
|
||||
text: Text to type
|
||||
key: Key to press
|
||||
keys: Key combination to press
|
||||
duration: Duration in seconds to wait
|
||||
**kwargs: Additional arguments
|
||||
Returns:
|
||||
ToolResult with the action's output or error
|
||||
"""
|
||||
try:
|
||||
if action == "move_to":
|
||||
if x is None or y is None:
|
||||
return ToolResult(error="x and y coordinates are required")
|
||||
x_int = int(round(float(x)))
|
||||
y_int = int(round(float(y)))
|
||||
result = await self._api_request(
|
||||
"POST", "/automation/mouse/move", {"x": x_int, "y": y_int}
|
||||
)
|
||||
if result.get("success", False):
|
||||
self.mouse_x = x_int
|
||||
self.mouse_y = y_int
|
||||
return ToolResult(output=f"Moved to ({x_int}, {y_int})")
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"Failed to move: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
elif action == "click":
|
||||
x_val = x if x is not None else self.mouse_x
|
||||
y_val = y if y is not None else self.mouse_y
|
||||
x_int = int(round(float(x_val)))
|
||||
y_int = int(round(float(y_val)))
|
||||
num_clicks = int(num_clicks)
|
||||
result = await self._api_request(
|
||||
"POST",
|
||||
"/automation/mouse/click",
|
||||
{
|
||||
"x": x_int,
|
||||
"y": y_int,
|
||||
"clicks": num_clicks,
|
||||
"button": button.lower(),
|
||||
},
|
||||
)
|
||||
if result.get("success", False):
|
||||
self.mouse_x = x_int
|
||||
self.mouse_y = y_int
|
||||
return ToolResult(
|
||||
output=f"{num_clicks} {button} click(s) performed at ({x_int}, {y_int})"
|
||||
)
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"Failed to click: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
elif action == "scroll":
|
||||
if amount is None:
|
||||
return ToolResult(error="Scroll amount is required")
|
||||
amount = int(float(amount))
|
||||
amount = max(-10, min(10, amount))
|
||||
result = await self._api_request(
|
||||
"POST",
|
||||
"/automation/mouse/scroll",
|
||||
{"clicks": amount, "x": self.mouse_x, "y": self.mouse_y},
|
||||
)
|
||||
if result.get("success", False):
|
||||
direction = "up" if amount > 0 else "down"
|
||||
steps = abs(amount)
|
||||
return ToolResult(
|
||||
output=f"Scrolled {direction} {steps} step(s) at position ({self.mouse_x}, {self.mouse_y})"
|
||||
)
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"Failed to scroll: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
elif action == "typing":
|
||||
if text is None:
|
||||
return ToolResult(error="Text is required for typing")
|
||||
text = str(text)
|
||||
result = await self._api_request(
|
||||
"POST",
|
||||
"/automation/keyboard/write",
|
||||
{"message": text, "interval": 0.01},
|
||||
)
|
||||
if result.get("success", False):
|
||||
return ToolResult(output=f"Typed: {text}")
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"Failed to type: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
elif action == "press":
|
||||
if key is None:
|
||||
return ToolResult(error="Key is required for press action")
|
||||
key = str(key).lower()
|
||||
result = await self._api_request(
|
||||
"POST", "/automation/keyboard/press", {"keys": key, "presses": 1}
|
||||
)
|
||||
if result.get("success", False):
|
||||
return ToolResult(output=f"Pressed key: {key}")
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"Failed to press key: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
elif action == "wait":
|
||||
duration = float(duration)
|
||||
duration = max(0, min(10, duration))
|
||||
await asyncio.sleep(duration)
|
||||
return ToolResult(output=f"Waited {duration} seconds")
|
||||
elif action == "mouse_down":
|
||||
x_val = x if x is not None else self.mouse_x
|
||||
y_val = y if y is not None else self.mouse_y
|
||||
x_int = int(round(float(x_val)))
|
||||
y_int = int(round(float(y_val)))
|
||||
result = await self._api_request(
|
||||
"POST",
|
||||
"/automation/mouse/down",
|
||||
{"x": x_int, "y": y_int, "button": button.lower()},
|
||||
)
|
||||
if result.get("success", False):
|
||||
self.mouse_x = x_int
|
||||
self.mouse_y = y_int
|
||||
return ToolResult(
|
||||
output=f"{button} button pressed at ({x_int}, {y_int})"
|
||||
)
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"Failed to press button: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
elif action == "mouse_up":
|
||||
x_val = x if x is not None else self.mouse_x
|
||||
y_val = y if y is not None else self.mouse_y
|
||||
x_int = int(round(float(x_val)))
|
||||
y_int = int(round(float(y_val)))
|
||||
result = await self._api_request(
|
||||
"POST",
|
||||
"/automation/mouse/up",
|
||||
{"x": x_int, "y": y_int, "button": button.lower()},
|
||||
)
|
||||
if result.get("success", False):
|
||||
self.mouse_x = x_int
|
||||
self.mouse_y = y_int
|
||||
return ToolResult(
|
||||
output=f"{button} button released at ({x_int}, {y_int})"
|
||||
)
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"Failed to release button: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
elif action == "drag_to":
|
||||
if x is None or y is None:
|
||||
return ToolResult(error="x and y coordinates are required")
|
||||
target_x = int(round(float(x)))
|
||||
target_y = int(round(float(y)))
|
||||
start_x = self.mouse_x
|
||||
start_y = self.mouse_y
|
||||
result = await self._api_request(
|
||||
"POST",
|
||||
"/automation/mouse/drag",
|
||||
{"x": target_x, "y": target_y, "duration": 0.3, "button": "left"},
|
||||
)
|
||||
if result.get("success", False):
|
||||
self.mouse_x = target_x
|
||||
self.mouse_y = target_y
|
||||
return ToolResult(
|
||||
output=f"Dragged from ({start_x}, {start_y}) to ({target_x}, {target_y})"
|
||||
)
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"Failed to drag: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
elif action == "hotkey":
|
||||
if keys is None:
|
||||
return ToolResult(error="Keys are required for hotkey action")
|
||||
keys = str(keys).lower().strip()
|
||||
key_sequence = keys.split("+")
|
||||
result = await self._api_request(
|
||||
"POST",
|
||||
"/automation/keyboard/hotkey",
|
||||
{"keys": key_sequence, "interval": 0.01},
|
||||
)
|
||||
if result.get("success", False):
|
||||
return ToolResult(output=f"Pressed key combination: {keys}")
|
||||
else:
|
||||
return ToolResult(
|
||||
error=f"Failed to press keys: {result.get('error', 'Unknown error')}"
|
||||
)
|
||||
elif action == "screenshot":
|
||||
result = await self._api_request("POST", "/automation/screenshot")
|
||||
if "image" in result:
|
||||
base64_str = result["image"]
|
||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
# Save screenshot to file
|
||||
screenshots_dir = "screenshots"
|
||||
if not os.path.exists(screenshots_dir):
|
||||
os.makedirs(screenshots_dir)
|
||||
timestamped_filename = os.path.join(
|
||||
screenshots_dir, f"screenshot_{timestamp}.png"
|
||||
)
|
||||
latest_filename = "latest_screenshot.png"
|
||||
# Decode base64 string and save to file
|
||||
img_data = base64.b64decode(base64_str)
|
||||
with open(timestamped_filename, "wb") as f:
|
||||
f.write(img_data)
|
||||
# Save a copy as the latest screenshot
|
||||
with open(latest_filename, "wb") as f:
|
||||
f.write(img_data)
|
||||
return ToolResult(
|
||||
output=f"Screenshot saved as {timestamped_filename}",
|
||||
base64_image=base64_str,
|
||||
)
|
||||
else:
|
||||
return ToolResult(error="Failed to capture screenshot")
|
||||
else:
|
||||
return ToolResult(error=f"Unknown action: {action}")
|
||||
except Exception as e:
|
||||
return ToolResult(error=f"Computer action failed: {str(e)}")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
self.session = None
|
||||
|
||||
def __del__(self):
|
||||
"""Ensure cleanup on destruction."""
|
||||
if hasattr(self, "session") and self.session is not None:
|
||||
try:
|
||||
asyncio.run(self.cleanup())
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(self.cleanup())
|
||||
loop.close()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Crawl4AI Web Crawler Tool for OpenManus
|
||||
|
||||
This tool integrates Crawl4AI, a high-performance web crawler designed for LLMs and AI agents,
|
||||
providing fast, precise, and AI-ready data extraction with clean Markdown generation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import List, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.logger import logger
|
||||
from app.tool.base import BaseTool, ToolResult
|
||||
|
||||
|
||||
class Crawl4aiTool(BaseTool):
|
||||
"""
|
||||
Web crawler tool powered by Crawl4AI.
|
||||
|
||||
Provides clean markdown extraction optimized for AI processing.
|
||||
"""
|
||||
|
||||
name: str = "crawl4ai"
|
||||
description: str = """Web crawler that extracts clean, AI-ready content from web pages.
|
||||
|
||||
Features:
|
||||
- Extracts clean markdown content optimized for LLMs
|
||||
- Handles JavaScript-heavy sites and dynamic content
|
||||
- Supports multiple URLs in a single request
|
||||
- Fast and reliable with built-in error handling
|
||||
|
||||
Perfect for content analysis, research, and feeding web content to AI models."""
|
||||
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "(required) List of URLs to crawl. Can be a single URL or multiple URLs.",
|
||||
"minItems": 1,
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "(optional) Timeout in seconds for each URL. Default is 30.",
|
||||
"default": 30,
|
||||
"minimum": 5,
|
||||
"maximum": 120,
|
||||
},
|
||||
"bypass_cache": {
|
||||
"type": "boolean",
|
||||
"description": "(optional) Whether to bypass cache and fetch fresh content. Default is false.",
|
||||
"default": False,
|
||||
},
|
||||
"word_count_threshold": {
|
||||
"type": "integer",
|
||||
"description": "(optional) Minimum word count for content blocks. Default is 10.",
|
||||
"default": 10,
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["urls"],
|
||||
}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
urls: Union[str, List[str]],
|
||||
timeout: int = 30,
|
||||
bypass_cache: bool = False,
|
||||
word_count_threshold: int = 10,
|
||||
) -> ToolResult:
|
||||
"""
|
||||
Execute web crawling for the specified URLs.
|
||||
|
||||
Args:
|
||||
urls: Single URL string or list of URLs to crawl
|
||||
timeout: Timeout in seconds for each URL
|
||||
bypass_cache: Whether to bypass cache
|
||||
word_count_threshold: Minimum word count for content blocks
|
||||
|
||||
Returns:
|
||||
ToolResult with crawl results
|
||||
"""
|
||||
# Normalize URLs to list
|
||||
if isinstance(urls, str):
|
||||
url_list = [urls]
|
||||
else:
|
||||
url_list = urls
|
||||
|
||||
# Validate URLs
|
||||
valid_urls = []
|
||||
for url in url_list:
|
||||
if self._is_valid_url(url):
|
||||
valid_urls.append(url)
|
||||
else:
|
||||
logger.warning(f"Invalid URL skipped: {url}")
|
||||
|
||||
if not valid_urls:
|
||||
return ToolResult(error="No valid URLs provided")
|
||||
|
||||
try:
|
||||
# Import crawl4ai components
|
||||
from crawl4ai import (
|
||||
AsyncWebCrawler,
|
||||
BrowserConfig,
|
||||
CacheMode,
|
||||
CrawlerRunConfig,
|
||||
)
|
||||
|
||||
# Configure browser settings
|
||||
browser_config = BrowserConfig(
|
||||
headless=True,
|
||||
verbose=False,
|
||||
browser_type="chromium",
|
||||
ignore_https_errors=True,
|
||||
java_script_enabled=True,
|
||||
)
|
||||
|
||||
# Configure crawler settings
|
||||
run_config = CrawlerRunConfig(
|
||||
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED,
|
||||
word_count_threshold=word_count_threshold,
|
||||
process_iframes=True,
|
||||
remove_overlay_elements=True,
|
||||
excluded_tags=["script", "style"],
|
||||
page_timeout=timeout * 1000, # Convert to milliseconds
|
||||
verbose=False,
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
|
||||
results = []
|
||||
successful_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# Process each URL
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
for url in valid_urls:
|
||||
try:
|
||||
logger.info(f"🕷️ Crawling URL: {url}")
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
result = await crawler.arun(url=url, config=run_config)
|
||||
|
||||
end_time = asyncio.get_event_loop().time()
|
||||
execution_time = end_time - start_time
|
||||
|
||||
if result.success:
|
||||
# Count words in markdown
|
||||
word_count = 0
|
||||
if hasattr(result, "markdown") and result.markdown:
|
||||
word_count = len(result.markdown.split())
|
||||
|
||||
# Count links
|
||||
links_count = 0
|
||||
if hasattr(result, "links") and result.links:
|
||||
internal_links = result.links.get("internal", [])
|
||||
external_links = result.links.get("external", [])
|
||||
links_count = len(internal_links) + len(external_links)
|
||||
|
||||
# Count images
|
||||
images_count = 0
|
||||
if hasattr(result, "media") and result.media:
|
||||
images = result.media.get("images", [])
|
||||
images_count = len(images)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"url": url,
|
||||
"success": True,
|
||||
"status_code": getattr(result, "status_code", 200),
|
||||
"title": result.metadata.get("title")
|
||||
if result.metadata
|
||||
else None,
|
||||
"markdown": result.markdown
|
||||
if hasattr(result, "markdown")
|
||||
else None,
|
||||
"word_count": word_count,
|
||||
"links_count": links_count,
|
||||
"images_count": images_count,
|
||||
"execution_time": execution_time,
|
||||
}
|
||||
)
|
||||
successful_count += 1
|
||||
logger.info(
|
||||
f"✅ Successfully crawled {url} in {execution_time:.2f}s"
|
||||
)
|
||||
|
||||
else:
|
||||
results.append(
|
||||
{
|
||||
"url": url,
|
||||
"success": False,
|
||||
"error_message": getattr(
|
||||
result, "error_message", "Unknown error"
|
||||
),
|
||||
"execution_time": execution_time,
|
||||
}
|
||||
)
|
||||
failed_count += 1
|
||||
logger.warning(f"❌ Failed to crawl {url}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error crawling {url}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
results.append(
|
||||
{"url": url, "success": False, "error_message": error_msg}
|
||||
)
|
||||
failed_count += 1
|
||||
|
||||
# Format output
|
||||
output_lines = [f"🕷️ Crawl4AI Results Summary:"]
|
||||
output_lines.append(f"📊 Total URLs: {len(valid_urls)}")
|
||||
output_lines.append(f"✅ Successful: {successful_count}")
|
||||
output_lines.append(f"❌ Failed: {failed_count}")
|
||||
output_lines.append("")
|
||||
|
||||
for i, result in enumerate(results, 1):
|
||||
output_lines.append(f"{i}. {result['url']}")
|
||||
|
||||
if result["success"]:
|
||||
output_lines.append(
|
||||
f" ✅ Status: Success (HTTP {result.get('status_code', 'N/A')})"
|
||||
)
|
||||
if result.get("title"):
|
||||
output_lines.append(f" 📄 Title: {result['title']}")
|
||||
|
||||
if result.get("markdown"):
|
||||
# Show first 300 characters of markdown content
|
||||
content_preview = result["markdown"]
|
||||
if len(result["markdown"]) > 300:
|
||||
content_preview += "..."
|
||||
output_lines.append(f" 📝 Content: {content_preview}")
|
||||
|
||||
output_lines.append(
|
||||
f" 📊 Stats: {result.get('word_count', 0)} words, {result.get('links_count', 0)} links, {result.get('images_count', 0)} images"
|
||||
)
|
||||
|
||||
if result.get("execution_time"):
|
||||
output_lines.append(
|
||||
f" ⏱️ Time: {result['execution_time']:.2f}s"
|
||||
)
|
||||
else:
|
||||
output_lines.append(f" ❌ Status: Failed")
|
||||
if result.get("error_message"):
|
||||
output_lines.append(f" 🚫 Error: {result['error_message']}")
|
||||
|
||||
output_lines.append("")
|
||||
|
||||
return ToolResult(output="\n".join(output_lines))
|
||||
|
||||
except ImportError:
|
||||
error_msg = "Crawl4AI is not installed. Please install it with: pip install crawl4ai"
|
||||
logger.error(error_msg)
|
||||
return ToolResult(error=error_msg)
|
||||
except Exception as e:
|
||||
error_msg = f"Crawl4AI execution failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return ToolResult(error=error_msg)
|
||||
|
||||
def _is_valid_url(self, url: str) -> bool:
|
||||
"""Validate if a URL is properly formatted."""
|
||||
try:
|
||||
result = urlparse(url)
|
||||
return all([result.scheme, result.netloc]) and result.scheme in [
|
||||
"http",
|
||||
"https",
|
||||
]
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1,537 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.exceptions import ToolError
|
||||
from app.llm import LLM
|
||||
from app.logger import logger
|
||||
from app.schema import ToolChoice
|
||||
from app.tool.base import BaseTool, ToolResult
|
||||
from app.tool.web_search import SearchResult, WebSearch
|
||||
|
||||
|
||||
# Prompts for LLM interactions
|
||||
OPTIMIZE_QUERY_PROMPT = """
|
||||
You are a research assistant helping to optimize a search query for web research.
|
||||
Your task is to reformulate the given query to be more effective for web searches.
|
||||
Make it specific, use relevant keywords, and ensure it's clear and concise.
|
||||
|
||||
Original query: {query}
|
||||
|
||||
Provide only the optimized query text without any explanation or additional formatting.
|
||||
"""
|
||||
|
||||
EXTRACT_INSIGHTS_PROMPT = """
|
||||
Analyze the following content and extract key insights related to the research query.
|
||||
For each insight, assess its relevance to the query on a scale of 0.0 to 1.0.
|
||||
|
||||
Research query: {query}
|
||||
Content to analyze:
|
||||
{content}
|
||||
|
||||
Extract up to 3 most important insights from this content. For each insight:
|
||||
1. Provide the insight content
|
||||
2. Provide relevance score (0.0-1.0)
|
||||
"""
|
||||
|
||||
GENERATE_FOLLOW_UPS_PROMPT = """
|
||||
Based on the insights discovered so far, generate follow-up research queries to explore gaps or related areas.
|
||||
These should help deepen our understanding of the topic.
|
||||
|
||||
Original query: {original_query}
|
||||
Current query: {current_query}
|
||||
Key insights so far:
|
||||
{insights}
|
||||
|
||||
Generate up to 3 specific follow-up queries that would help address gaps in our current knowledge.
|
||||
Each query should be concise and focused on a specific aspect of the research topic.
|
||||
"""
|
||||
|
||||
# Constants for insight parsing
|
||||
DEFAULT_RELEVANCE_SCORE = 1.0
|
||||
FALLBACK_RELEVANCE_SCORE = 0.7
|
||||
FALLBACK_CONTENT_LIMIT = 500
|
||||
# Pattern to detect start of an insight (number., -, *, •) and capture content
|
||||
INSIGHT_MARKER_PATTERN = re.compile(r"^\s*(?:\d+\.|-|\*|•)\s*(.*)")
|
||||
# Pattern to detect relevance score, capturing the number (case-insensitive)
|
||||
RELEVANCE_SCORE_PATTERN = re.compile(r"relevance.*?:.*?(\d\.?\d*)", re.IGNORECASE)
|
||||
|
||||
|
||||
class ResearchInsight(BaseModel):
|
||||
"""A single insight discovered during research."""
|
||||
|
||||
model_config = ConfigDict(frozen=True) # Make insights immutable
|
||||
|
||||
content: str = Field(description="The insight content")
|
||||
source_url: str = Field(description="URL where this insight was found")
|
||||
source_title: Optional[str] = Field(default=None, description="Title of the source")
|
||||
relevance_score: float = Field(
|
||||
default=1.0, description="Relevance score (0.0-1.0)", ge=0.0, le=1.0
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Format insight as string with source attribution."""
|
||||
source = self.source_title or self.source_url
|
||||
return f"{self.content} [Source: {source}]"
|
||||
|
||||
|
||||
class ResearchContext(BaseModel):
|
||||
"""Research context for tracking research progress."""
|
||||
|
||||
query: str = Field(description="The original research query")
|
||||
insights: List[ResearchInsight] = Field(
|
||||
default_factory=list, description="Key insights discovered"
|
||||
)
|
||||
follow_up_queries: List[str] = Field(
|
||||
default_factory=list, description="Generated follow-up queries"
|
||||
)
|
||||
visited_urls: Set[str] = Field(
|
||||
default_factory=set, description="URLs visited during research"
|
||||
)
|
||||
current_depth: int = Field(
|
||||
default=0, description="Current depth of research exploration", ge=0
|
||||
)
|
||||
max_depth: int = Field(
|
||||
default=2, description="Maximum depth of research to reach", ge=1
|
||||
)
|
||||
|
||||
|
||||
class ResearchSummary(ToolResult):
|
||||
"""Comprehensive summary of deep research results."""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
query: str = Field(description="The original research query")
|
||||
insights: List[ResearchInsight] = Field(
|
||||
default_factory=list, description="Key insights discovered"
|
||||
)
|
||||
visited_urls: Set[str] = Field(
|
||||
default_factory=set, description="URLs visited during research"
|
||||
)
|
||||
depth_reached: int = Field(
|
||||
default=0, description="Maximum depth of research reached", ge=0
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def populate_output(self) -> "ResearchSummary":
|
||||
"""Populate the output field after validation."""
|
||||
# Group and sort insights by relevance
|
||||
grouped_insights = {
|
||||
"Key Findings": [i for i in self.insights if i.relevance_score >= 0.8],
|
||||
"Additional Information": [
|
||||
i for i in self.insights if 0.5 <= i.relevance_score < 0.8
|
||||
],
|
||||
"Supplementary Information": [
|
||||
i for i in self.insights if i.relevance_score < 0.5
|
||||
],
|
||||
}
|
||||
|
||||
sections = [
|
||||
f"# Research: {self.query}\n",
|
||||
f"**Sources**: {len(self.visited_urls)} | **Depth**: {self.depth_reached + 1}\n",
|
||||
]
|
||||
|
||||
for section_title, insights in grouped_insights.items():
|
||||
if insights:
|
||||
sections.append(f"## {section_title}")
|
||||
for i, insight in enumerate(insights, 1):
|
||||
sections.extend(
|
||||
[
|
||||
insight.content,
|
||||
f"> Source: [{insight.source_title or 'Link'}]({insight.source_url})\n",
|
||||
]
|
||||
)
|
||||
|
||||
# Assign the formatted string to the 'output' field inherited from ToolResult
|
||||
self.output = "\n".join(sections)
|
||||
return self
|
||||
|
||||
|
||||
class DeepResearch(BaseTool):
|
||||
"""Advanced research tool that explores a topic through iterative web searches."""
|
||||
|
||||
name: str = "deep_research"
|
||||
description: str = """
|
||||
Performs comprehensive research on a topic through multi-level web searches
|
||||
and content analysis. Returns a structured summary of findings with source
|
||||
attribution and relevance ratings.
|
||||
"""
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The research question or topic to investigate.",
|
||||
},
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "Maximum depth of iterative research (1-5). Default is 2.",
|
||||
"default": 2,
|
||||
},
|
||||
"results_per_search": {
|
||||
"type": "integer",
|
||||
"description": "Number of search results to analyze per search (1-20). Default is 5.",
|
||||
"default": 5,
|
||||
},
|
||||
"max_insights": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of insights to return. Default is 20.",
|
||||
"default": 20,
|
||||
},
|
||||
"time_limit_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Maximum execution time in seconds. Default is 120.",
|
||||
"default": 120,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
# Dependency injection for easier testing
|
||||
search_tool: WebSearch = Field(default_factory=WebSearch)
|
||||
llm: LLM = Field(default_factory=LLM)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
query: str,
|
||||
max_depth: int = 2,
|
||||
results_per_search: int = 5,
|
||||
max_insights: int = 20,
|
||||
time_limit_seconds: int = 120,
|
||||
) -> ResearchSummary:
|
||||
"""Execute deep research on the given query."""
|
||||
# Normalize parameters
|
||||
max_depth = max(1, min(max_depth, 5))
|
||||
results_per_search = max(1, min(results_per_search, 20))
|
||||
|
||||
# Initialize research context and set deadline
|
||||
context = ResearchContext(query=query, max_depth=max_depth)
|
||||
deadline = time.time() + time_limit_seconds
|
||||
|
||||
try:
|
||||
# Initiate research process with optimized query
|
||||
optimized_query = await self._generate_optimized_query(query)
|
||||
await self._research_graph(
|
||||
context=context,
|
||||
query=optimized_query,
|
||||
results_count=results_per_search,
|
||||
deadline=deadline,
|
||||
)
|
||||
except ToolError as e:
|
||||
logger.error(f"Research error: {str(e)}")
|
||||
|
||||
# Prepare final summary
|
||||
return ResearchSummary(
|
||||
query=query,
|
||||
insights=sorted(
|
||||
context.insights, key=lambda x: x.relevance_score, reverse=True
|
||||
)[:max_insights],
|
||||
visited_urls=context.visited_urls,
|
||||
depth_reached=context.current_depth,
|
||||
)
|
||||
|
||||
async def _generate_optimized_query(self, query: str) -> str:
|
||||
"""Generate an optimized search query using LLM."""
|
||||
try:
|
||||
prompt = OPTIMIZE_QUERY_PROMPT.format(query=query)
|
||||
response = await self.llm.ask_tool(
|
||||
[{"role": "user", "content": prompt}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "optimize_query",
|
||||
"description": "Generate an optimized search query",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The optimized search query",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
tool_choice=ToolChoice.REQUIRED,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
# Extract the query from the tool_call response
|
||||
if response and response.tool_calls and len(response.tool_calls) > 0:
|
||||
tool_call = response.tool_calls[0]
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
optimized_query = arguments.get("query", "")
|
||||
else:
|
||||
# Fallback to original query if tool call failed
|
||||
logger.warning("Tool call failed to return a valid response")
|
||||
return query
|
||||
|
||||
if not optimized_query:
|
||||
logger.warning("Generated empty optimized query, using original")
|
||||
return query
|
||||
|
||||
logger.info(f"Optimized query: '{optimized_query}'")
|
||||
return optimized_query
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to optimize query: {str(e)}")
|
||||
return query # Fall back to original query on error
|
||||
|
||||
async def _research_graph(
|
||||
self,
|
||||
context: ResearchContext,
|
||||
query: str,
|
||||
results_count: int,
|
||||
deadline: float,
|
||||
) -> None:
|
||||
"""Run a complete research cycle (search, analyze, generate follow-ups)."""
|
||||
# Check termination conditions
|
||||
if time.time() >= deadline or context.current_depth >= context.max_depth:
|
||||
return
|
||||
|
||||
# Log current research step
|
||||
logger.info(f"Research cycle at depth {context.current_depth + 1}")
|
||||
|
||||
# 1. Web search
|
||||
search_results = await self._search_web(query, results_count)
|
||||
if not search_results:
|
||||
return
|
||||
|
||||
# 2. Extract insights
|
||||
new_insights = await self._extract_insights(
|
||||
context, search_results, context.query, deadline
|
||||
)
|
||||
if not new_insights:
|
||||
return
|
||||
|
||||
# 3. Generate follow-up queries
|
||||
follow_up_queries = await self._generate_follow_ups(
|
||||
new_insights, query, context.query
|
||||
)
|
||||
context.follow_up_queries.extend(follow_up_queries)
|
||||
|
||||
# Update depth and proceed to next level
|
||||
context.current_depth += 1
|
||||
|
||||
# 4. Continue research with follow-up queries
|
||||
if follow_up_queries and context.current_depth < context.max_depth:
|
||||
tasks = [] # Create a list to hold the tasks
|
||||
for follow_up in follow_up_queries[:2]: # Limit branching factor
|
||||
if time.time() >= deadline:
|
||||
break
|
||||
|
||||
# Create a coroutine for the recursive research call
|
||||
task = self._research_graph(
|
||||
context=context,
|
||||
query=follow_up,
|
||||
results_count=max(1, results_count - 1), # Reduce result count
|
||||
deadline=deadline,
|
||||
)
|
||||
tasks.append(task) # Add the task to the list
|
||||
|
||||
# Run all the created tasks concurrently
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def _search_web(self, query: str, results_count: int) -> List[SearchResult]:
|
||||
"""Perform web search for the given query."""
|
||||
search_response = await self.search_tool.execute(
|
||||
query=query, num_results=results_count, fetch_content=True
|
||||
)
|
||||
return [] if search_response.error else search_response.results
|
||||
|
||||
async def _extract_insights(
|
||||
self,
|
||||
context: ResearchContext,
|
||||
results: List[SearchResult],
|
||||
original_query: str,
|
||||
deadline: float,
|
||||
) -> List[ResearchInsight]:
|
||||
"""Extract insights from search results."""
|
||||
all_insights = []
|
||||
|
||||
for rst in results:
|
||||
# Skip if URL already visited or time exceeded
|
||||
if rst.url in context.visited_urls or time.time() >= deadline:
|
||||
continue
|
||||
|
||||
context.visited_urls.add(rst.url)
|
||||
|
||||
# Skip if no content available
|
||||
if not rst.raw_content:
|
||||
continue
|
||||
|
||||
# Extract insights using LLM
|
||||
insights = await self._analyze_content(
|
||||
content=rst.raw_content[:10000], # Limit content size
|
||||
url=rst.url,
|
||||
title=rst.title,
|
||||
query=original_query,
|
||||
)
|
||||
|
||||
all_insights.extend(insights)
|
||||
context.insights.extend(insights)
|
||||
|
||||
# Log discovered insights
|
||||
logger.info(f"Extracted {len(insights)} insights from {rst.url}")
|
||||
|
||||
return all_insights
|
||||
|
||||
async def _generate_follow_ups(
|
||||
self, insights: List[ResearchInsight], current_query: str, original_query: str
|
||||
) -> List[str]:
|
||||
"""Generate follow-up queries based on insights."""
|
||||
if not insights:
|
||||
return []
|
||||
|
||||
# Format insights for the prompt
|
||||
insights_text = "\n".join([f"- {insight.content}" for insight in insights[:5]])
|
||||
|
||||
# Create prompt for generating follow-up queries
|
||||
prompt = GENERATE_FOLLOW_UPS_PROMPT.format(
|
||||
original_query=original_query,
|
||||
current_query=current_query,
|
||||
insights=insights_text,
|
||||
)
|
||||
|
||||
# Get follow-up queries from LLM using structured output
|
||||
response = await self.llm.ask_tool(
|
||||
[{"role": "user", "content": prompt}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_follow_ups",
|
||||
"description": "Generate follow-up queries based on research insights",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"follow_up_queries": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of follow-up queries (max 3) that would help address gaps in current knowledge",
|
||||
"maxItems": 3,
|
||||
}
|
||||
},
|
||||
"required": ["follow_up_queries"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
tool_choice=ToolChoice.REQUIRED,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
# Extract queries from the tool response
|
||||
queries = []
|
||||
if response and response.tool_calls and len(response.tool_calls) > 0:
|
||||
tool_call = response.tool_calls[0]
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
queries = arguments.get("follow_up_queries", [])
|
||||
|
||||
# Ensure we don't return more than 3 queries
|
||||
return queries[:3]
|
||||
|
||||
async def _analyze_content(
|
||||
self, content: str, url: str, title: str, query: str
|
||||
) -> List[ResearchInsight]:
|
||||
"""Extract insights from content based on relevance to query."""
|
||||
prompt = EXTRACT_INSIGHTS_PROMPT.format(
|
||||
query=query, content=content[:5000] # Limit content size
|
||||
)
|
||||
|
||||
response = await self.llm.ask_tool(
|
||||
[{"role": "user", "content": prompt}],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "extract_insights",
|
||||
"description": "Extract key insights from content with relevance scores",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"insights": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The insight content",
|
||||
},
|
||||
"relevance_score": {
|
||||
"type": "number",
|
||||
"description": "Relevance score between 0.0 and 1.0",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
},
|
||||
},
|
||||
"required": ["content", "relevance_score"],
|
||||
},
|
||||
"description": "List of key insights extracted from the content",
|
||||
"maxItems": 3,
|
||||
}
|
||||
},
|
||||
"required": ["insights"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
tool_choice=ToolChoice.REQUIRED,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
insights = []
|
||||
|
||||
# Process structured JSON response
|
||||
if response and response.tool_calls and len(response.tool_calls) > 0:
|
||||
tool_call = response.tool_calls[0]
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
extracted_insights = arguments.get("insights", [])
|
||||
|
||||
for insight_data in extracted_insights:
|
||||
insights.append(
|
||||
ResearchInsight(
|
||||
content=insight_data.get("content", ""),
|
||||
source_url=url,
|
||||
source_title=title,
|
||||
relevance_score=insight_data.get(
|
||||
"relevance_score", FALLBACK_RELEVANCE_SCORE
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Fallback: if no structured insights found, use fallback approach
|
||||
if not insights:
|
||||
logger.warning(
|
||||
f"Could not parse structured insights from LLM response for {url}. Using fallback."
|
||||
)
|
||||
insights.append(
|
||||
ResearchInsight(
|
||||
content=f"Failed to extract structured insights from content about {title or url}."[
|
||||
:FALLBACK_CONTENT_LIMIT
|
||||
],
|
||||
source_url=url,
|
||||
source_title=title,
|
||||
relevance_score=FALLBACK_RELEVANCE_SCORE,
|
||||
)
|
||||
)
|
||||
|
||||
return insights
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
deep_research = DeepResearch()
|
||||
result = asyncio.run(
|
||||
deep_research.execute(
|
||||
"What is deep learning", max_depth=1, results_per_search=2
|
||||
)
|
||||
)
|
||||
print(result)
|
||||
+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,450 @@
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import traceback
|
||||
from typing import Optional # Add this import for Optional
|
||||
|
||||
from PIL import Image
|
||||
from pydantic import Field
|
||||
|
||||
from app.daytona.tool_base import ( # Ensure Sandbox is imported correctly
|
||||
Sandbox,
|
||||
SandboxToolsBase,
|
||||
ThreadMessage,
|
||||
)
|
||||
from app.tool.base import ToolResult
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
# Context = TypeVar("Context")
|
||||
_BROWSER_DESCRIPTION = """\
|
||||
A sandbox-based browser automation tool that allows interaction with web pages through various actions.
|
||||
* This tool provides commands for controlling a browser session in a sandboxed environment
|
||||
* 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, or extract content in a secure sandbox
|
||||
* Each action requires specific parameters as defined in the tool's dependencies
|
||||
Key capabilities include:
|
||||
* Navigation: Go to specific URLs, go back in history
|
||||
* Interaction: Click elements by index, input text, send keyboard commands
|
||||
* Scrolling: Scroll up/down by pixel amount or scroll to specific text
|
||||
* Tab management: Switch between tabs or close tabs
|
||||
* Content extraction: Get dropdown options or select dropdown options
|
||||
"""
|
||||
|
||||
|
||||
# noinspection PyArgumentList
|
||||
class SandboxBrowserTool(SandboxToolsBase):
|
||||
"""Tool for executing tasks in a Daytona sandbox with browser-use capabilities."""
|
||||
|
||||
name: str = "sandbox_browser"
|
||||
description: str = _BROWSER_DESCRIPTION
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"navigate_to",
|
||||
"go_back",
|
||||
"wait",
|
||||
"click_element",
|
||||
"input_text",
|
||||
"send_keys",
|
||||
"switch_tab",
|
||||
"close_tab",
|
||||
"scroll_down",
|
||||
"scroll_up",
|
||||
"scroll_to_text",
|
||||
"get_dropdown_options",
|
||||
"select_dropdown_option",
|
||||
"click_coordinates",
|
||||
"drag_drop",
|
||||
],
|
||||
"description": "The browser action to perform",
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL for 'navigate_to' action",
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Element index for interaction actions",
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text for input or scroll actions",
|
||||
},
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"description": "Pixel amount to scroll",
|
||||
},
|
||||
"page_id": {
|
||||
"type": "integer",
|
||||
"description": "Tab ID for tab management actions",
|
||||
},
|
||||
"keys": {
|
||||
"type": "string",
|
||||
"description": "Keys to send for keyboard actions",
|
||||
},
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"description": "Seconds to wait",
|
||||
},
|
||||
"x": {
|
||||
"type": "integer",
|
||||
"description": "X coordinate for click or drag actions",
|
||||
},
|
||||
"y": {
|
||||
"type": "integer",
|
||||
"description": "Y coordinate for click or drag actions",
|
||||
},
|
||||
"element_source": {
|
||||
"type": "string",
|
||||
"description": "Source element for drag and drop",
|
||||
},
|
||||
"element_target": {
|
||||
"type": "string",
|
||||
"description": "Target element for drag and drop",
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
"dependencies": {
|
||||
"navigate_to": ["url"],
|
||||
"click_element": ["index"],
|
||||
"input_text": ["index", "text"],
|
||||
"send_keys": ["keys"],
|
||||
"switch_tab": ["page_id"],
|
||||
"close_tab": ["page_id"],
|
||||
"scroll_down": ["amount"],
|
||||
"scroll_up": ["amount"],
|
||||
"scroll_to_text": ["text"],
|
||||
"get_dropdown_options": ["index"],
|
||||
"select_dropdown_option": ["index", "text"],
|
||||
"click_coordinates": ["x", "y"],
|
||||
"drag_drop": ["element_source", "element_target"],
|
||||
"wait": ["seconds"],
|
||||
},
|
||||
}
|
||||
browser_message: Optional[ThreadMessage] = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(
|
||||
self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data
|
||||
):
|
||||
"""Initialize with optional sandbox and thread_id."""
|
||||
super().__init__(**data)
|
||||
if sandbox is not None:
|
||||
self._sandbox = sandbox # Directly set the base class private attribute
|
||||
|
||||
def _validate_base64_image(
|
||||
self, base64_string: str, max_size_mb: int = 10
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate base64 image data.
|
||||
Args:
|
||||
base64_string: The base64 encoded image data
|
||||
max_size_mb: Maximum allowed image size in megabytes
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
if not base64_string or len(base64_string) < 10:
|
||||
return False, "Base64 string is empty or too short"
|
||||
if base64_string.startswith("data:"):
|
||||
try:
|
||||
base64_string = base64_string.split(",", 1)[1]
|
||||
except (IndexError, ValueError):
|
||||
return False, "Invalid data URL format"
|
||||
import re
|
||||
|
||||
if not re.match(r"^[A-Za-z0-9+/]*={0,2}$", base64_string):
|
||||
return False, "Invalid base64 characters detected"
|
||||
if len(base64_string) % 4 != 0:
|
||||
return False, "Invalid base64 string length"
|
||||
try:
|
||||
image_data = base64.b64decode(base64_string, validate=True)
|
||||
except Exception as e:
|
||||
return False, f"Base64 decoding failed: {str(e)}"
|
||||
max_size_bytes = max_size_mb * 1024 * 1024
|
||||
if len(image_data) > max_size_bytes:
|
||||
return False, f"Image size exceeds limit ({max_size_bytes} bytes)"
|
||||
try:
|
||||
image_stream = io.BytesIO(image_data)
|
||||
with Image.open(image_stream) as img:
|
||||
img.verify()
|
||||
supported_formats = {"JPEG", "PNG", "GIF", "BMP", "WEBP", "TIFF"}
|
||||
if img.format not in supported_formats:
|
||||
return False, f"Unsupported image format: {img.format}"
|
||||
image_stream.seek(0)
|
||||
with Image.open(image_stream) as img_check:
|
||||
width, height = img_check.size
|
||||
max_dimension = 8192
|
||||
if width > max_dimension or height > max_dimension:
|
||||
return (
|
||||
False,
|
||||
f"Image dimensions exceed limit ({max_dimension}x{max_dimension})",
|
||||
)
|
||||
if width < 1 or height < 1:
|
||||
return False, f"Invalid image dimensions: {width}x{height}"
|
||||
except Exception as e:
|
||||
return False, f"Invalid image data: {str(e)}"
|
||||
return True, "Valid image"
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during base64 image validation: {e}")
|
||||
return False, f"Validation error: {str(e)}"
|
||||
|
||||
async def _execute_browser_action(
|
||||
self, endpoint: str, params: dict = None, method: str = "POST"
|
||||
) -> ToolResult:
|
||||
"""Execute a browser automation action through the sandbox API."""
|
||||
try:
|
||||
await self._ensure_sandbox()
|
||||
url = f"http://localhost:8003/api/automation/{endpoint}"
|
||||
if method == "GET" and params:
|
||||
query_params = "&".join([f"{k}={v}" for k, v in params.items()])
|
||||
url = f"{url}?{query_params}"
|
||||
curl_cmd = (
|
||||
f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'"
|
||||
)
|
||||
else:
|
||||
curl_cmd = (
|
||||
f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'"
|
||||
)
|
||||
if params:
|
||||
json_data = json.dumps(params)
|
||||
curl_cmd += f" -d '{json_data}'"
|
||||
logger.debug(f"Executing curl command: {curl_cmd}")
|
||||
response = self.sandbox.process.exec(curl_cmd, timeout=30)
|
||||
if response.exit_code == 0:
|
||||
try:
|
||||
result = json.loads(response.result)
|
||||
result.setdefault("content", "")
|
||||
result.setdefault("role", "assistant")
|
||||
if "screenshot_base64" in result:
|
||||
screenshot_data = result["screenshot_base64"]
|
||||
is_valid, validation_message = self._validate_base64_image(
|
||||
screenshot_data
|
||||
)
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"Screenshot validation failed: {validation_message}"
|
||||
)
|
||||
result["image_validation_error"] = validation_message
|
||||
del result["screenshot_base64"]
|
||||
|
||||
# added_message = await self.thread_manager.add_message(
|
||||
# thread_id=self.thread_id,
|
||||
# type="browser_state",
|
||||
# content=result,
|
||||
# is_llm_message=False
|
||||
# )
|
||||
message = ThreadMessage(
|
||||
type="browser_state", content=result, is_llm_message=False
|
||||
)
|
||||
self.browser_message = message
|
||||
success_response = {
|
||||
"success": result.get("success", False),
|
||||
"message": result.get("message", "Browser action completed"),
|
||||
}
|
||||
# if added_message and 'message_id' in added_message:
|
||||
# success_response['message_id'] = added_message['message_id']
|
||||
for field in [
|
||||
"url",
|
||||
"title",
|
||||
"element_count",
|
||||
"pixels_below",
|
||||
"ocr_text",
|
||||
"image_url",
|
||||
]:
|
||||
if field in result:
|
||||
success_response[field] = result[field]
|
||||
return (
|
||||
self.success_response(success_response)
|
||||
if success_response["success"]
|
||||
else self.fail_response(success_response)
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse response JSON: {e}")
|
||||
return self.fail_response(f"Failed to parse response JSON: {e}")
|
||||
else:
|
||||
logger.error(f"Browser automation request failed: {response}")
|
||||
return self.fail_response(
|
||||
f"Browser automation request failed: {response}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing browser action: {e}")
|
||||
logger.debug(traceback.format_exc())
|
||||
return self.fail_response(f"Error executing browser action: {e}")
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
action: str,
|
||||
url: Optional[str] = None,
|
||||
index: Optional[int] = None,
|
||||
text: Optional[str] = None,
|
||||
amount: Optional[int] = None,
|
||||
page_id: Optional[int] = None,
|
||||
keys: Optional[str] = None,
|
||||
seconds: Optional[int] = None,
|
||||
x: Optional[int] = None,
|
||||
y: Optional[int] = None,
|
||||
element_source: Optional[str] = None,
|
||||
element_target: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
"""
|
||||
Execute a browser action in the sandbox environment.
|
||||
Args:
|
||||
action: The browser action to perform
|
||||
url: URL for navigation
|
||||
index: Element index for interaction
|
||||
text: Text for input or scroll actions
|
||||
amount: Pixel amount to scroll
|
||||
page_id: Tab ID for tab management
|
||||
keys: Keys to send for keyboard actions
|
||||
seconds: Seconds to wait
|
||||
x: X coordinate for click/drag
|
||||
y: Y coordinate for click/drag
|
||||
element_source: Source element for drag and drop
|
||||
element_target: Target element for drag and drop
|
||||
Returns:
|
||||
ToolResult with the action's output or error
|
||||
"""
|
||||
# async with self.lock:
|
||||
try:
|
||||
# Navigation actions
|
||||
if action == "navigate_to":
|
||||
if not url:
|
||||
return self.fail_response("URL is required for navigation")
|
||||
return await self._execute_browser_action("navigate_to", {"url": url})
|
||||
elif action == "go_back":
|
||||
return await self._execute_browser_action("go_back", {})
|
||||
# Interaction actions
|
||||
elif action == "click_element":
|
||||
if index is None:
|
||||
return self.fail_response("Index is required for click_element")
|
||||
return await self._execute_browser_action(
|
||||
"click_element", {"index": index}
|
||||
)
|
||||
elif action == "input_text":
|
||||
if index is None or not text:
|
||||
return self.fail_response(
|
||||
"Index and text are required for input_text"
|
||||
)
|
||||
return await self._execute_browser_action(
|
||||
"input_text", {"index": index, "text": text}
|
||||
)
|
||||
elif action == "send_keys":
|
||||
if not keys:
|
||||
return self.fail_response("Keys are required for send_keys")
|
||||
return await self._execute_browser_action("send_keys", {"keys": keys})
|
||||
# Tab management
|
||||
elif action == "switch_tab":
|
||||
if page_id is None:
|
||||
return self.fail_response("Page ID is required for switch_tab")
|
||||
return await self._execute_browser_action(
|
||||
"switch_tab", {"page_id": page_id}
|
||||
)
|
||||
elif action == "close_tab":
|
||||
if page_id is None:
|
||||
return self.fail_response("Page ID is required for close_tab")
|
||||
return await self._execute_browser_action(
|
||||
"close_tab", {"page_id": page_id}
|
||||
)
|
||||
# Scrolling actions
|
||||
elif action == "scroll_down":
|
||||
params = {"amount": amount} if amount is not None else {}
|
||||
return await self._execute_browser_action("scroll_down", params)
|
||||
elif action == "scroll_up":
|
||||
params = {"amount": amount} if amount is not None else {}
|
||||
return await self._execute_browser_action("scroll_up", params)
|
||||
elif action == "scroll_to_text":
|
||||
if not text:
|
||||
return self.fail_response("Text is required for scroll_to_text")
|
||||
return await self._execute_browser_action(
|
||||
"scroll_to_text", {"text": text}
|
||||
)
|
||||
# Dropdown actions
|
||||
elif action == "get_dropdown_options":
|
||||
if index is None:
|
||||
return self.fail_response(
|
||||
"Index is required for get_dropdown_options"
|
||||
)
|
||||
return await self._execute_browser_action(
|
||||
"get_dropdown_options", {"index": index}
|
||||
)
|
||||
elif action == "select_dropdown_option":
|
||||
if index is None or not text:
|
||||
return self.fail_response(
|
||||
"Index and text are required for select_dropdown_option"
|
||||
)
|
||||
return await self._execute_browser_action(
|
||||
"select_dropdown_option", {"index": index, "text": text}
|
||||
)
|
||||
# Coordinate-based actions
|
||||
elif action == "click_coordinates":
|
||||
if x is None or y is None:
|
||||
return self.fail_response(
|
||||
"X and Y coordinates are required for click_coordinates"
|
||||
)
|
||||
return await self._execute_browser_action(
|
||||
"click_coordinates", {"x": x, "y": y}
|
||||
)
|
||||
elif action == "drag_drop":
|
||||
if not element_source or not element_target:
|
||||
return self.fail_response(
|
||||
"Source and target elements are required for drag_drop"
|
||||
)
|
||||
return await self._execute_browser_action(
|
||||
"drag_drop",
|
||||
{
|
||||
"element_source": element_source,
|
||||
"element_target": element_target,
|
||||
},
|
||||
)
|
||||
# Utility actions
|
||||
elif action == "wait":
|
||||
seconds_to_wait = seconds if seconds is not None else 3
|
||||
return await self._execute_browser_action(
|
||||
"wait", {"seconds": seconds_to_wait}
|
||||
)
|
||||
else:
|
||||
return self.fail_response(f"Unknown action: {action}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing browser action: {e}")
|
||||
return self.fail_response(f"Error executing browser action: {e}")
|
||||
|
||||
async def get_current_state(
|
||||
self, message: Optional[ThreadMessage] = None
|
||||
) -> ToolResult:
|
||||
"""
|
||||
Get the current browser state as a ToolResult.
|
||||
If context is not provided, uses self.context.
|
||||
"""
|
||||
try:
|
||||
# Use provided context or fall back to self.context
|
||||
message = message or self.browser_message
|
||||
if not message:
|
||||
return ToolResult(error="Browser context not initialized")
|
||||
state = message.content
|
||||
screenshot = state.get("screenshot_base64")
|
||||
# Build the state info with all required fields
|
||||
state_info = {
|
||||
"url": state.get("url", ""),
|
||||
"title": state.get("title", ""),
|
||||
"tabs": [tab.model_dump() for tab in state.get("tabs", [])],
|
||||
"pixels_above": getattr(state, "pixels_above", 0),
|
||||
"pixels_below": getattr(state, "pixels_below", 0),
|
||||
"help": "[0], [1], [2], etc., represent clickable indices corresponding to the elements listed. Clicking on these indices will navigate to or interact with the respective content behind them.",
|
||||
}
|
||||
|
||||
return ToolResult(
|
||||
output=json.dumps(state_info, indent=4, ensure_ascii=False),
|
||||
base64_image=screenshot,
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult(error=f"Failed to get browser state: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
def create_with_sandbox(cls, sandbox: Sandbox) -> "SandboxBrowserTool":
|
||||
"""Factory method to create a tool with sandbox."""
|
||||
return cls(sandbox=sandbox)
|
||||
@@ -0,0 +1,361 @@
|
||||
import asyncio
|
||||
from typing import Optional, TypeVar
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.daytona.tool_base import Sandbox, SandboxToolsBase
|
||||
from app.tool.base import ToolResult
|
||||
from app.utils.files_utils import clean_path, should_exclude_file
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
Context = TypeVar("Context")
|
||||
|
||||
_FILES_DESCRIPTION = """\
|
||||
A sandbox-based file system tool that allows file operations in a secure sandboxed environment.
|
||||
* This tool provides commands for creating, reading, updating, and deleting files in the workspace
|
||||
* All operations are performed relative to the /workspace directory for security
|
||||
* Use this when you need to manage files, edit code, or manipulate file contents in a sandbox
|
||||
* Each action requires specific parameters as defined in the tool's dependencies
|
||||
Key capabilities include:
|
||||
* File creation: Create new files with specified content and permissions
|
||||
* File modification: Replace specific strings or completely rewrite files
|
||||
* File deletion: Remove files from the workspace
|
||||
* File reading: Read file contents with optional line range specification
|
||||
"""
|
||||
|
||||
|
||||
class SandboxFilesTool(SandboxToolsBase):
|
||||
name: str = "sandbox_files"
|
||||
description: str = _FILES_DESCRIPTION
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"create_file",
|
||||
"str_replace",
|
||||
"full_file_rewrite",
|
||||
"delete_file",
|
||||
],
|
||||
"description": "The file operation to perform",
|
||||
},
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file, relative to /workspace (e.g., 'src/main.py')",
|
||||
},
|
||||
"file_contents": {
|
||||
"type": "string",
|
||||
"description": "Content to write to the file",
|
||||
},
|
||||
"old_str": {
|
||||
"type": "string",
|
||||
"description": "Text to be replaced (must appear exactly once)",
|
||||
},
|
||||
"new_str": {
|
||||
"type": "string",
|
||||
"description": "Replacement text",
|
||||
},
|
||||
"permissions": {
|
||||
"type": "string",
|
||||
"description": "File permissions in octal format (e.g., '644')",
|
||||
"default": "644",
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
"dependencies": {
|
||||
"create_file": ["file_path", "file_contents"],
|
||||
"str_replace": ["file_path", "old_str", "new_str"],
|
||||
"full_file_rewrite": ["file_path", "file_contents"],
|
||||
"delete_file": ["file_path"],
|
||||
},
|
||||
}
|
||||
SNIPPET_LINES: int = Field(default=4, exclude=True)
|
||||
# workspace_path: str = Field(default="/workspace", exclude=True)
|
||||
# sandbox: Optional[Sandbox] = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(
|
||||
self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data
|
||||
):
|
||||
"""Initialize with optional sandbox and thread_id."""
|
||||
super().__init__(**data)
|
||||
if sandbox is not None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
def clean_path(self, path: str) -> str:
|
||||
"""Clean and normalize a path to be relative to /workspace"""
|
||||
return clean_path(path, self.workspace_path)
|
||||
|
||||
def _should_exclude_file(self, rel_path: str) -> bool:
|
||||
"""Check if a file should be excluded based on path, name, or extension"""
|
||||
return should_exclude_file(rel_path)
|
||||
|
||||
def _file_exists(self, path: str) -> bool:
|
||||
"""Check if a file exists in the sandbox"""
|
||||
try:
|
||||
self.sandbox.fs.get_file_info(path)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_workspace_state(self) -> dict:
|
||||
"""Get the current workspace state by reading all files"""
|
||||
files_state = {}
|
||||
try:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
|
||||
files = self.sandbox.fs.list_files(self.workspace_path)
|
||||
for file_info in files:
|
||||
rel_path = file_info.name
|
||||
|
||||
# Skip excluded files and directories
|
||||
if self._should_exclude_file(rel_path) or file_info.is_dir:
|
||||
continue
|
||||
|
||||
try:
|
||||
full_path = f"{self.workspace_path}/{rel_path}"
|
||||
content = self.sandbox.fs.download_file(full_path).decode()
|
||||
files_state[rel_path] = {
|
||||
"content": content,
|
||||
"is_dir": file_info.is_dir,
|
||||
"size": file_info.size,
|
||||
"modified": file_info.mod_time,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error reading file {rel_path}: {e}")
|
||||
except UnicodeDecodeError:
|
||||
print(f"Skipping binary file: {rel_path}")
|
||||
|
||||
return files_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting workspace state: {str(e)}")
|
||||
return {}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
action: str,
|
||||
file_path: Optional[str] = None,
|
||||
file_contents: Optional[str] = None,
|
||||
old_str: Optional[str] = None,
|
||||
new_str: Optional[str] = None,
|
||||
permissions: Optional[str] = "644",
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
"""
|
||||
Execute a file operation in the sandbox environment.
|
||||
Args:
|
||||
action: The file operation to perform
|
||||
file_path: Path to the file relative to /workspace
|
||||
file_contents: Content to write to the file
|
||||
old_str: Text to be replaced (for str_replace)
|
||||
new_str: Replacement text (for str_replace)
|
||||
permissions: File permissions in octal format
|
||||
Returns:
|
||||
ToolResult with the operation's output or error
|
||||
"""
|
||||
async with asyncio.Lock():
|
||||
try:
|
||||
# File creation
|
||||
if action == "create_file":
|
||||
if not file_path or not file_contents:
|
||||
return self.fail_response(
|
||||
"file_path and file_contents are required for create_file"
|
||||
)
|
||||
return await self._create_file(
|
||||
file_path, file_contents, permissions
|
||||
)
|
||||
|
||||
# String replacement
|
||||
elif action == "str_replace":
|
||||
if not file_path or not old_str or not new_str:
|
||||
return self.fail_response(
|
||||
"file_path, old_str, and new_str are required for str_replace"
|
||||
)
|
||||
return await self._str_replace(file_path, old_str, new_str)
|
||||
|
||||
# Full file rewrite
|
||||
elif action == "full_file_rewrite":
|
||||
if not file_path or not file_contents:
|
||||
return self.fail_response(
|
||||
"file_path and file_contents are required for full_file_rewrite"
|
||||
)
|
||||
return await self._full_file_rewrite(
|
||||
file_path, file_contents, permissions
|
||||
)
|
||||
|
||||
# File deletion
|
||||
elif action == "delete_file":
|
||||
if not file_path:
|
||||
return self.fail_response(
|
||||
"file_path is required for delete_file"
|
||||
)
|
||||
return await self._delete_file(file_path)
|
||||
|
||||
else:
|
||||
return self.fail_response(f"Unknown action: {action}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing file action: {e}")
|
||||
return self.fail_response(f"Error executing file action: {e}")
|
||||
|
||||
async def _create_file(
|
||||
self, file_path: str, file_contents: str, permissions: str = "644"
|
||||
) -> ToolResult:
|
||||
"""Create a new file with the provided contents"""
|
||||
try:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
|
||||
file_path = self.clean_path(file_path)
|
||||
full_path = f"{self.workspace_path}/{file_path}"
|
||||
if self._file_exists(full_path):
|
||||
return self.fail_response(
|
||||
f"File '{file_path}' already exists. Use full_file_rewrite to modify existing files."
|
||||
)
|
||||
|
||||
# Create parent directories if needed
|
||||
parent_dir = "/".join(full_path.split("/")[:-1])
|
||||
if parent_dir:
|
||||
self.sandbox.fs.create_folder(parent_dir, "755")
|
||||
|
||||
# Write the file content
|
||||
self.sandbox.fs.upload_file(file_contents.encode(), full_path)
|
||||
self.sandbox.fs.set_file_permissions(full_path, permissions)
|
||||
|
||||
message = f"File '{file_path}' created successfully."
|
||||
|
||||
# Check if index.html was created and add 8080 server info (only in root workspace)
|
||||
if file_path.lower() == "index.html":
|
||||
try:
|
||||
website_link = self.sandbox.get_preview_link(8080)
|
||||
website_url = (
|
||||
website_link.url
|
||||
if hasattr(website_link, "url")
|
||||
else str(website_link).split("url='")[1].split("'")[0]
|
||||
)
|
||||
message += f"\n\n[Auto-detected index.html - HTTP server available at: {website_url}]"
|
||||
message += "\n[Note: Use the provided HTTP server URL above instead of starting a new server]"
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get website URL for index.html: {str(e)}"
|
||||
)
|
||||
|
||||
return self.success_response(message)
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Error creating file: {str(e)}")
|
||||
|
||||
async def _str_replace(
|
||||
self, file_path: str, old_str: str, new_str: str
|
||||
) -> ToolResult:
|
||||
"""Replace specific text in a file"""
|
||||
try:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
|
||||
file_path = self.clean_path(file_path)
|
||||
full_path = f"{self.workspace_path}/{file_path}"
|
||||
if not self._file_exists(full_path):
|
||||
return self.fail_response(f"File '{file_path}' does not exist")
|
||||
|
||||
content = self.sandbox.fs.download_file(full_path).decode()
|
||||
old_str = old_str.expandtabs()
|
||||
new_str = new_str.expandtabs()
|
||||
|
||||
occurrences = content.count(old_str)
|
||||
if occurrences == 0:
|
||||
return self.fail_response(f"String '{old_str}' not found in file")
|
||||
if occurrences > 1:
|
||||
lines = [
|
||||
i + 1
|
||||
for i, line in enumerate(content.split("\n"))
|
||||
if old_str in line
|
||||
]
|
||||
return self.fail_response(
|
||||
f"Multiple occurrences found in lines {lines}. Please ensure string is unique"
|
||||
)
|
||||
|
||||
# Perform replacement
|
||||
new_content = content.replace(old_str, new_str)
|
||||
self.sandbox.fs.upload_file(new_content.encode(), full_path)
|
||||
|
||||
# Show snippet around the edit
|
||||
replacement_line = content.split(old_str)[0].count("\n")
|
||||
start_line = max(0, replacement_line - self.SNIPPET_LINES)
|
||||
end_line = replacement_line + self.SNIPPET_LINES + new_str.count("\n")
|
||||
snippet = "\n".join(new_content.split("\n")[start_line : end_line + 1])
|
||||
|
||||
message = f"Replacement successful."
|
||||
|
||||
return self.success_response(message)
|
||||
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Error replacing string: {str(e)}")
|
||||
|
||||
async def _full_file_rewrite(
|
||||
self, file_path: str, file_contents: str, permissions: str = "644"
|
||||
) -> ToolResult:
|
||||
"""Completely rewrite an existing file with new content"""
|
||||
try:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
|
||||
file_path = self.clean_path(file_path)
|
||||
full_path = f"{self.workspace_path}/{file_path}"
|
||||
if not self._file_exists(full_path):
|
||||
return self.fail_response(
|
||||
f"File '{file_path}' does not exist. Use create_file to create a new file."
|
||||
)
|
||||
|
||||
self.sandbox.fs.upload_file(file_contents.encode(), full_path)
|
||||
self.sandbox.fs.set_file_permissions(full_path, permissions)
|
||||
|
||||
message = f"File '{file_path}' completely rewritten successfully."
|
||||
|
||||
# Check if index.html was rewritten and add 8080 server info (only in root workspace)
|
||||
if file_path.lower() == "index.html":
|
||||
try:
|
||||
website_link = self.sandbox.get_preview_link(8080)
|
||||
website_url = (
|
||||
website_link.url
|
||||
if hasattr(website_link, "url")
|
||||
else str(website_link).split("url='")[1].split("'")[0]
|
||||
)
|
||||
message += f"\n\n[Auto-detected index.html - HTTP server available at: {website_url}]"
|
||||
message += "\n[Note: Use the provided HTTP server URL above instead of starting a new server]"
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get website URL for index.html: {str(e)}"
|
||||
)
|
||||
|
||||
return self.success_response(message)
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Error rewriting file: {str(e)}")
|
||||
|
||||
async def _delete_file(self, file_path: str) -> ToolResult:
|
||||
"""Delete a file at the given path"""
|
||||
try:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
|
||||
file_path = self.clean_path(file_path)
|
||||
full_path = f"{self.workspace_path}/{file_path}"
|
||||
if not self._file_exists(full_path):
|
||||
return self.fail_response(f"File '{file_path}' does not exist")
|
||||
|
||||
self.sandbox.fs.delete_file(full_path)
|
||||
return self.success_response(f"File '{file_path}' deleted successfully.")
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Error deleting file: {str(e)}")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up sandbox resources."""
|
||||
|
||||
@classmethod
|
||||
def create_with_context(cls, context: Context) -> "SandboxFilesTool[Context]":
|
||||
"""Factory method to create a SandboxFilesTool with a specific context."""
|
||||
raise NotImplementedError(
|
||||
"create_with_context not implemented for SandboxFilesTool"
|
||||
)
|
||||
@@ -0,0 +1,419 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Dict, Optional, TypeVar
|
||||
from uuid import uuid4
|
||||
|
||||
from app.daytona.tool_base import Sandbox, SandboxToolsBase
|
||||
from app.tool.base import ToolResult
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
Context = TypeVar("Context")
|
||||
_SHELL_DESCRIPTION = """\
|
||||
Execute a shell command in the workspace directory.
|
||||
IMPORTANT: Commands are non-blocking by default and run in a tmux session.
|
||||
This is ideal for long-running operations like starting servers or build processes.
|
||||
Uses sessions to maintain state between commands.
|
||||
This tool is essential for running CLI tools, installing packages, and managing system operations.
|
||||
"""
|
||||
|
||||
|
||||
class SandboxShellTool(SandboxToolsBase):
|
||||
"""Tool for executing tasks in a Daytona sandbox with browser-use capabilities.
|
||||
Uses sessions for maintaining state between commands and provides comprehensive process management.
|
||||
"""
|
||||
|
||||
name: str = "sandbox_shell"
|
||||
description: str = _SHELL_DESCRIPTION
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"execute_command",
|
||||
"check_command_output",
|
||||
"terminate_command",
|
||||
"list_commands",
|
||||
],
|
||||
"description": "The shell action to perform",
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The shell command to execute. Use this for running CLI tools, installing packages, "
|
||||
"or system operations. Commands can be chained using &&, ||, and | operators.",
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description": "Optional relative path to a subdirectory of /workspace where the command should be "
|
||||
"executed. Example: 'data/pdfs'",
|
||||
},
|
||||
"session_name": {
|
||||
"type": "string",
|
||||
"description": "Optional name of the tmux session to use. Use named sessions for related commands "
|
||||
"that need to maintain state. Defaults to a random session name.",
|
||||
},
|
||||
"blocking": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to wait for the command to complete. Defaults to false for non-blocking "
|
||||
"execution.",
|
||||
"default": False,
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Optional timeout in seconds for blocking commands. Defaults to 60. Ignored for "
|
||||
"non-blocking commands.",
|
||||
"default": 60,
|
||||
},
|
||||
"kill_session": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to terminate the tmux session after checking. Set to true when you're done "
|
||||
"with the command.",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
"dependencies": {
|
||||
"execute_command": ["command"],
|
||||
"check_command_output": ["session_name"],
|
||||
"terminate_command": ["session_name"],
|
||||
"list_commands": [],
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data
|
||||
):
|
||||
"""Initialize with optional sandbox and thread_id."""
|
||||
super().__init__(**data)
|
||||
if sandbox is not None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
async def _ensure_session(self, session_name: str = "default") -> str:
|
||||
"""Ensure a session exists and return its ID."""
|
||||
if session_name not in self._sessions:
|
||||
session_id = str(uuid4())
|
||||
try:
|
||||
await self._ensure_sandbox() # Ensure sandbox is initialized
|
||||
self.sandbox.process.create_session(session_id)
|
||||
self._sessions[session_name] = session_id
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to create session: {str(e)}")
|
||||
return self._sessions[session_name]
|
||||
|
||||
async def _cleanup_session(self, session_name: str):
|
||||
"""Clean up a session if it exists."""
|
||||
if session_name in self._sessions:
|
||||
try:
|
||||
await self._ensure_sandbox() # Ensure sandbox is initialized
|
||||
self.sandbox.process.delete_session(self._sessions[session_name])
|
||||
del self._sessions[session_name]
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to cleanup session {session_name}: {str(e)}")
|
||||
|
||||
async def _execute_raw_command(self, command: str) -> Dict[str, Any]:
|
||||
"""Execute a raw command directly in the sandbox."""
|
||||
# Ensure session exists for raw commands
|
||||
session_id = await self._ensure_session("raw_commands")
|
||||
|
||||
# Execute command in session
|
||||
from app.daytona.sandbox import SessionExecuteRequest
|
||||
|
||||
req = SessionExecuteRequest(
|
||||
command=command, run_async=False, cwd=self.workspace_path
|
||||
)
|
||||
|
||||
response = self.sandbox.process.execute_session_command(
|
||||
session_id=session_id,
|
||||
req=req,
|
||||
timeout=30, # Short timeout for utility commands
|
||||
)
|
||||
|
||||
logs = self.sandbox.process.get_session_command_logs(
|
||||
session_id=session_id, command_id=response.cmd_id
|
||||
)
|
||||
|
||||
return {"output": logs, "exit_code": response.exit_code}
|
||||
|
||||
async def _execute_command(
|
||||
self,
|
||||
command: str,
|
||||
folder: Optional[str] = None,
|
||||
session_name: Optional[str] = None,
|
||||
blocking: bool = False,
|
||||
timeout: int = 60,
|
||||
) -> ToolResult:
|
||||
try:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
|
||||
# Set up working directory
|
||||
cwd = self.workspace_path
|
||||
if folder:
|
||||
folder = folder.strip("/")
|
||||
cwd = f"{self.workspace_path}/{folder}"
|
||||
|
||||
# Generate a session name if not provided
|
||||
if not session_name:
|
||||
session_name = f"session_{str(uuid4())[:8]}"
|
||||
|
||||
# Check if tmux session already exists
|
||||
check_session = await self._execute_raw_command(
|
||||
f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'"
|
||||
)
|
||||
session_exists = "not_exists" not in check_session.get("output", "")
|
||||
|
||||
if not session_exists:
|
||||
# Create a new tmux session
|
||||
await self._execute_raw_command(
|
||||
f"tmux new-session -d -s {session_name}"
|
||||
)
|
||||
|
||||
# Ensure we're in the correct directory and send command to tmux
|
||||
full_command = f"cd {cwd} && {command}"
|
||||
wrapped_command = full_command.replace('"', '\\"') # Escape double quotes
|
||||
|
||||
# Send command to tmux session
|
||||
await self._execute_raw_command(
|
||||
f'tmux send-keys -t {session_name} "{wrapped_command}" Enter'
|
||||
)
|
||||
|
||||
if blocking:
|
||||
# For blocking execution, wait and capture output
|
||||
start_time = time.time()
|
||||
while (time.time() - start_time) < timeout:
|
||||
# Wait a bit before checking
|
||||
time.sleep(2)
|
||||
|
||||
# Check if session still exists (command might have exited)
|
||||
check_result = await self._execute_raw_command(
|
||||
f"tmux has-session -t {session_name} 2>/dev/null || echo 'ended'"
|
||||
)
|
||||
if "ended" in check_result.get("output", ""):
|
||||
break
|
||||
|
||||
# Get current output and check for common completion indicators
|
||||
output_result = await self._execute_raw_command(
|
||||
f"tmux capture-pane -t {session_name} -p -S - -E -"
|
||||
)
|
||||
current_output = output_result.get("output", "")
|
||||
|
||||
# Check for prompt indicators that suggest command completion
|
||||
last_lines = current_output.split("\n")[-3:]
|
||||
completion_indicators = [
|
||||
"$",
|
||||
"#",
|
||||
">",
|
||||
"Done",
|
||||
"Completed",
|
||||
"Finished",
|
||||
"✓",
|
||||
]
|
||||
if any(
|
||||
indicator in line
|
||||
for indicator in completion_indicators
|
||||
for line in last_lines
|
||||
):
|
||||
break
|
||||
|
||||
# Capture final output
|
||||
output_result = await self._execute_raw_command(
|
||||
f"tmux capture-pane -t {session_name} -p -S - -E -"
|
||||
)
|
||||
final_output = output_result.get("output", "")
|
||||
|
||||
# Kill the session after capture
|
||||
await self._execute_raw_command(f"tmux kill-session -t {session_name}")
|
||||
|
||||
return self.success_response(
|
||||
{
|
||||
"output": final_output,
|
||||
"session_name": session_name,
|
||||
"cwd": cwd,
|
||||
"completed": True,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# For non-blocking, just return immediately
|
||||
return self.success_response(
|
||||
{
|
||||
"session_name": session_name,
|
||||
"cwd": cwd,
|
||||
"message": f"Command sent to tmux session '{session_name}'. Use check_command_output to view results.",
|
||||
"completed": False,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Attempt to clean up session in case of error
|
||||
if session_name:
|
||||
try:
|
||||
await self._execute_raw_command(
|
||||
f"tmux kill-session -t {session_name}"
|
||||
)
|
||||
except:
|
||||
pass
|
||||
return self.fail_response(f"Error executing command: {str(e)}")
|
||||
|
||||
async def _check_command_output(
|
||||
self, session_name: str, kill_session: bool = False
|
||||
) -> ToolResult:
|
||||
try:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
|
||||
# Check if session exists
|
||||
check_result = await self._execute_raw_command(
|
||||
f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'"
|
||||
)
|
||||
if "not_exists" in check_result.get("output", ""):
|
||||
return self.fail_response(
|
||||
f"Tmux session '{session_name}' does not exist."
|
||||
)
|
||||
|
||||
# Get output from tmux pane
|
||||
output_result = await self._execute_raw_command(
|
||||
f"tmux capture-pane -t {session_name} -p -S - -E -"
|
||||
)
|
||||
output = output_result.get("output", "")
|
||||
|
||||
# Kill session if requested
|
||||
if kill_session:
|
||||
await self._execute_raw_command(f"tmux kill-session -t {session_name}")
|
||||
termination_status = "Session terminated."
|
||||
else:
|
||||
termination_status = "Session still running."
|
||||
|
||||
return self.success_response(
|
||||
{
|
||||
"output": output,
|
||||
"session_name": session_name,
|
||||
"status": termination_status,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Error checking command output: {str(e)}")
|
||||
|
||||
async def _terminate_command(self, session_name: str) -> ToolResult:
|
||||
try:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
|
||||
# Check if session exists
|
||||
check_result = await self._execute_raw_command(
|
||||
f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'"
|
||||
)
|
||||
if "not_exists" in check_result.get("output", ""):
|
||||
return self.fail_response(
|
||||
f"Tmux session '{session_name}' does not exist."
|
||||
)
|
||||
|
||||
# Kill the session
|
||||
await self._execute_raw_command(f"tmux kill-session -t {session_name}")
|
||||
|
||||
return self.success_response(
|
||||
{"message": f"Tmux session '{session_name}' terminated successfully."}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Error terminating command: {str(e)}")
|
||||
|
||||
async def _list_commands(self) -> ToolResult:
|
||||
try:
|
||||
# Ensure sandbox is initialized
|
||||
await self._ensure_sandbox()
|
||||
|
||||
# List all tmux sessions
|
||||
result = await self._execute_raw_command(
|
||||
"tmux list-sessions 2>/dev/null || echo 'No sessions'"
|
||||
)
|
||||
output = result.get("output", "")
|
||||
|
||||
if "No sessions" in output or not output.strip():
|
||||
return self.success_response(
|
||||
{"message": "No active tmux sessions found.", "sessions": []}
|
||||
)
|
||||
|
||||
# Parse session list
|
||||
sessions = []
|
||||
for line in output.split("\n"):
|
||||
if line.strip():
|
||||
parts = line.split(":")
|
||||
if parts:
|
||||
session_name = parts[0].strip()
|
||||
sessions.append(session_name)
|
||||
|
||||
return self.success_response(
|
||||
{
|
||||
"message": f"Found {len(sessions)} active sessions.",
|
||||
"sessions": sessions,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Error listing commands: {str(e)}")
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
action: str,
|
||||
command: str,
|
||||
folder: Optional[str] = None,
|
||||
session_name: Optional[str] = None,
|
||||
blocking: bool = False,
|
||||
timeout: int = 60,
|
||||
kill_session: bool = False,
|
||||
) -> ToolResult:
|
||||
"""
|
||||
Execute a browser action in the sandbox environment.
|
||||
Args:
|
||||
timeout:
|
||||
blocking:
|
||||
session_name:
|
||||
folder:
|
||||
command:
|
||||
kill_session:
|
||||
action: The browser action to perform
|
||||
Returns:
|
||||
ToolResult with the action's output or error
|
||||
"""
|
||||
async with asyncio.Lock():
|
||||
try:
|
||||
# Navigation actions
|
||||
if action == "execute_command":
|
||||
if not command:
|
||||
return self.fail_response("command is required for navigation")
|
||||
return await self._execute_command(
|
||||
command, folder, session_name, blocking, timeout
|
||||
)
|
||||
elif action == "check_command_output":
|
||||
if session_name is None:
|
||||
return self.fail_response(
|
||||
"session_name is required for navigation"
|
||||
)
|
||||
return await self._check_command_output(session_name, kill_session)
|
||||
elif action == "terminate_command":
|
||||
if session_name is None:
|
||||
return self.fail_response(
|
||||
"session_name is required for click_element"
|
||||
)
|
||||
return await self._terminate_command(session_name)
|
||||
elif action == "list_commands":
|
||||
return await self._list_commands()
|
||||
else:
|
||||
return self.fail_response(f"Unknown action: {action}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing shell action: {e}")
|
||||
return self.fail_response(f"Error executing shell action: {e}")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up all sessions."""
|
||||
for session_name in list(self._sessions.keys()):
|
||||
await self._cleanup_session(session_name)
|
||||
|
||||
# Also clean up any tmux sessions
|
||||
try:
|
||||
await self._ensure_sandbox()
|
||||
await self._execute_raw_command("tmux kill-server 2>/dev/null || true")
|
||||
except Exception as e:
|
||||
logger.error(f"Error shell box cleanup action: {e}")
|
||||
@@ -0,0 +1,178 @@
|
||||
import base64
|
||||
import mimetypes
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image
|
||||
from pydantic import Field
|
||||
|
||||
from app.daytona.tool_base import Sandbox, SandboxToolsBase, ThreadMessage
|
||||
from app.tool.base import ToolResult
|
||||
|
||||
|
||||
# 最大文件大小(原图10MB,压缩后5MB)
|
||||
MAX_IMAGE_SIZE = 10 * 1024 * 1024
|
||||
MAX_COMPRESSED_SIZE = 5 * 1024 * 1024
|
||||
|
||||
# 压缩设置
|
||||
DEFAULT_MAX_WIDTH = 1920
|
||||
DEFAULT_MAX_HEIGHT = 1080
|
||||
DEFAULT_JPEG_QUALITY = 85
|
||||
DEFAULT_PNG_COMPRESS_LEVEL = 6
|
||||
|
||||
_VISION_DESCRIPTION = """
|
||||
A sandbox-based vision tool that allows the agent to read image files inside the sandbox using the see_image action.
|
||||
* Only the see_image action is supported, with the parameter being the relative path of the image under /workspace.
|
||||
* The image will be compressed and converted to base64 for use in subsequent context.
|
||||
* Supported formats: JPG, PNG, GIF, WEBP. Maximum size: 10MB.
|
||||
"""
|
||||
|
||||
|
||||
class SandboxVisionTool(SandboxToolsBase):
|
||||
name: str = "sandbox_vision"
|
||||
description: str = _VISION_DESCRIPTION
|
||||
parameters: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["see_image"],
|
||||
"description": "要执行的视觉动作,目前仅支持 see_image",
|
||||
},
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "图片在 /workspace 下的相对路径,如 'screenshots/image.png'",
|
||||
},
|
||||
},
|
||||
"required": ["action", "file_path"],
|
||||
"dependencies": {"see_image": ["file_path"]},
|
||||
}
|
||||
|
||||
# def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManager):
|
||||
# super().__init__(project_id=project_id, thread_manager=thread_manager)
|
||||
# self.thread_id = thread_id
|
||||
# self.thread_manager = thread_manager
|
||||
|
||||
vision_message: Optional[ThreadMessage] = Field(default=None, exclude=True)
|
||||
|
||||
def __init__(
|
||||
self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data
|
||||
):
|
||||
"""Initialize with optional sandbox and thread_id."""
|
||||
super().__init__(**data)
|
||||
if sandbox is not None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str):
|
||||
"""压缩图片,保持合理质量。"""
|
||||
try:
|
||||
img = Image.open(BytesIO(image_bytes))
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||
if img.mode == "P":
|
||||
img = img.convert("RGBA")
|
||||
background.paste(
|
||||
img, mask=img.split()[-1] if img.mode == "RGBA" else None
|
||||
)
|
||||
img = background
|
||||
width, height = img.size
|
||||
if width > DEFAULT_MAX_WIDTH or height > DEFAULT_MAX_HEIGHT:
|
||||
ratio = min(DEFAULT_MAX_WIDTH / width, DEFAULT_MAX_HEIGHT / height)
|
||||
new_width = int(width * ratio)
|
||||
new_height = int(height * ratio)
|
||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
output = BytesIO()
|
||||
if mime_type == "image/gif":
|
||||
img.save(output, format="GIF", optimize=True)
|
||||
output_mime = "image/gif"
|
||||
elif mime_type == "image/png":
|
||||
img.save(
|
||||
output,
|
||||
format="PNG",
|
||||
optimize=True,
|
||||
compress_level=DEFAULT_PNG_COMPRESS_LEVEL,
|
||||
)
|
||||
output_mime = "image/png"
|
||||
else:
|
||||
img.save(
|
||||
output, format="JPEG", quality=DEFAULT_JPEG_QUALITY, optimize=True
|
||||
)
|
||||
output_mime = "image/jpeg"
|
||||
compressed_bytes = output.getvalue()
|
||||
return compressed_bytes, output_mime
|
||||
except Exception:
|
||||
return image_bytes, mime_type
|
||||
|
||||
async def execute(
|
||||
self, action: str, file_path: Optional[str] = None, **kwargs
|
||||
) -> ToolResult:
|
||||
"""
|
||||
执行视觉动作,目前仅支持 see_image。
|
||||
参数:
|
||||
action: 必须为 'see_image'
|
||||
file_path: 图片相对路径
|
||||
"""
|
||||
if action != "see_image":
|
||||
return self.fail_response(f"未知的视觉动作: {action}")
|
||||
if not file_path:
|
||||
return self.fail_response("file_path 参数不能为空")
|
||||
try:
|
||||
await self._ensure_sandbox()
|
||||
cleaned_path = self.clean_path(file_path)
|
||||
full_path = f"{self.workspace_path}/{cleaned_path}"
|
||||
try:
|
||||
file_info = self.sandbox.fs.get_file_info(full_path)
|
||||
if file_info.is_dir:
|
||||
return self.fail_response(f"路径 '{cleaned_path}' 是目录,不是图片文件。")
|
||||
except Exception:
|
||||
return self.fail_response(f"图片文件未找到: '{cleaned_path}'")
|
||||
if file_info.size > MAX_IMAGE_SIZE:
|
||||
return self.fail_response(
|
||||
f"图片文件 '{cleaned_path}' 过大 ({file_info.size / (1024*1024):.2f}MB),最大允许 {MAX_IMAGE_SIZE / (1024*1024)}MB。"
|
||||
)
|
||||
try:
|
||||
image_bytes = self.sandbox.fs.download_file(full_path)
|
||||
except Exception:
|
||||
return self.fail_response(f"无法读取图片文件: {cleaned_path}")
|
||||
mime_type, _ = mimetypes.guess_type(full_path)
|
||||
if not mime_type or not mime_type.startswith("image/"):
|
||||
ext = os.path.splitext(cleaned_path)[1].lower()
|
||||
if ext == ".jpg" or ext == ".jpeg":
|
||||
mime_type = "image/jpeg"
|
||||
elif ext == ".png":
|
||||
mime_type = "image/png"
|
||||
elif ext == ".gif":
|
||||
mime_type = "image/gif"
|
||||
elif ext == ".webp":
|
||||
mime_type = "image/webp"
|
||||
else:
|
||||
return self.fail_response(
|
||||
f"不支持或未知的图片格式: '{cleaned_path}'。支持: JPG, PNG, GIF, WEBP。"
|
||||
)
|
||||
compressed_bytes, compressed_mime_type = self.compress_image(
|
||||
image_bytes, mime_type, cleaned_path
|
||||
)
|
||||
if len(compressed_bytes) > MAX_COMPRESSED_SIZE:
|
||||
return self.fail_response(
|
||||
f"图片文件 '{cleaned_path}' 压缩后仍过大 ({len(compressed_bytes) / (1024*1024):.2f}MB),最大允许 {MAX_COMPRESSED_SIZE / (1024*1024)}MB。"
|
||||
)
|
||||
base64_image = base64.b64encode(compressed_bytes).decode("utf-8")
|
||||
image_context_data = {
|
||||
"mime_type": compressed_mime_type,
|
||||
"base64": base64_image,
|
||||
"file_path": cleaned_path,
|
||||
"original_size": file_info.size,
|
||||
"compressed_size": len(compressed_bytes),
|
||||
}
|
||||
message = ThreadMessage(
|
||||
type="image_context", content=image_context_data, is_llm_message=False
|
||||
)
|
||||
self.vision_message = message
|
||||
# return self.success_response(f"成功加载并压缩图片 '{cleaned_path}' (由 {file_info.size / 1024:.1f}KB 压缩到 {len(compressed_bytes) / 1024:.1f}KB)。")
|
||||
return ToolResult(
|
||||
output=f"成功加载并压缩图片 '{cleaned_path}'",
|
||||
base64_image=base64_image,
|
||||
)
|
||||
except Exception as e:
|
||||
return self.fail_response(f"see_image 执行异常: {str(e)}")
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Utility functions and constants for agent tools
|
||||
@@ -0,0 +1,87 @@
|
||||
import os
|
||||
|
||||
|
||||
# Files to exclude from operations
|
||||
EXCLUDED_FILES = {
|
||||
".DS_Store",
|
||||
".gitignore",
|
||||
"package-lock.json",
|
||||
"postcss.config.js",
|
||||
"postcss.config.mjs",
|
||||
"jsconfig.json",
|
||||
"components.json",
|
||||
"tsconfig.tsbuildinfo",
|
||||
"tsconfig.json",
|
||||
}
|
||||
|
||||
# Directories to exclude from operations
|
||||
EXCLUDED_DIRS = {"node_modules", ".next", "dist", "build", ".git"}
|
||||
|
||||
# File extensions to exclude from operations
|
||||
EXCLUDED_EXT = {
|
||||
".ico",
|
||||
".svg",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".webp",
|
||||
".db",
|
||||
".sql",
|
||||
}
|
||||
|
||||
|
||||
def should_exclude_file(rel_path: str) -> bool:
|
||||
"""Check if a file should be excluded based on path, name, or extension
|
||||
|
||||
Args:
|
||||
rel_path: Relative path of the file to check
|
||||
|
||||
Returns:
|
||||
True if the file should be excluded, False otherwise
|
||||
"""
|
||||
# Check filename
|
||||
filename = os.path.basename(rel_path)
|
||||
if filename in EXCLUDED_FILES:
|
||||
return True
|
||||
|
||||
# Check directory
|
||||
dir_path = os.path.dirname(rel_path)
|
||||
if any(excluded in dir_path for excluded in EXCLUDED_DIRS):
|
||||
return True
|
||||
|
||||
# Check extension
|
||||
_, ext = os.path.splitext(filename)
|
||||
if ext.lower() in EXCLUDED_EXT:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def clean_path(path: str, workspace_path: str = "/workspace") -> str:
|
||||
"""Clean and normalize a path to be relative to the workspace
|
||||
|
||||
Args:
|
||||
path: The path to clean
|
||||
workspace_path: The base workspace path to remove (default: "/workspace")
|
||||
|
||||
Returns:
|
||||
The cleaned path, relative to the workspace
|
||||
"""
|
||||
# Remove any leading slash
|
||||
path = path.lstrip("/")
|
||||
|
||||
# Remove workspace prefix if present
|
||||
if path.startswith(workspace_path.lstrip("/")):
|
||||
path = path[len(workspace_path.lstrip("/")) :]
|
||||
|
||||
# Remove workspace/ prefix if present
|
||||
if path.startswith("workspace/"):
|
||||
path = path[9:]
|
||||
|
||||
# Remove any remaining leading slash
|
||||
path = path.lstrip("/")
|
||||
|
||||
return path
|
||||
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
ENV_MODE = os.getenv("ENV_MODE", "LOCAL")
|
||||
|
||||
renderer = [structlog.processors.JSONRenderer()]
|
||||
if ENV_MODE.lower() == "local".lower():
|
||||
renderer = [structlog.dev.ConsoleRenderer()]
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.dict_tracebacks,
|
||||
structlog.processors.CallsiteParameterAdder(
|
||||
{
|
||||
structlog.processors.CallsiteParameter.FILENAME,
|
||||
structlog.processors.CallsiteParameter.FUNC_NAME,
|
||||
structlog.processors.CallsiteParameter.LINENO,
|
||||
}
|
||||
),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.contextvars.merge_contextvars,
|
||||
*renderer,
|
||||
],
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
logger: structlog.stdlib.BoundLogger = structlog.get_logger(level=logging.DEBUG)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
@@ -0,0 +1,114 @@
|
||||
# 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
|
||||
|
||||
# [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_DEPLOYMENT_ID}"
|
||||
# api_key = "AZURE API KEY"
|
||||
# max_tokens = 8096
|
||||
# temperature = 0.0
|
||||
# api_version="AZURE API VERSION" #"2024-08-01-preview"
|
||||
|
||||
# [llm] #OLLAMA:
|
||||
# api_type = 'ollama'
|
||||
# model = "llama3.2"
|
||||
# base_url = "http://localhost:11434/v1"
|
||||
# api_key = "ollama"
|
||||
# max_tokens = 4096
|
||||
# temperature = 0.0
|
||||
|
||||
# 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
|
||||
|
||||
# [llm.vision] #OLLAMA VISION:
|
||||
# api_type = 'ollama'
|
||||
# model = "llama3.2-vision"
|
||||
# base_url = "http://localhost:11434/v1"
|
||||
# api_key = "ollama"
|
||||
# max_tokens = 4096
|
||||
# temperature = 0.0
|
||||
|
||||
# Optional configuration for specific browser configuration
|
||||
# [browser]
|
||||
# Whether to run browser in headless mode (default: false)
|
||||
#headless = false
|
||||
# Disable browser security features (default: true)
|
||||
#disable_security = true
|
||||
# Extra arguments to pass to the browser
|
||||
#extra_chromium_args = []
|
||||
# Path to a Chrome instance to use to connect to your normal browser
|
||||
# e.g. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||||
#chrome_instance_path = ""
|
||||
# Connect to a browser instance via WebSocket
|
||||
#wss_url = ""
|
||||
# Connect to a browser instance via CDP
|
||||
#cdp_url = ""
|
||||
|
||||
# Optional configuration, Proxy settings for the browser
|
||||
# [browser.proxy]
|
||||
# server = "http://proxy-server:port"
|
||||
# username = "proxy-username"
|
||||
# password = "proxy-password"
|
||||
|
||||
# Optional configuration, Search settings.
|
||||
# [search]
|
||||
# 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]
|
||||
#use_sandbox = false
|
||||
#image = "python:3.12-slim"
|
||||
#work_dir = "/workspace"
|
||||
#memory_limit = "1g" # 512m
|
||||
#cpu_limit = 2.0
|
||||
#timeout = 300
|
||||
#network_enabled = true
|
||||
|
||||
# Daytona configuration
|
||||
[daytona]
|
||||
daytona_api_key = ""
|
||||
#daytona_server_url = "https://app.daytona.io/api"
|
||||
#daytona_target = "us" #Daytona is currently available in the following regions:United States (us)、Europe (eu)
|
||||
#sandbox_image_name = "whitezxj/sandbox:0.1.0" #If you don't use this default image,sandbox tools may be useless
|
||||
#sandbox_entrypoint = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf" #If you change this entrypoint,server in sandbox may be useless
|
||||
#VNC_password = #The password you set to log in sandbox by VNC,it will be 123456 if you don't set
|
||||
|
||||
# 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,17 @@
|
||||
# Global LLM configuration
|
||||
[llm] #Jiekou.AI:
|
||||
api_type = 'jiekou'
|
||||
model = "claude-sonnet-4-5-20250929" # The LLM model to use
|
||||
base_url = "https://api.jiekou.ai/openai" # API endpoint URL
|
||||
api_key = "your Jiekou.AI api key" # Your API key
|
||||
max_tokens = 64000 # Maximum number of tokens in the response
|
||||
temperature = 0.0 # Controls randomness
|
||||
|
||||
|
||||
[llm.vision] #Jiekou.AI VISION:
|
||||
api_type = 'jiekou'
|
||||
model = "claude-sonnet-4-5-20250929" # The vision model to use
|
||||
base_url = "https://api.jiekou.ai/openai" # API endpoint URL for vision model
|
||||
api_key = "your Jiekou.AI api key" # Your API key for vision model
|
||||
max_tokens = 64000 # 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
|
||||
@@ -31,6 +31,14 @@ temperature = 0.0 # Controls randomness
|
||||
# max_tokens = 4096
|
||||
# temperature = 0.0
|
||||
|
||||
# [llm] #Jiekou.AI:
|
||||
# api_type = 'jiekou'
|
||||
# model = "claude-sonnet-4-5-20250929" # The LLM model to use
|
||||
# base_url = "https://api.jiekou.ai/openai" # API endpoint URL
|
||||
# api_key = "your Jiekou.AI api key" # Your API key
|
||||
# max_tokens = 64000 # 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
|
||||
@@ -98,3 +106,8 @@ temperature = 0.0 # Controls randomness for vision mode
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# 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,194 @@
|
||||
# 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 @@
|
||||
from typing import Any, AsyncIterable, ClassVar, Dict, List, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.agent.manus import Manus
|
||||
|
||||
|
||||
class ResponseFormat(BaseModel):
|
||||
"""Respond to the user in this format."""
|
||||
|
||||
status: Literal["input_required", "completed", "error"] = "input_required"
|
||||
message: str
|
||||
|
||||
|
||||
class 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,72 @@
|
||||
import logging
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
||||
from a2a.server.events import EventQueue
|
||||
from a2a.types import (
|
||||
InvalidParamsError,
|
||||
Part,
|
||||
Task,
|
||||
TextPart,
|
||||
UnsupportedOperationError,
|
||||
)
|
||||
from a2a.utils import completed_task, new_artifact
|
||||
from a2a.utils.errors import ServerError
|
||||
|
||||
from .agent import A2AManus
|
||||
|
||||
|
||||
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,131 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.tasks import InMemoryPushNotifier, InMemoryTaskStore
|
||||
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app.tool.browser_use_tool import _BROWSER_DESCRIPTION
|
||||
from app.tool.str_replace_editor import _STR_REPLACE_EDITOR_DESCRIPTION
|
||||
from app.tool.terminate import _TERMINATE_DESCRIPTION
|
||||
|
||||
from .agent import A2AManus
|
||||
from .agent_executor import ManusExecutor
|
||||
|
||||
|
||||
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)
|
||||
@@ -36,6 +36,7 @@ boto3~=1.37.18
|
||||
|
||||
requests~=2.32.3
|
||||
beautifulsoup4~=4.13.3
|
||||
crawl4ai~=0.6.3
|
||||
|
||||
huggingface-hub~=0.29.2
|
||||
setuptools~=75.8.0
|
||||
|
||||
+4
-1
@@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from app.agent.data_analysis import DataAnalysis
|
||||
from app.agent.manus import Manus
|
||||
from app.config import config
|
||||
from app.flow.flow_factory import FlowFactory, FlowType
|
||||
from app.logger import logger
|
||||
|
||||
@@ -10,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: ")
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from app.agent.sandbox_agent import SandboxManus
|
||||
from app.logger import logger
|
||||
|
||||
|
||||
async def main():
|
||||
# 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 SandboxManus.create()
|
||||
try:
|
||||
# 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
|
||||
|
||||
logger.warning("Processing your request...")
|
||||
await agent.run(prompt)
|
||||
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__":
|
||||
asyncio.run(main())
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user