Compare commits
24 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
167b1acd5c | ||
|
8f3a60f52b | ||
|
0952caf526 | ||
|
ea98fe569e | ||
|
f6b2250e95 | ||
|
0072174023 | ||
|
fc5e25343c | ||
|
395d5a3add | ||
|
f380372a07 | ||
|
bc3149e983 | ||
|
f22b225156 | ||
|
ecc84306e8 | ||
|
7baab6ad95 | ||
|
792dc664a7 | ||
|
4d02defd3b | ||
|
a8fc3e9709 | ||
|
de5ca60cf7 | ||
|
e25cfa2cb3 | ||
|
3f6e515970 | ||
|
5ae32c91e5 | ||
|
6e45490412 | ||
|
463bc0fe75 | ||
|
099074eec1 | ||
|
e08a6b313a |
14
.github/ISSUE_TEMPLATE/request_new_features.md
vendored
Normal file
14
.github/ISSUE_TEMPLATE/request_new_features.md
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
name: "🤔 Request new features"
|
||||
about: Suggest ideas or features you’d like to see implemented in OpenManus.
|
||||
title: ''
|
||||
labels: kind/features
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Feature description**
|
||||
<!-- Provide a clear and concise description of the proposed feature -->
|
||||
|
||||
**Your Feature**
|
||||
<!-- Explain your idea or implementation process. Optionally, include a Pull Request URL. -->
|
||||
<!-- Ensure accompanying docs/tests/examples are provided for review. -->
|
21
.github/ISSUE_TEMPLATE/request_new_features.yaml
vendored
21
.github/ISSUE_TEMPLATE/request_new_features.yaml
vendored
@ -1,21 +0,0 @@
|
||||
name: "🤔 Request new features"
|
||||
description: Suggest ideas or features you’d like to see implemented in OpenManus.
|
||||
labels: enhancement
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Feature description
|
||||
description: |
|
||||
Provide a clear and concise description of the proposed feature
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: your-feature
|
||||
attributes:
|
||||
label: Your Feature
|
||||
description: |
|
||||
Explain your idea or implementation process, if any. Optionally, include a Pull Request URL.
|
||||
Ensure accompanying docs/tests/examples are provided for review.
|
||||
validations:
|
||||
required: false
|
25
.github/ISSUE_TEMPLATE/show_me_the_bug.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/show_me_the_bug.md
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "🪲 Show me the Bug"
|
||||
about: Report a bug encountered while using OpenManus and seek assistance.
|
||||
title: ''
|
||||
labels: kind/bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Bug description**
|
||||
<!-- Clearly describe the bug you encountered -->
|
||||
|
||||
**Bug solved method**
|
||||
<!-- If resolved, explain the solution. Optionally, include a Pull Request URL. -->
|
||||
<!-- If unresolved, provide additional details to aid investigation -->
|
||||
|
||||
**Environment information**
|
||||
<!-- System: e.g., Ubuntu 22.04, Python: e.g., 3.12, OpenManus version: e.g., 0.1.0 -->
|
||||
|
||||
- System version:
|
||||
- Python version:
|
||||
- OpenManus version or branch:
|
||||
- Installation method (e.g., `pip install -r requirements.txt` or `pip install -e .`):
|
||||
|
||||
**Screenshots or logs**
|
||||
<!-- Attach screenshots or logs to help diagnose the issue -->
|
44
.github/ISSUE_TEMPLATE/show_me_the_bug.yaml
vendored
44
.github/ISSUE_TEMPLATE/show_me_the_bug.yaml
vendored
@ -1,44 +0,0 @@
|
||||
name: "🪲 Show me the Bug"
|
||||
description: Report a bug encountered while using OpenManus and seek assistance.
|
||||
labels: bug
|
||||
body:
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: |
|
||||
Clearly describe the bug you encountered
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solve-method
|
||||
attributes:
|
||||
label: Bug solved method
|
||||
description: |
|
||||
If resolved, explain the solution. Optionally, include a Pull Request URL.
|
||||
If unresolved, provide additional details to aid investigation
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment-information
|
||||
attributes:
|
||||
label: Environment information
|
||||
description: |
|
||||
System: e.g., Ubuntu 22.04
|
||||
Python: e.g., 3.12
|
||||
OpenManus version: e.g., 0.1.0
|
||||
value: |
|
||||
- System version:
|
||||
- Python version:
|
||||
- OpenManus version or branch:
|
||||
- Installation method (e.g., `pip install -r requirements.txt` or `pip install -e .`):
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: extra-information
|
||||
attributes:
|
||||
label: Extra information
|
||||
description: |
|
||||
For example, attach screenshots or logs to help diagnose the issue
|
||||
validations:
|
||||
required: false
|
33
.github/workflows/pr-autodiff.yaml
vendored
33
.github/workflows/pr-autodiff.yaml
vendored
@ -15,20 +15,21 @@ jobs:
|
||||
(github.event_name == 'pull_request') ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '!pr-diff') &&
|
||||
(github.event.comment.author_association == 'CONTRIBUTOR' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') &&
|
||||
(github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') &&
|
||||
github.event.issue.pull_request)
|
||||
steps:
|
||||
- name: Get PR head SHA
|
||||
id: get-pr-sha
|
||||
run: |
|
||||
PR_URL="${{ github.event.issue.pull_request.url || github.event.pull_request.url }}"
|
||||
# https://api.github.com/repos/OpenManus/pulls/1
|
||||
RESPONSE=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" $PR_URL)
|
||||
SHA=$(echo $RESPONSE | jq -r '.head.sha')
|
||||
TARGET_BRANCH=$(echo $RESPONSE | jq -r '.base.ref')
|
||||
echo "pr_sha=$SHA" >> $GITHUB_OUTPUT
|
||||
echo "target_branch=$TARGET_BRANCH" >> $GITHUB_OUTPUT
|
||||
echo "Retrieved PR head SHA from API: $SHA, target branch: $TARGET_BRANCH"
|
||||
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
||||
echo "pr_sha=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
|
||||
echo "Retrieved PR head SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
PR_URL="${{ github.event.issue.pull_request.url }}"
|
||||
SHA=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" $PR_URL | jq -r '.head.sha')
|
||||
echo "pr_sha=$SHA" >> $GITHUB_OUTPUT
|
||||
echo "Retrieved PR head SHA from API: $SHA"
|
||||
fi
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@ -48,7 +49,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
TARGET_BRANCH: ${{ steps.get-pr-sha.outputs.target_branch }}
|
||||
run: |-
|
||||
cat << 'EOF' > /tmp/_workflow_core.py
|
||||
import os
|
||||
@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
def get_diff():
|
||||
result = subprocess.run(
|
||||
['git', 'diff', 'origin/' + os.getenv('TARGET_BRANCH') + '...HEAD'],
|
||||
['git', 'diff', 'origin/main...HEAD'],
|
||||
capture_output=True, text=True, check=True)
|
||||
return '\n'.join(
|
||||
line for line in result.stdout.split('\n')
|
||||
@ -86,17 +86,6 @@ jobs:
|
||||
|
||||
### Spelling/Offensive Content Check
|
||||
- No spelling mistakes or offensive content found in the code or comments.
|
||||
|
||||
## 中文(简体)
|
||||
- 新增了 `ABC` 类
|
||||
- `foo` 模块中的 `f()` 行为已修复
|
||||
|
||||
### 评论高亮
|
||||
- `config.toml` 需要正确配置才能确保新功能正常运行。
|
||||
|
||||
### 内容检查
|
||||
- 没有发现代码或注释中的拼写错误或不当措辞。
|
||||
|
||||
3. Highlight non-English comments
|
||||
4. Check for spelling/offensive content'''
|
||||
|
||||
|
12
README.md
12
README.md
@ -81,11 +81,6 @@ source .venv/bin/activate # On Unix/macOS
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Browser Automation Tool (Optional)
|
||||
```bash
|
||||
playwright install
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
OpenManus requires configuration for the LLM APIs it uses. Follow these steps to set up your configuration:
|
||||
@ -124,12 +119,7 @@ python main.py
|
||||
|
||||
Then input your idea via terminal!
|
||||
|
||||
For MCP tool version, you can run:
|
||||
```bash
|
||||
python run_mcp.py
|
||||
```
|
||||
|
||||
For unstable multi-agent version, you also can run:
|
||||
For unstable version, you also can run:
|
||||
|
||||
```bash
|
||||
python run_flow.py
|
||||
|
12
README_ja.md
12
README_ja.md
@ -81,11 +81,6 @@ source .venv/bin/activate # Unix/macOSの場合
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### ブラウザ自動化ツール(オプション)
|
||||
```bash
|
||||
playwright install
|
||||
```
|
||||
|
||||
## 設定
|
||||
|
||||
OpenManusを使用するには、LLM APIの設定が必要です。以下の手順に従って設定してください:
|
||||
@ -124,12 +119,7 @@ python main.py
|
||||
|
||||
その後、ターミナルからプロンプトを入力してください!
|
||||
|
||||
MCP ツールバージョンを使用する場合は、以下を実行します:
|
||||
```bash
|
||||
python run_mcp.py
|
||||
```
|
||||
|
||||
開発中のマルチエージェントバージョンを試すには、以下を実行します:
|
||||
開発中バージョンを試すには、以下を実行します:
|
||||
|
||||
```bash
|
||||
python run_flow.py
|
||||
|
12
README_ko.md
12
README_ko.md
@ -81,11 +81,6 @@ source .venv/bin/activate # Unix/macOS의 경우
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 브라우저 자동화 도구 (선택사항)
|
||||
```bash
|
||||
playwright install
|
||||
```
|
||||
|
||||
## 설정 방법
|
||||
|
||||
OpenManus를 사용하려면 사용하는 LLM API에 대한 설정이 필요합니다. 아래 단계를 따라 설정을 완료하세요:
|
||||
@ -124,12 +119,7 @@ python main.py
|
||||
|
||||
이후 터미널에서 아이디어를 작성하세요!
|
||||
|
||||
MCP 도구 버전을 사용하려면 다음을 실행하세요:
|
||||
```bash
|
||||
python run_mcp.py
|
||||
```
|
||||
|
||||
불안정한 멀티 에이전트 버전을 실행하려면 다음을 실행할 수 있습니다:
|
||||
unstable 버전을 실행하려면 아래 명령어를 사용할 수도 있습니다:
|
||||
|
||||
```bash
|
||||
python run_flow.py
|
||||
|
12
README_zh.md
12
README_zh.md
@ -82,11 +82,6 @@ source .venv/bin/activate # Unix/macOS 系统
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 浏览器自动化工具(可选)
|
||||
```bash
|
||||
playwright install
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
OpenManus 需要配置使用的 LLM API,请按以下步骤设置:
|
||||
@ -125,12 +120,7 @@ python main.py
|
||||
|
||||
然后通过终端输入你的创意!
|
||||
|
||||
如需使用 MCP 工具版本,可运行:
|
||||
```bash
|
||||
python run_mcp.py
|
||||
```
|
||||
|
||||
如需体验不稳定的多智能体版本,可运行:
|
||||
如需体验不稳定的开发版本,可运行:
|
||||
|
||||
```bash
|
||||
python run_flow.py
|
||||
|
254
app.py
Normal file
254
app.py
Normal file
@ -0,0 +1,254 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from json import dumps
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
id: str
|
||||
prompt: str
|
||||
created_at: datetime
|
||||
status: str
|
||||
steps: list = []
|
||||
|
||||
def model_dump(self, *args, **kwargs):
|
||||
data = super().model_dump(*args, **kwargs)
|
||||
data["created_at"] = self.created_at.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self):
|
||||
self.tasks = {}
|
||||
self.queues = {}
|
||||
|
||||
def create_task(self, prompt: str) -> Task:
|
||||
task_id = str(uuid.uuid4())
|
||||
task = Task(
|
||||
id=task_id, prompt=prompt, created_at=datetime.now(), status="pending"
|
||||
)
|
||||
self.tasks[task_id] = task
|
||||
self.queues[task_id] = asyncio.Queue()
|
||||
return task
|
||||
|
||||
async def update_task_step(
|
||||
self, task_id: str, step: int, result: str, step_type: str = "step"
|
||||
):
|
||||
if task_id in self.tasks:
|
||||
task = self.tasks[task_id]
|
||||
task.steps.append({"step": step, "result": result, "type": step_type})
|
||||
await self.queues[task_id].put(
|
||||
{"type": step_type, "step": step, "result": result}
|
||||
)
|
||||
await self.queues[task_id].put(
|
||||
{"type": "status", "status": task.status, "steps": task.steps}
|
||||
)
|
||||
|
||||
async def complete_task(self, task_id: str):
|
||||
if task_id in self.tasks:
|
||||
task = self.tasks[task_id]
|
||||
task.status = "completed"
|
||||
await self.queues[task_id].put(
|
||||
{"type": "status", "status": task.status, "steps": task.steps}
|
||||
)
|
||||
await self.queues[task_id].put({"type": "complete"})
|
||||
|
||||
async def fail_task(self, task_id: str, error: str):
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = f"failed: {error}"
|
||||
await self.queues[task_id].put({"type": "error", "message": error})
|
||||
|
||||
|
||||
task_manager = TaskManager()
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
|
||||
@app.post("/tasks")
|
||||
async def create_task(prompt: str = Body(..., embed=True)):
|
||||
task = task_manager.create_task(prompt)
|
||||
asyncio.create_task(run_task(task.id, prompt))
|
||||
return {"task_id": task.id}
|
||||
|
||||
|
||||
from app.agent.manus import Manus
|
||||
|
||||
|
||||
async def run_task(task_id: str, prompt: str):
|
||||
try:
|
||||
task_manager.tasks[task_id].status = "running"
|
||||
|
||||
agent = Manus(
|
||||
name="Manus",
|
||||
description="A versatile agent that can solve various tasks using multiple tools",
|
||||
max_steps=30,
|
||||
)
|
||||
|
||||
async def on_think(thought):
|
||||
await task_manager.update_task_step(task_id, 0, thought, "think")
|
||||
|
||||
async def on_tool_execute(tool, input):
|
||||
await task_manager.update_task_step(
|
||||
task_id, 0, f"Executing tool: {tool}\nInput: {input}", "tool"
|
||||
)
|
||||
|
||||
async def on_action(action):
|
||||
await task_manager.update_task_step(
|
||||
task_id, 0, f"Executing action: {action}", "act"
|
||||
)
|
||||
|
||||
async def on_run(step, result):
|
||||
await task_manager.update_task_step(task_id, step, result, "run")
|
||||
|
||||
from app.logger import logger
|
||||
|
||||
class SSELogHandler:
|
||||
def __init__(self, task_id):
|
||||
self.task_id = task_id
|
||||
|
||||
async def __call__(self, message):
|
||||
import re
|
||||
|
||||
# 提取 - 后面的内容
|
||||
cleaned_message = re.sub(r"^.*? - ", "", message)
|
||||
|
||||
event_type = "log"
|
||||
if "✨ Manus's thoughts:" in cleaned_message:
|
||||
event_type = "think"
|
||||
elif "🛠️ Manus selected" in cleaned_message:
|
||||
event_type = "tool"
|
||||
elif "🎯 Tool" in cleaned_message:
|
||||
event_type = "act"
|
||||
elif "📝 Oops!" in cleaned_message:
|
||||
event_type = "error"
|
||||
elif "🏁 Special tool" in cleaned_message:
|
||||
event_type = "complete"
|
||||
|
||||
await task_manager.update_task_step(
|
||||
self.task_id, 0, cleaned_message, event_type
|
||||
)
|
||||
|
||||
sse_handler = SSELogHandler(task_id)
|
||||
logger.add(sse_handler)
|
||||
|
||||
result = await agent.run(prompt)
|
||||
await task_manager.update_task_step(task_id, 1, result, "result")
|
||||
await task_manager.complete_task(task_id)
|
||||
except Exception as e:
|
||||
await task_manager.fail_task(task_id, str(e))
|
||||
|
||||
|
||||
@app.get("/tasks/{task_id}/events")
|
||||
async def task_events(task_id: str):
|
||||
async def event_generator():
|
||||
if task_id not in task_manager.queues:
|
||||
yield f"event: error\ndata: {dumps({'message': 'Task not found'})}\n\n"
|
||||
return
|
||||
|
||||
queue = task_manager.queues[task_id]
|
||||
|
||||
task = task_manager.tasks.get(task_id)
|
||||
if task:
|
||||
status_data = {"type": "status", "status": task.status, "steps": task.steps}
|
||||
yield f"event: status\ndata: {dumps(status_data)}\n\n"
|
||||
|
||||
while True:
|
||||
try:
|
||||
event = await queue.get()
|
||||
formatted_event = dumps(event)
|
||||
|
||||
yield ": heartbeat\n\n"
|
||||
|
||||
if event["type"] == "complete":
|
||||
yield f"event: complete\ndata: {formatted_event}\n\n"
|
||||
break
|
||||
elif event["type"] == "error":
|
||||
yield f"event: error\ndata: {formatted_event}\n\n"
|
||||
break
|
||||
elif event["type"] == "step":
|
||||
task = task_manager.tasks.get(task_id)
|
||||
if task:
|
||||
status_data = {
|
||||
"type": "status",
|
||||
"status": task.status,
|
||||
"steps": task.steps,
|
||||
}
|
||||
yield f"event: status\ndata: {dumps(status_data)}\n\n"
|
||||
yield f"event: {event['type']}\ndata: {formatted_event}\n\n"
|
||||
elif event["type"] in ["think", "tool", "act", "run"]:
|
||||
yield f"event: {event['type']}\ndata: {formatted_event}\n\n"
|
||||
else:
|
||||
yield f"event: {event['type']}\ndata: {formatted_event}\n\n"
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print(f"Client disconnected for task {task_id}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error in event stream: {str(e)}")
|
||||
yield f"event: error\ndata: {dumps({'message': str(e)})}\n\n"
|
||||
break
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/tasks")
|
||||
async def get_tasks():
|
||||
sorted_tasks = sorted(
|
||||
task_manager.tasks.values(), key=lambda task: task.created_at, reverse=True
|
||||
)
|
||||
return JSONResponse(
|
||||
content=[task.model_dump() for task in sorted_tasks],
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/tasks/{task_id}")
|
||||
async def get_task(task_id: str):
|
||||
if task_id not in task_manager.tasks:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task_manager.tasks[task_id]
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def generic_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=500, content={"message": f"Server error: {str(exc)}"}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="localhost", port=5172)
|
@ -1,6 +1,5 @@
|
||||
from app.agent.base import BaseAgent
|
||||
from app.agent.browser import BrowserAgent
|
||||
from app.agent.mcp import MCPAgent
|
||||
from app.agent.planning import PlanningAgent
|
||||
from app.agent.react import ReActAgent
|
||||
from app.agent.swe import SWEAgent
|
||||
@ -14,5 +13,4 @@ __all__ = [
|
||||
"ReActAgent",
|
||||
"SWEAgent",
|
||||
"ToolCallAgent",
|
||||
"MCPAgent",
|
||||
]
|
||||
|
185
app/agent/mcp.py
185
app/agent/mcp.py
@ -1,185 +0,0 @@
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.agent.toolcall import ToolCallAgent
|
||||
from app.logger import logger
|
||||
from app.prompt.mcp import MULTIMEDIA_RESPONSE_PROMPT, NEXT_STEP_PROMPT, SYSTEM_PROMPT
|
||||
from app.schema import AgentState, Message
|
||||
from app.tool.base import ToolResult
|
||||
from app.tool.mcp import MCPClients
|
||||
|
||||
|
||||
class MCPAgent(ToolCallAgent):
|
||||
"""Agent for interacting with MCP (Model Context Protocol) servers.
|
||||
|
||||
This agent connects to an MCP server using either SSE or stdio transport
|
||||
and makes the server's tools available through the agent's tool interface.
|
||||
"""
|
||||
|
||||
name: str = "mcp_agent"
|
||||
description: str = "An agent that connects to an MCP server and uses its tools."
|
||||
|
||||
system_prompt: str = SYSTEM_PROMPT
|
||||
next_step_prompt: str = NEXT_STEP_PROMPT
|
||||
|
||||
# Initialize MCP tool collection
|
||||
mcp_clients: MCPClients = Field(default_factory=MCPClients)
|
||||
available_tools: MCPClients = None # Will be set in initialize()
|
||||
|
||||
max_steps: int = 20
|
||||
connection_type: str = "stdio" # "stdio" or "sse"
|
||||
|
||||
# Track tool schemas to detect changes
|
||||
tool_schemas: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
|
||||
_refresh_tools_interval: int = 5 # Refresh tools every N steps
|
||||
|
||||
# Special tool names that should trigger termination
|
||||
special_tool_names: List[str] = Field(default_factory=lambda: ["terminate"])
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
connection_type: Optional[str] = None,
|
||||
server_url: Optional[str] = None,
|
||||
command: Optional[str] = None,
|
||||
args: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Initialize the MCP connection.
|
||||
|
||||
Args:
|
||||
connection_type: Type of connection to use ("stdio" or "sse")
|
||||
server_url: URL of the MCP server (for SSE connection)
|
||||
command: Command to run (for stdio connection)
|
||||
args: Arguments for the command (for stdio connection)
|
||||
"""
|
||||
if connection_type:
|
||||
self.connection_type = connection_type
|
||||
|
||||
# Connect to the MCP server based on connection type
|
||||
if self.connection_type == "sse":
|
||||
if not server_url:
|
||||
raise ValueError("Server URL is required for SSE connection")
|
||||
await self.mcp_clients.connect_sse(server_url=server_url)
|
||||
elif self.connection_type == "stdio":
|
||||
if not command:
|
||||
raise ValueError("Command is required for stdio connection")
|
||||
await self.mcp_clients.connect_stdio(command=command, args=args or [])
|
||||
else:
|
||||
raise ValueError(f"Unsupported connection type: {self.connection_type}")
|
||||
|
||||
# Set available_tools to our MCP instance
|
||||
self.available_tools = self.mcp_clients
|
||||
|
||||
# Store initial tool schemas
|
||||
await self._refresh_tools()
|
||||
|
||||
# Add system message about available tools
|
||||
tool_names = list(self.mcp_clients.tool_map.keys())
|
||||
tools_info = ", ".join(tool_names)
|
||||
|
||||
# Add system prompt and available tools information
|
||||
self.memory.add_message(
|
||||
Message.system_message(
|
||||
f"{self.system_prompt}\n\nAvailable MCP tools: {tools_info}"
|
||||
)
|
||||
)
|
||||
|
||||
async def _refresh_tools(self) -> Tuple[List[str], List[str]]:
|
||||
"""Refresh the list of available tools from the MCP server.
|
||||
|
||||
Returns:
|
||||
A tuple of (added_tools, removed_tools)
|
||||
"""
|
||||
if not self.mcp_clients.session:
|
||||
return [], []
|
||||
|
||||
# Get current tool schemas directly from the server
|
||||
response = await self.mcp_clients.session.list_tools()
|
||||
current_tools = {tool.name: tool.inputSchema for tool in response.tools}
|
||||
|
||||
# Determine added, removed, and changed tools
|
||||
current_names = set(current_tools.keys())
|
||||
previous_names = set(self.tool_schemas.keys())
|
||||
|
||||
added_tools = list(current_names - previous_names)
|
||||
removed_tools = list(previous_names - current_names)
|
||||
|
||||
# Check for schema changes in existing tools
|
||||
changed_tools = []
|
||||
for name in current_names.intersection(previous_names):
|
||||
if current_tools[name] != self.tool_schemas.get(name):
|
||||
changed_tools.append(name)
|
||||
|
||||
# Update stored schemas
|
||||
self.tool_schemas = current_tools
|
||||
|
||||
# Log and notify about changes
|
||||
if added_tools:
|
||||
logger.info(f"Added MCP tools: {added_tools}")
|
||||
self.memory.add_message(
|
||||
Message.system_message(f"New tools available: {', '.join(added_tools)}")
|
||||
)
|
||||
if removed_tools:
|
||||
logger.info(f"Removed MCP tools: {removed_tools}")
|
||||
self.memory.add_message(
|
||||
Message.system_message(
|
||||
f"Tools no longer available: {', '.join(removed_tools)}"
|
||||
)
|
||||
)
|
||||
if changed_tools:
|
||||
logger.info(f"Changed MCP tools: {changed_tools}")
|
||||
|
||||
return added_tools, removed_tools
|
||||
|
||||
async def think(self) -> bool:
|
||||
"""Process current state and decide next action."""
|
||||
# Check MCP session and tools availability
|
||||
if not self.mcp_clients.session or not self.mcp_clients.tool_map:
|
||||
logger.info("MCP service is no longer available, ending interaction")
|
||||
self.state = AgentState.FINISHED
|
||||
return False
|
||||
|
||||
# Refresh tools periodically
|
||||
if self.current_step % self._refresh_tools_interval == 0:
|
||||
await self._refresh_tools()
|
||||
# All tools removed indicates shutdown
|
||||
if not self.mcp_clients.tool_map:
|
||||
logger.info("MCP service has shut down, ending interaction")
|
||||
self.state = AgentState.FINISHED
|
||||
return False
|
||||
|
||||
# Use the parent class's think method
|
||||
return await super().think()
|
||||
|
||||
async def _handle_special_tool(self, name: str, result: Any, **kwargs) -> None:
|
||||
"""Handle special tool execution and state changes"""
|
||||
# First process with parent handler
|
||||
await super()._handle_special_tool(name, result, **kwargs)
|
||||
|
||||
# Handle multimedia responses
|
||||
if isinstance(result, ToolResult) and result.base64_image:
|
||||
self.memory.add_message(
|
||||
Message.system_message(
|
||||
MULTIMEDIA_RESPONSE_PROMPT.format(tool_name=name)
|
||||
)
|
||||
)
|
||||
|
||||
def _should_finish_execution(self, name: str, **kwargs) -> bool:
|
||||
"""Determine if tool execution should finish the agent"""
|
||||
# Terminate if the tool name is 'terminate'
|
||||
return name.lower() == "terminate"
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up MCP connection when done."""
|
||||
if self.mcp_clients.session:
|
||||
await self.mcp_clients.disconnect()
|
||||
logger.info("MCP connection closed")
|
||||
|
||||
async def run(self, request: Optional[str] = None) -> str:
|
||||
"""Run the agent with cleanup when done."""
|
||||
try:
|
||||
result = await super().run(request)
|
||||
return result
|
||||
finally:
|
||||
# Ensure cleanup happens even if there's an error
|
||||
await self.cleanup()
|
@ -29,8 +29,7 @@ class SWEAgent(ToolCallAgent):
|
||||
async def think(self) -> bool:
|
||||
"""Process current state and decide next action"""
|
||||
# Update working directory
|
||||
result = await self.bash.execute("pwd")
|
||||
self.working_dir = result.output
|
||||
self.working_dir = await self.bash.execute("pwd")
|
||||
self.next_step_prompt = self.next_step_prompt.format(
|
||||
current_dir=self.working_dir
|
||||
)
|
||||
|
@ -71,42 +71,40 @@ class ToolCallAgent(ReActAgent):
|
||||
return False
|
||||
raise
|
||||
|
||||
self.tool_calls = tool_calls = (
|
||||
response.tool_calls if response and response.tool_calls else []
|
||||
)
|
||||
content = response.content if response and response.content else ""
|
||||
self.tool_calls = response.tool_calls
|
||||
|
||||
# Log response info
|
||||
logger.info(f"✨ {self.name}'s thoughts: {content}")
|
||||
logger.info(f"✨ {self.name}'s thoughts: {response.content}")
|
||||
logger.info(
|
||||
f"🛠️ {self.name} selected {len(tool_calls) if tool_calls else 0} tools to use"
|
||||
f"🛠️ {self.name} selected {len(response.tool_calls) if response.tool_calls else 0} tools to use"
|
||||
)
|
||||
if tool_calls:
|
||||
if response.tool_calls:
|
||||
logger.info(
|
||||
f"🧰 Tools being prepared: {[call.function.name for call in tool_calls]}"
|
||||
f"🧰 Tools being prepared: {[call.function.name for call in response.tool_calls]}"
|
||||
)
|
||||
logger.info(
|
||||
f"🔧 Tool arguments: {response.tool_calls[0].function.arguments}"
|
||||
)
|
||||
logger.info(f"🔧 Tool arguments: {tool_calls[0].function.arguments}")
|
||||
|
||||
try:
|
||||
if response is None:
|
||||
raise RuntimeError("No response received from the LLM")
|
||||
|
||||
# Handle different tool_choices modes
|
||||
if self.tool_choices == ToolChoice.NONE:
|
||||
if tool_calls:
|
||||
if response.tool_calls:
|
||||
logger.warning(
|
||||
f"🤔 Hmm, {self.name} tried to use tools when they weren't available!"
|
||||
)
|
||||
if content:
|
||||
self.memory.add_message(Message.assistant_message(content))
|
||||
if response.content:
|
||||
self.memory.add_message(Message.assistant_message(response.content))
|
||||
return True
|
||||
return False
|
||||
|
||||
# Create and add assistant message
|
||||
assistant_msg = (
|
||||
Message.from_tool_calls(content=content, tool_calls=self.tool_calls)
|
||||
Message.from_tool_calls(
|
||||
content=response.content, tool_calls=self.tool_calls
|
||||
)
|
||||
if self.tool_calls
|
||||
else Message.assistant_message(content)
|
||||
else Message.assistant_message(response.content)
|
||||
)
|
||||
self.memory.add_message(assistant_msg)
|
||||
|
||||
@ -115,7 +113,7 @@ class ToolCallAgent(ReActAgent):
|
||||
|
||||
# For 'auto' mode, continue with content if no commands but content exists
|
||||
if self.tool_choices == ToolChoice.AUTO and not self.tool_calls:
|
||||
return bool(content)
|
||||
return bool(response.content)
|
||||
|
||||
return bool(self.tool_calls)
|
||||
except Exception as e:
|
||||
@ -211,7 +209,7 @@ class ToolCallAgent(ReActAgent):
|
||||
return f"Error: {error_msg}"
|
||||
except Exception as e:
|
||||
error_msg = f"⚠️ Tool '{name}' encountered a problem: {str(e)}"
|
||||
logger.exception(error_msg)
|
||||
logger.error(error_msg)
|
||||
return f"Error: {error_msg}"
|
||||
|
||||
async def _handle_special_tool(self, name: str, result: Any, **kwargs):
|
||||
|
334
app/bedrock.py
334
app/bedrock.py
@ -1,334 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
|
||||
import boto3
|
||||
|
||||
|
||||
# Global variables to track the current tool use ID across function calls
|
||||
# Tmp solution
|
||||
CURRENT_TOOLUSE_ID = None
|
||||
|
||||
|
||||
# Class to handle OpenAI-style response formatting
|
||||
class OpenAIResponse:
|
||||
def __init__(self, data):
|
||||
# Recursively convert nested dicts and lists to OpenAIResponse objects
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
value = OpenAIResponse(value)
|
||||
elif isinstance(value, list):
|
||||
value = [
|
||||
OpenAIResponse(item) if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
setattr(self, key, value)
|
||||
|
||||
def model_dump(self, *args, **kwargs):
|
||||
# Convert object to dict and add timestamp
|
||||
data = self.__dict__
|
||||
data["created_at"] = datetime.now().isoformat()
|
||||
return data
|
||||
|
||||
|
||||
# Main client class for interacting with Amazon Bedrock
|
||||
class BedrockClient:
|
||||
def __init__(self):
|
||||
# Initialize Bedrock client, you need to configure AWS env first
|
||||
try:
|
||||
self.client = boto3.client("bedrock-runtime")
|
||||
self.chat = Chat(self.client)
|
||||
except Exception as e:
|
||||
print(f"Error initializing Bedrock client: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Chat interface class
|
||||
class Chat:
|
||||
def __init__(self, client):
|
||||
self.completions = ChatCompletions(client)
|
||||
|
||||
|
||||
# Core class handling chat completions functionality
|
||||
class ChatCompletions:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
|
||||
def _convert_openai_tools_to_bedrock_format(self, tools):
|
||||
# Convert OpenAI function calling format to Bedrock tool format
|
||||
bedrock_tools = []
|
||||
for tool in tools:
|
||||
if tool.get("type") == "function":
|
||||
function = tool.get("function", {})
|
||||
bedrock_tool = {
|
||||
"toolSpec": {
|
||||
"name": function.get("name", ""),
|
||||
"description": function.get("description", ""),
|
||||
"inputSchema": {
|
||||
"json": {
|
||||
"type": "object",
|
||||
"properties": function.get("parameters", {}).get(
|
||||
"properties", {}
|
||||
),
|
||||
"required": function.get("parameters", {}).get(
|
||||
"required", []
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
bedrock_tools.append(bedrock_tool)
|
||||
return bedrock_tools
|
||||
|
||||
def _convert_openai_messages_to_bedrock_format(self, messages):
|
||||
# Convert OpenAI message format to Bedrock message format
|
||||
bedrock_messages = []
|
||||
system_prompt = []
|
||||
for message in messages:
|
||||
if message.get("role") == "system":
|
||||
system_prompt = [{"text": message.get("content")}]
|
||||
elif message.get("role") == "user":
|
||||
bedrock_message = {
|
||||
"role": message.get("role", "user"),
|
||||
"content": [{"text": message.get("content")}],
|
||||
}
|
||||
bedrock_messages.append(bedrock_message)
|
||||
elif message.get("role") == "assistant":
|
||||
bedrock_message = {
|
||||
"role": "assistant",
|
||||
"content": [{"text": message.get("content")}],
|
||||
}
|
||||
openai_tool_calls = message.get("tool_calls", [])
|
||||
if openai_tool_calls:
|
||||
bedrock_tool_use = {
|
||||
"toolUseId": openai_tool_calls[0]["id"],
|
||||
"name": openai_tool_calls[0]["function"]["name"],
|
||||
"input": json.loads(
|
||||
openai_tool_calls[0]["function"]["arguments"]
|
||||
),
|
||||
}
|
||||
bedrock_message["content"].append({"toolUse": bedrock_tool_use})
|
||||
global CURRENT_TOOLUSE_ID
|
||||
CURRENT_TOOLUSE_ID = openai_tool_calls[0]["id"]
|
||||
bedrock_messages.append(bedrock_message)
|
||||
elif message.get("role") == "tool":
|
||||
bedrock_message = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"toolResult": {
|
||||
"toolUseId": CURRENT_TOOLUSE_ID,
|
||||
"content": [{"text": message.get("content")}],
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
bedrock_messages.append(bedrock_message)
|
||||
else:
|
||||
raise ValueError(f"Invalid role: {message.get('role')}")
|
||||
return system_prompt, bedrock_messages
|
||||
|
||||
def _convert_bedrock_response_to_openai_format(self, bedrock_response):
|
||||
# Convert Bedrock response format to OpenAI format
|
||||
content = ""
|
||||
if bedrock_response.get("output", {}).get("message", {}).get("content"):
|
||||
content_array = bedrock_response["output"]["message"]["content"]
|
||||
content = "".join(item.get("text", "") for item in content_array)
|
||||
if content == "":
|
||||
content = "."
|
||||
|
||||
# Handle tool calls in response
|
||||
openai_tool_calls = []
|
||||
if bedrock_response.get("output", {}).get("message", {}).get("content"):
|
||||
for content_item in bedrock_response["output"]["message"]["content"]:
|
||||
if content_item.get("toolUse"):
|
||||
bedrock_tool_use = content_item["toolUse"]
|
||||
global CURRENT_TOOLUSE_ID
|
||||
CURRENT_TOOLUSE_ID = bedrock_tool_use["toolUseId"]
|
||||
openai_tool_call = {
|
||||
"id": CURRENT_TOOLUSE_ID,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": bedrock_tool_use["name"],
|
||||
"arguments": json.dumps(bedrock_tool_use["input"]),
|
||||
},
|
||||
}
|
||||
openai_tool_calls.append(openai_tool_call)
|
||||
|
||||
# Construct final OpenAI format response
|
||||
openai_format = {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"created": int(time.time()),
|
||||
"object": "chat.completion",
|
||||
"system_fingerprint": None,
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": bedrock_response.get("stopReason", "end_turn"),
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": content,
|
||||
"role": bedrock_response.get("output", {})
|
||||
.get("message", {})
|
||||
.get("role", "assistant"),
|
||||
"tool_calls": openai_tool_calls
|
||||
if openai_tool_calls != []
|
||||
else None,
|
||||
"function_call": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"completion_tokens": bedrock_response.get("usage", {}).get(
|
||||
"outputTokens", 0
|
||||
),
|
||||
"prompt_tokens": bedrock_response.get("usage", {}).get(
|
||||
"inputTokens", 0
|
||||
),
|
||||
"total_tokens": bedrock_response.get("usage", {}).get("totalTokens", 0),
|
||||
},
|
||||
}
|
||||
return OpenAIResponse(openai_format)
|
||||
|
||||
async def _invoke_bedrock(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
tools: Optional[List[dict]] = None,
|
||||
tool_choice: Literal["none", "auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
) -> OpenAIResponse:
|
||||
# Non-streaming invocation of Bedrock model
|
||||
(
|
||||
system_prompt,
|
||||
bedrock_messages,
|
||||
) = self._convert_openai_messages_to_bedrock_format(messages)
|
||||
response = self.client.converse(
|
||||
modelId=model,
|
||||
system=system_prompt,
|
||||
messages=bedrock_messages,
|
||||
inferenceConfig={"temperature": temperature, "maxTokens": max_tokens},
|
||||
toolConfig={"tools": tools} if tools else None,
|
||||
)
|
||||
openai_response = self._convert_bedrock_response_to_openai_format(response)
|
||||
return openai_response
|
||||
|
||||
async def _invoke_bedrock_stream(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
tools: Optional[List[dict]] = None,
|
||||
tool_choice: Literal["none", "auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
) -> OpenAIResponse:
|
||||
# Streaming invocation of Bedrock model
|
||||
(
|
||||
system_prompt,
|
||||
bedrock_messages,
|
||||
) = self._convert_openai_messages_to_bedrock_format(messages)
|
||||
response = self.client.converse_stream(
|
||||
modelId=model,
|
||||
system=system_prompt,
|
||||
messages=bedrock_messages,
|
||||
inferenceConfig={"temperature": temperature, "maxTokens": max_tokens},
|
||||
toolConfig={"tools": tools} if tools else None,
|
||||
)
|
||||
|
||||
# Initialize response structure
|
||||
bedrock_response = {
|
||||
"output": {"message": {"role": "", "content": []}},
|
||||
"stopReason": "",
|
||||
"usage": {},
|
||||
"metrics": {},
|
||||
}
|
||||
bedrock_response_text = ""
|
||||
bedrock_response_tool_input = ""
|
||||
|
||||
# Process streaming response
|
||||
stream = response.get("stream")
|
||||
if stream:
|
||||
for event in stream:
|
||||
if event.get("messageStart", {}).get("role"):
|
||||
bedrock_response["output"]["message"]["role"] = event[
|
||||
"messageStart"
|
||||
]["role"]
|
||||
if event.get("contentBlockDelta", {}).get("delta", {}).get("text"):
|
||||
bedrock_response_text += event["contentBlockDelta"]["delta"]["text"]
|
||||
print(
|
||||
event["contentBlockDelta"]["delta"]["text"], end="", flush=True
|
||||
)
|
||||
if event.get("contentBlockStop", {}).get("contentBlockIndex") == 0:
|
||||
bedrock_response["output"]["message"]["content"].append(
|
||||
{"text": bedrock_response_text}
|
||||
)
|
||||
if event.get("contentBlockStart", {}).get("start", {}).get("toolUse"):
|
||||
bedrock_tool_use = event["contentBlockStart"]["start"]["toolUse"]
|
||||
tool_use = {
|
||||
"toolUseId": bedrock_tool_use["toolUseId"],
|
||||
"name": bedrock_tool_use["name"],
|
||||
}
|
||||
bedrock_response["output"]["message"]["content"].append(
|
||||
{"toolUse": tool_use}
|
||||
)
|
||||
global CURRENT_TOOLUSE_ID
|
||||
CURRENT_TOOLUSE_ID = bedrock_tool_use["toolUseId"]
|
||||
if event.get("contentBlockDelta", {}).get("delta", {}).get("toolUse"):
|
||||
bedrock_response_tool_input += event["contentBlockDelta"]["delta"][
|
||||
"toolUse"
|
||||
]["input"]
|
||||
print(
|
||||
event["contentBlockDelta"]["delta"]["toolUse"]["input"],
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
if event.get("contentBlockStop", {}).get("contentBlockIndex") == 1:
|
||||
bedrock_response["output"]["message"]["content"][1]["toolUse"][
|
||||
"input"
|
||||
] = json.loads(bedrock_response_tool_input)
|
||||
print()
|
||||
openai_response = self._convert_bedrock_response_to_openai_format(
|
||||
bedrock_response
|
||||
)
|
||||
return openai_response
|
||||
|
||||
def create(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
stream: Optional[bool] = True,
|
||||
tools: Optional[List[dict]] = None,
|
||||
tool_choice: Literal["none", "auto", "required"] = "auto",
|
||||
**kwargs,
|
||||
) -> OpenAIResponse:
|
||||
# Main entry point for chat completion
|
||||
bedrock_tools = []
|
||||
if tools is not None:
|
||||
bedrock_tools = self._convert_openai_tools_to_bedrock_format(tools)
|
||||
if stream:
|
||||
return self._invoke_bedrock_stream(
|
||||
model,
|
||||
messages,
|
||||
max_tokens,
|
||||
temperature,
|
||||
bedrock_tools,
|
||||
tool_choice,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
return self._invoke_bedrock(
|
||||
model,
|
||||
messages,
|
||||
max_tokens,
|
||||
temperature,
|
||||
bedrock_tools,
|
||||
tool_choice,
|
||||
**kwargs,
|
||||
)
|
@ -37,18 +37,6 @@ class ProxySettings(BaseModel):
|
||||
|
||||
class SearchSettings(BaseModel):
|
||||
engine: str = Field(default="Google", description="Search engine the llm to use")
|
||||
fallback_engines: List[str] = Field(
|
||||
default_factory=lambda: ["DuckDuckGo", "Baidu"],
|
||||
description="Fallback search engines to try if the primary engine fails",
|
||||
)
|
||||
retry_delay: int = Field(
|
||||
default=60,
|
||||
description="Seconds to wait before retrying all engines again after they all fail",
|
||||
)
|
||||
max_retries: int = Field(
|
||||
default=3,
|
||||
description="Maximum number of times to retry all engines when all fail",
|
||||
)
|
||||
|
||||
|
||||
class BrowserSettings(BaseModel):
|
||||
@ -239,10 +227,5 @@ class Config:
|
||||
"""Get the workspace root directory"""
|
||||
return WORKSPACE_ROOT
|
||||
|
||||
@property
|
||||
def root_path(self) -> Path:
|
||||
"""Get the root path of the application"""
|
||||
return PROJECT_ROOT
|
||||
|
||||
|
||||
config = Config()
|
||||
|
@ -1,4 +1,5 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
@ -6,6 +7,10 @@ from pydantic import BaseModel
|
||||
from app.agent.base import BaseAgent
|
||||
|
||||
|
||||
class FlowType(str, Enum):
|
||||
PLANNING = "planning"
|
||||
|
||||
|
||||
class BaseFlow(BaseModel, ABC):
|
||||
"""Base class for execution flows supporting multiple agents"""
|
||||
|
||||
@ -55,3 +60,32 @@ class BaseFlow(BaseModel, ABC):
|
||||
@abstractmethod
|
||||
async def execute(self, input_text: str) -> str:
|
||||
"""Execute the flow with given input"""
|
||||
|
||||
|
||||
class PlanStepStatus(str, Enum):
|
||||
"""Enum class defining possible statuses of a plan step"""
|
||||
|
||||
NOT_STARTED = "not_started"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
@classmethod
|
||||
def get_all_statuses(cls) -> list[str]:
|
||||
"""Return a list of all possible step status values"""
|
||||
return [status.value for status in cls]
|
||||
|
||||
@classmethod
|
||||
def get_active_statuses(cls) -> list[str]:
|
||||
"""Return a list of values representing active statuses (not started or in progress)"""
|
||||
return [cls.NOT_STARTED.value, cls.IN_PROGRESS.value]
|
||||
|
||||
@classmethod
|
||||
def get_status_marks(cls) -> Dict[str, str]:
|
||||
"""Return a mapping of statuses to their marker symbols"""
|
||||
return {
|
||||
cls.COMPLETED.value: "[✓]",
|
||||
cls.IN_PROGRESS.value: "[→]",
|
||||
cls.BLOCKED.value: "[!]",
|
||||
cls.NOT_STARTED.value: "[ ]",
|
||||
}
|
||||
|
@ -1,15 +1,10 @@
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Union
|
||||
|
||||
from app.agent.base import BaseAgent
|
||||
from app.flow.base import BaseFlow
|
||||
from app.flow.base import BaseFlow, FlowType
|
||||
from app.flow.planning import PlanningFlow
|
||||
|
||||
|
||||
class FlowType(str, Enum):
|
||||
PLANNING = "planning"
|
||||
|
||||
|
||||
class FlowFactory:
|
||||
"""Factory for creating different types of flows with support for multiple agents"""
|
||||
|
||||
|
@ -1,47 +1,17 @@
|
||||
import json
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.agent.base import BaseAgent
|
||||
from app.flow.base import BaseFlow
|
||||
from app.flow.base import BaseFlow, PlanStepStatus
|
||||
from app.llm import LLM
|
||||
from app.logger import logger
|
||||
from app.schema import AgentState, Message, ToolChoice
|
||||
from app.tool import PlanningTool
|
||||
|
||||
|
||||
class PlanStepStatus(str, Enum):
|
||||
"""Enum class defining possible statuses of a plan step"""
|
||||
|
||||
NOT_STARTED = "not_started"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
@classmethod
|
||||
def get_all_statuses(cls) -> list[str]:
|
||||
"""Return a list of all possible step status values"""
|
||||
return [status.value for status in cls]
|
||||
|
||||
@classmethod
|
||||
def get_active_statuses(cls) -> list[str]:
|
||||
"""Return a list of values representing active statuses (not started or in progress)"""
|
||||
return [cls.NOT_STARTED.value, cls.IN_PROGRESS.value]
|
||||
|
||||
@classmethod
|
||||
def get_status_marks(cls) -> Dict[str, str]:
|
||||
"""Return a mapping of statuses to their marker symbols"""
|
||||
return {
|
||||
cls.COMPLETED.value: "[✓]",
|
||||
cls.IN_PROGRESS.value: "[→]",
|
||||
cls.BLOCKED.value: "[!]",
|
||||
cls.NOT_STARTED.value: "[ ]",
|
||||
}
|
||||
|
||||
|
||||
class PlanningFlow(BaseFlow):
|
||||
"""A flow that manages planning and execution of tasks using agents."""
|
||||
|
||||
|
32
app/llm.py
32
app/llm.py
@ -10,7 +10,6 @@ from openai import (
|
||||
OpenAIError,
|
||||
RateLimitError,
|
||||
)
|
||||
from openai.types.chat.chat_completion_message import ChatCompletionMessage
|
||||
from tenacity import (
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
@ -18,7 +17,6 @@ from tenacity import (
|
||||
wait_random_exponential,
|
||||
)
|
||||
|
||||
from app.bedrock import BedrockClient
|
||||
from app.config import LLMSettings, config
|
||||
from app.exceptions import TokenLimitExceeded
|
||||
from app.logger import logger # Assuming a logger is set up in your app
|
||||
@ -226,8 +224,6 @@ class LLM:
|
||||
api_key=self.api_key,
|
||||
api_version=self.api_version,
|
||||
)
|
||||
elif self.api_type == "aws":
|
||||
self.client = BedrockClient()
|
||||
else:
|
||||
self.client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
|
||||
@ -425,9 +421,9 @@ class LLM:
|
||||
|
||||
if not stream:
|
||||
# Non-streaming request
|
||||
response = await self.client.chat.completions.create(
|
||||
**params, stream=False
|
||||
)
|
||||
params["stream"] = False
|
||||
|
||||
response = await self.client.chat.completions.create(**params)
|
||||
|
||||
if not response.choices or not response.choices[0].message.content:
|
||||
raise ValueError("Empty or invalid response from LLM")
|
||||
@ -442,7 +438,8 @@ class LLM:
|
||||
# Streaming request, For streaming, update estimated token count before making the request
|
||||
self.update_token_count(input_tokens)
|
||||
|
||||
response = await self.client.chat.completions.create(**params, stream=True)
|
||||
params["stream"] = True
|
||||
response = await self.client.chat.completions.create(**params)
|
||||
|
||||
collected_messages = []
|
||||
completion_text = ""
|
||||
@ -469,11 +466,11 @@ class LLM:
|
||||
except TokenLimitExceeded:
|
||||
# Re-raise token limit errors without logging
|
||||
raise
|
||||
except ValueError:
|
||||
logger.exception(f"Validation error")
|
||||
except ValueError as ve:
|
||||
logger.error(f"Validation error: {ve}")
|
||||
raise
|
||||
except OpenAIError as oe:
|
||||
logger.exception(f"OpenAI API error")
|
||||
logger.error(f"OpenAI API error: {oe}")
|
||||
if isinstance(oe, AuthenticationError):
|
||||
logger.error("Authentication failed. Check API key.")
|
||||
elif isinstance(oe, RateLimitError):
|
||||
@ -481,8 +478,8 @@ class LLM:
|
||||
elif isinstance(oe, APIError):
|
||||
logger.error(f"API error: {oe}")
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception(f"Unexpected error in ask")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in ask: {e}")
|
||||
raise
|
||||
|
||||
@retry(
|
||||
@ -657,7 +654,7 @@ class LLM:
|
||||
tool_choice: TOOL_CHOICE_TYPE = ToolChoice.AUTO, # type: ignore
|
||||
temperature: Optional[float] = None,
|
||||
**kwargs,
|
||||
) -> ChatCompletionMessage | None:
|
||||
):
|
||||
"""
|
||||
Ask LLM using functions/tools and return the response.
|
||||
|
||||
@ -735,15 +732,12 @@ class LLM:
|
||||
temperature if temperature is not None else self.temperature
|
||||
)
|
||||
|
||||
response: ChatCompletion = await self.client.chat.completions.create(
|
||||
**params, stream=False
|
||||
)
|
||||
response = await self.client.chat.completions.create(**params)
|
||||
|
||||
# Check if response is valid
|
||||
if not response.choices or not response.choices[0].message:
|
||||
print(response)
|
||||
# raise ValueError("Invalid or empty response from LLM")
|
||||
return None
|
||||
raise ValueError("Invalid or empty response from LLM")
|
||||
|
||||
# Update token counts
|
||||
self.update_token_count(
|
||||
|
@ -1,180 +0,0 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO, handlers=[logging.StreamHandler(sys.stderr)])
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import atexit
|
||||
import json
|
||||
from inspect import Parameter, Signature
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from app.logger import logger
|
||||
from app.tool.base import BaseTool
|
||||
from app.tool.bash import Bash
|
||||
from app.tool.browser_use_tool import BrowserUseTool
|
||||
from app.tool.str_replace_editor import StrReplaceEditor
|
||||
from app.tool.terminate import Terminate
|
||||
|
||||
|
||||
class MCPServer:
|
||||
"""MCP Server implementation with tool registration and management."""
|
||||
|
||||
def __init__(self, name: str = "openmanus"):
|
||||
self.server = FastMCP(name)
|
||||
self.tools: Dict[str, BaseTool] = {}
|
||||
|
||||
# Initialize standard tools
|
||||
self.tools["bash"] = Bash()
|
||||
self.tools["browser"] = BrowserUseTool()
|
||||
self.tools["editor"] = StrReplaceEditor()
|
||||
self.tools["terminate"] = Terminate()
|
||||
|
||||
def register_tool(self, tool: BaseTool, method_name: Optional[str] = None) -> None:
|
||||
"""Register a tool with parameter validation and documentation."""
|
||||
tool_name = method_name or tool.name
|
||||
tool_param = tool.to_param()
|
||||
tool_function = tool_param["function"]
|
||||
|
||||
# Define the async function to be registered
|
||||
async def tool_method(**kwargs):
|
||||
logger.info(f"Executing {tool_name}: {kwargs}")
|
||||
result = await tool.execute(**kwargs)
|
||||
|
||||
logger.info(f"Result of {tool_name}: {result}")
|
||||
|
||||
# Handle different types of results (match original logic)
|
||||
if hasattr(result, "model_dump"):
|
||||
return json.dumps(result.model_dump())
|
||||
elif isinstance(result, dict):
|
||||
return json.dumps(result)
|
||||
return result
|
||||
|
||||
# Set method metadata
|
||||
tool_method.__name__ = tool_name
|
||||
tool_method.__doc__ = self._build_docstring(tool_function)
|
||||
tool_method.__signature__ = self._build_signature(tool_function)
|
||||
|
||||
# Store parameter schema (important for tools that access it programmatically)
|
||||
param_props = tool_function.get("parameters", {}).get("properties", {})
|
||||
required_params = tool_function.get("parameters", {}).get("required", [])
|
||||
tool_method._parameter_schema = {
|
||||
param_name: {
|
||||
"description": param_details.get("description", ""),
|
||||
"type": param_details.get("type", "any"),
|
||||
"required": param_name in required_params,
|
||||
}
|
||||
for param_name, param_details in param_props.items()
|
||||
}
|
||||
|
||||
# Register with server
|
||||
self.server.tool()(tool_method)
|
||||
logger.info(f"Registered tool: {tool_name}")
|
||||
|
||||
def _build_docstring(self, tool_function: dict) -> str:
|
||||
"""Build a formatted docstring from tool function metadata."""
|
||||
description = tool_function.get("description", "")
|
||||
param_props = tool_function.get("parameters", {}).get("properties", {})
|
||||
required_params = tool_function.get("parameters", {}).get("required", [])
|
||||
|
||||
# Build docstring (match original format)
|
||||
docstring = description
|
||||
if param_props:
|
||||
docstring += "\n\nParameters:\n"
|
||||
for param_name, param_details in param_props.items():
|
||||
required_str = (
|
||||
"(required)" if param_name in required_params else "(optional)"
|
||||
)
|
||||
param_type = param_details.get("type", "any")
|
||||
param_desc = param_details.get("description", "")
|
||||
docstring += (
|
||||
f" {param_name} ({param_type}) {required_str}: {param_desc}\n"
|
||||
)
|
||||
|
||||
return docstring
|
||||
|
||||
def _build_signature(self, tool_function: dict) -> Signature:
|
||||
"""Build a function signature from tool function metadata."""
|
||||
param_props = tool_function.get("parameters", {}).get("properties", {})
|
||||
required_params = tool_function.get("parameters", {}).get("required", [])
|
||||
|
||||
parameters = []
|
||||
|
||||
# Follow original type mapping
|
||||
for param_name, param_details in param_props.items():
|
||||
param_type = param_details.get("type", "")
|
||||
default = Parameter.empty if param_name in required_params else None
|
||||
|
||||
# Map JSON Schema types to Python types (same as original)
|
||||
annotation = Any
|
||||
if param_type == "string":
|
||||
annotation = str
|
||||
elif param_type == "integer":
|
||||
annotation = int
|
||||
elif param_type == "number":
|
||||
annotation = float
|
||||
elif param_type == "boolean":
|
||||
annotation = bool
|
||||
elif param_type == "object":
|
||||
annotation = dict
|
||||
elif param_type == "array":
|
||||
annotation = list
|
||||
|
||||
# Create parameter with same structure as original
|
||||
param = Parameter(
|
||||
name=param_name,
|
||||
kind=Parameter.KEYWORD_ONLY,
|
||||
default=default,
|
||||
annotation=annotation,
|
||||
)
|
||||
parameters.append(param)
|
||||
|
||||
return Signature(parameters=parameters)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up server resources."""
|
||||
logger.info("Cleaning up resources")
|
||||
# Follow original cleanup logic - only clean browser tool
|
||||
if "browser" in self.tools and hasattr(self.tools["browser"], "cleanup"):
|
||||
await self.tools["browser"].cleanup()
|
||||
|
||||
def register_all_tools(self) -> None:
|
||||
"""Register all tools with the server."""
|
||||
for tool in self.tools.values():
|
||||
self.register_tool(tool)
|
||||
|
||||
def run(self, transport: str = "stdio") -> None:
|
||||
"""Run the MCP server."""
|
||||
# Register all tools
|
||||
self.register_all_tools()
|
||||
|
||||
# Register cleanup function (match original behavior)
|
||||
atexit.register(lambda: asyncio.run(self.cleanup()))
|
||||
|
||||
# Start server (with same logging as original)
|
||||
logger.info(f"Starting OpenManus server ({transport} mode)")
|
||||
self.server.run(transport=transport)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(description="OpenManus MCP Server")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
choices=["stdio"],
|
||||
default="stdio",
|
||||
help="Communication method: stdio or http (default: stdio)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
|
||||
# Create and run server (maintaining original flow)
|
||||
server = MCPServer()
|
||||
server.run(transport=args.transport)
|
@ -1,43 +0,0 @@
|
||||
"""Prompts for the MCP Agent."""
|
||||
|
||||
SYSTEM_PROMPT = """You are an AI assistant with access to a Model Context Protocol (MCP) server.
|
||||
You can use the tools provided by the MCP server to complete tasks.
|
||||
The MCP server will dynamically expose tools that you can use - always check the available tools first.
|
||||
|
||||
When using an MCP tool:
|
||||
1. Choose the appropriate tool based on your task requirements
|
||||
2. Provide properly formatted arguments as required by the tool
|
||||
3. Observe the results and use them to determine next steps
|
||||
4. Tools may change during operation - new tools might appear or existing ones might disappear
|
||||
|
||||
Follow these guidelines:
|
||||
- Call tools with valid parameters as documented in their schemas
|
||||
- Handle errors gracefully by understanding what went wrong and trying again with corrected parameters
|
||||
- For multimedia responses (like images), you'll receive a description of the content
|
||||
- Complete user requests step by step, using the most appropriate tools
|
||||
- If multiple tools need to be called in sequence, make one call at a time and wait for results
|
||||
|
||||
Remember to clearly explain your reasoning and actions to the user.
|
||||
"""
|
||||
|
||||
NEXT_STEP_PROMPT = """Based on the current state and available tools, what should be done next?
|
||||
Think step by step about the problem and identify which MCP tool would be most helpful for the current stage.
|
||||
If you've already made progress, consider what additional information you need or what actions would move you closer to completing the task.
|
||||
"""
|
||||
|
||||
# Additional specialized prompts
|
||||
TOOL_ERROR_PROMPT = """You encountered an error with the tool '{tool_name}'.
|
||||
Try to understand what went wrong and correct your approach.
|
||||
Common issues include:
|
||||
- Missing or incorrect parameters
|
||||
- Invalid parameter formats
|
||||
- Using a tool that's no longer available
|
||||
- Attempting an operation that's not supported
|
||||
|
||||
Please check the tool specifications and try again with corrected parameters.
|
||||
"""
|
||||
|
||||
MULTIMEDIA_RESPONSE_PROMPT = """You've received a multimedia response (image, audio, etc.) from the tool '{tool_name}'.
|
||||
This content has been processed and described for you.
|
||||
Use this information to continue the task or provide insights to the user.
|
||||
"""
|
@ -3,7 +3,7 @@ import os
|
||||
from typing import Optional
|
||||
|
||||
from app.exceptions import ToolError
|
||||
from app.tool.base import BaseTool, CLIResult
|
||||
from app.tool.base import BaseTool, CLIResult, ToolResult
|
||||
|
||||
|
||||
_BASH_DESCRIPTION = """Execute a bash command in the terminal.
|
||||
@ -57,7 +57,7 @@ class _BashSession:
|
||||
if not self._started:
|
||||
raise ToolError("Session has not started.")
|
||||
if self._process.returncode is not None:
|
||||
return CLIResult(
|
||||
return ToolResult(
|
||||
system="tool must be restarted",
|
||||
error=f"bash has exited with returncode {self._process.returncode}",
|
||||
)
|
||||
@ -140,7 +140,7 @@ class Bash(BaseTool):
|
||||
self._session = _BashSession()
|
||||
await self._session.start()
|
||||
|
||||
return CLIResult(system="tool has been restarted.")
|
||||
return ToolResult(system="tool has been restarted.")
|
||||
|
||||
if self._session is None:
|
||||
self._session = _BashSession()
|
||||
|
@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from typing import Generic, Optional, TypeVar
|
||||
|
||||
@ -553,16 +552,7 @@ Page content:
|
||||
viewport_height = ctx.config.browser_window_size.get("height", 0)
|
||||
|
||||
# Take a screenshot for the state
|
||||
page = await ctx.get_current_page()
|
||||
|
||||
await page.bring_to_front()
|
||||
await page.wait_for_load_state()
|
||||
|
||||
screenshot = await page.screenshot(
|
||||
full_page=True, animations="disabled", type="jpeg", quality=100
|
||||
)
|
||||
|
||||
screenshot = base64.b64encode(screenshot).decode("utf-8")
|
||||
screenshot = await ctx.take_screenshot(full_page=True)
|
||||
|
||||
# Build the state info with all required fields
|
||||
state_info = {
|
||||
|
@ -42,19 +42,17 @@ class FileOperator(Protocol):
|
||||
class LocalFileOperator(FileOperator):
|
||||
"""File operations implementation for local filesystem."""
|
||||
|
||||
encoding: str = "utf-8"
|
||||
|
||||
async def read_file(self, path: PathLike) -> str:
|
||||
"""Read content from a local file."""
|
||||
try:
|
||||
return Path(path).read_text(encoding=self.encoding)
|
||||
return Path(path).read_text()
|
||||
except Exception as e:
|
||||
raise ToolError(f"Failed to read {path}: {str(e)}") from None
|
||||
|
||||
async def write_file(self, path: PathLike, content: str) -> None:
|
||||
"""Write content to a local file."""
|
||||
try:
|
||||
Path(path).write_text(content, encoding=self.encoding)
|
||||
Path(path).write_text(content)
|
||||
except Exception as e:
|
||||
raise ToolError(f"Failed to write to {path}: {str(e)}") from None
|
||||
|
||||
|
115
app/tool/mcp.py
115
app/tool/mcp.py
@ -1,115 +0,0 @@
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import List, Optional
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.types import TextContent
|
||||
|
||||
from app.logger import logger
|
||||
from app.tool.base import BaseTool, ToolResult
|
||||
from app.tool.tool_collection import ToolCollection
|
||||
|
||||
|
||||
class MCPClientTool(BaseTool):
|
||||
"""Represents a tool proxy that can be called on the MCP server from the client side."""
|
||||
|
||||
session: Optional[ClientSession] = None
|
||||
|
||||
async def execute(self, **kwargs) -> ToolResult:
|
||||
"""Execute the tool by making a remote call to the MCP server."""
|
||||
if not self.session:
|
||||
return ToolResult(error="Not connected to MCP server")
|
||||
|
||||
try:
|
||||
result = await self.session.call_tool(self.name, kwargs)
|
||||
content_str = ", ".join(
|
||||
item.text for item in result.content if isinstance(item, TextContent)
|
||||
)
|
||||
return ToolResult(output=content_str or "No output returned.")
|
||||
except Exception as e:
|
||||
return ToolResult(error=f"Error executing tool: {str(e)}")
|
||||
|
||||
|
||||
class MCPClients(ToolCollection):
|
||||
"""
|
||||
A collection of tools that connects to an MCP server and manages available tools through the Model Context Protocol.
|
||||
"""
|
||||
|
||||
session: Optional[ClientSession] = None
|
||||
exit_stack: AsyncExitStack = None
|
||||
description: str = "MCP 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:
|
||||
"""Connect to an MCP server using SSE transport."""
|
||||
if not server_url:
|
||||
raise ValueError("Server URL is required.")
|
||||
if self.session:
|
||||
await self.disconnect()
|
||||
|
||||
streams_context = sse_client(url=server_url)
|
||||
streams = await self.exit_stack.enter_async_context(streams_context)
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
ClientSession(*streams)
|
||||
)
|
||||
|
||||
await self._initialize_and_list_tools()
|
||||
|
||||
async def connect_stdio(self, command: str, args: List[str]) -> None:
|
||||
"""Connect to an MCP server using stdio transport."""
|
||||
if not command:
|
||||
raise ValueError("Server command is required.")
|
||||
if self.session:
|
||||
await self.disconnect()
|
||||
|
||||
server_params = StdioServerParameters(command=command, args=args)
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
stdio_client(server_params)
|
||||
)
|
||||
read, write = stdio_transport
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
ClientSession(read, write)
|
||||
)
|
||||
|
||||
await self._initialize_and_list_tools()
|
||||
|
||||
async def _initialize_and_list_tools(self) -> None:
|
||||
"""Initialize session and populate tool map."""
|
||||
if not self.session:
|
||||
raise RuntimeError("Session not initialized.")
|
||||
|
||||
await self.session.initialize()
|
||||
response = await self.session.list_tools()
|
||||
|
||||
# Clear existing tools
|
||||
self.tools = tuple()
|
||||
self.tool_map = {}
|
||||
|
||||
# Create proper tool objects for each server tool
|
||||
for tool in response.tools:
|
||||
server_tool = MCPClientTool(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
parameters=tool.inputSchema,
|
||||
session=self.session,
|
||||
)
|
||||
self.tool_map[tool.name] = server_tool
|
||||
|
||||
self.tools = tuple(self.tool_map.values())
|
||||
logger.info(
|
||||
f"Connected to server with tools: {[tool.name for tool in response.tools]}"
|
||||
)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the MCP server and clean up resources."""
|
||||
if self.session and self.exit_stack:
|
||||
await self.exit_stack.aclose()
|
||||
self.session = None
|
||||
self.tools = tuple()
|
||||
self.tool_map = {}
|
||||
logger.info("Disconnected from MCP server")
|
@ -8,9 +8,6 @@ from app.tool.base import BaseTool, ToolFailure, ToolResult
|
||||
class ToolCollection:
|
||||
"""A collection of defined tools."""
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def __init__(self, *tools: BaseTool):
|
||||
self.tools = tools
|
||||
self.tool_map = {tool.name: tool for tool in tools}
|
||||
|
@ -4,7 +4,6 @@ from typing import List
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from app.config import config
|
||||
from app.logger import logger
|
||||
from app.tool.base import BaseTool
|
||||
from app.tool.search import (
|
||||
BaiduSearchEngine,
|
||||
@ -45,8 +44,6 @@ class WebSearch(BaseTool):
|
||||
async def execute(self, query: str, num_results: int = 10) -> List[str]:
|
||||
"""
|
||||
Execute a Web search and return a list of URLs.
|
||||
Tries engines in order based on configuration, falling back if an engine fails with errors.
|
||||
If all engines fail, it will wait and retry up to the configured number of times.
|
||||
|
||||
Args:
|
||||
query (str): The search query to submit to the search engine.
|
||||
@ -55,109 +52,37 @@ class WebSearch(BaseTool):
|
||||
Returns:
|
||||
List[str]: A list of URLs matching the search query.
|
||||
"""
|
||||
# Get retry settings from config
|
||||
retry_delay = 60 # Default to 60 seconds
|
||||
max_retries = 3 # Default to 3 retries
|
||||
|
||||
if config.search_config:
|
||||
retry_delay = getattr(config.search_config, "retry_delay", 60)
|
||||
max_retries = getattr(config.search_config, "max_retries", 3)
|
||||
|
||||
# Try searching with retries when all engines fail
|
||||
for retry_count in range(
|
||||
max_retries + 1
|
||||
): # +1 because first try is not a retry
|
||||
links = await self._try_all_engines(query, num_results)
|
||||
if links:
|
||||
return links
|
||||
|
||||
if retry_count < max_retries:
|
||||
# All engines failed, wait and retry
|
||||
logger.warning(
|
||||
f"All search engines failed. Waiting {retry_delay} seconds before retry {retry_count + 1}/{max_retries}..."
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
logger.error(
|
||||
f"All search engines failed after {max_retries} retries. Giving up."
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
async def _try_all_engines(self, query: str, num_results: int) -> List[str]:
|
||||
"""
|
||||
Try all search engines in the configured order.
|
||||
|
||||
Args:
|
||||
query (str): The search query to submit to the search engine.
|
||||
num_results (int): The number of search results to return.
|
||||
|
||||
Returns:
|
||||
List[str]: A list of URLs matching the search query, or empty list if all engines fail.
|
||||
"""
|
||||
engine_order = self._get_engine_order()
|
||||
failed_engines = []
|
||||
|
||||
for engine_name in engine_order:
|
||||
engine = self._search_engine[engine_name]
|
||||
try:
|
||||
logger.info(f"🔎 Attempting search with {engine_name.capitalize()}...")
|
||||
links = await self._perform_search_with_engine(
|
||||
engine, query, num_results
|
||||
)
|
||||
if links:
|
||||
if failed_engines:
|
||||
logger.info(
|
||||
f"Search successful with {engine_name.capitalize()} after trying: {', '.join(failed_engines)}"
|
||||
)
|
||||
return links
|
||||
except Exception as e:
|
||||
failed_engines.append(engine_name.capitalize())
|
||||
is_rate_limit = "429" in str(e) or "Too Many Requests" in str(e)
|
||||
|
||||
if is_rate_limit:
|
||||
logger.warning(
|
||||
f"⚠️ {engine_name.capitalize()} search engine rate limit exceeded, trying next engine..."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ {engine_name.capitalize()} search failed with error: {e}"
|
||||
)
|
||||
|
||||
if failed_engines:
|
||||
logger.error(f"All search engines failed: {', '.join(failed_engines)}")
|
||||
print(f"Search engine '{engine_name}' failed with error: {e}")
|
||||
return []
|
||||
|
||||
def _get_engine_order(self) -> List[str]:
|
||||
"""
|
||||
Determines the order in which to try search engines.
|
||||
Preferred engine is first (based on configuration), followed by fallback engines,
|
||||
and then the remaining engines.
|
||||
Preferred engine is first (based on configuration), followed by the remaining engines.
|
||||
|
||||
Returns:
|
||||
List[str]: Ordered list of search engine names.
|
||||
"""
|
||||
preferred = "google"
|
||||
fallbacks = []
|
||||
|
||||
if config.search_config:
|
||||
if config.search_config.engine:
|
||||
preferred = config.search_config.engine.lower()
|
||||
if config.search_config.fallback_engines:
|
||||
fallbacks = [
|
||||
engine.lower() for engine in config.search_config.fallback_engines
|
||||
]
|
||||
if config.search_config and config.search_config.engine:
|
||||
preferred = config.search_config.engine.lower()
|
||||
|
||||
engine_order = []
|
||||
# Add preferred engine first
|
||||
if preferred in self._search_engine:
|
||||
engine_order.append(preferred)
|
||||
|
||||
# Add configured fallback engines in order
|
||||
for fallback in fallbacks:
|
||||
if fallback in self._search_engine and fallback not in engine_order:
|
||||
engine_order.append(fallback)
|
||||
|
||||
for key in self._search_engine:
|
||||
if key not in engine_order:
|
||||
engine_order.append(key)
|
||||
return engine_order
|
||||
|
||||
@retry(
|
||||
|
@ -6,14 +6,6 @@ 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"
|
||||
@ -73,13 +65,6 @@ temperature = 0.0 # Controls randomness for vision mod
|
||||
# [search]
|
||||
# Search engine for agent to use. Default is "Google", can be set to "Baidu" or "DuckDuckGo".
|
||||
#engine = "Google"
|
||||
# Fallback engine order. Default is ["DuckDuckGo", "Baidu"] - will try in this order after primary engine fails.
|
||||
#fallback_engines = ["DuckDuckGo", "Baidu"]
|
||||
# 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
|
||||
|
||||
|
||||
## Sandbox configuration
|
||||
#[sandbox]
|
||||
|
131
mcp/README.md
Normal file
131
mcp/README.md
Normal file
@ -0,0 +1,131 @@
|
||||
# OpenManus-mcp 🤖
|
||||
|
||||
Implement a server based on [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) that exposes **OpenManus** tool functionalities as standardized APIs and create a simple client to interact with the server.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
This MCP server provides access to the following OpenManus tools:
|
||||
|
||||
1. **Browser Automation** 🌐 - Navigate webpages, click elements, input text, and more
|
||||
2. **Google Search** 🔍 - Execute searches and retrieve result links
|
||||
3. **Python Code Execution** 🐍 - Run Python code in a secure environment
|
||||
4. **File Saving** 💾 - Save content to local files
|
||||
5. **Termination Control** 🛑 - Control program execution flow
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- OpenManus project dependencies
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. First, install the OpenManus project:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mannaandpoem/OpenManus.git
|
||||
cd OpenManus
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
# Using uv (recommended)
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv venv
|
||||
source .venv/bin/activate # Unix/macOS
|
||||
# or .venv\Scripts\activate # Windows
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Install MCP dependencies:
|
||||
|
||||
```bash
|
||||
uv pip install -r mcp/mcp_requirements.txt
|
||||
playright install
|
||||
```
|
||||
|
||||
## Demo display
|
||||
https://github.com/user-attachments/assets/177b1f50-422f-4c2e-ab7d-1f3d7ff27679
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
### 1. Testing the server with Claude for Desktop 🖥️
|
||||
|
||||
> ⚠️ **Note**: Claude for Desktop is not yet available on Linux. Linux users can build an MCP client that connects to the server we just built.
|
||||
|
||||
#### Step 1: Installation Check ✅
|
||||
First, make sure you have Claude for Desktop installed. [You can install the latest version here](https://claude.ai/download). If you already have Claude for Desktop, **make sure it's updated to the latest version**.
|
||||
|
||||
#### Step 2: Configuration Setup ⚙️
|
||||
We'll need to configure Claude for Desktop for this server you want to use. To do this, open your Claude for Desktop App configuration at `~/Library/Application Support/Claude/claude_desktop_config.json` in a text editor. Make sure to create the file if it doesn't exist.
|
||||
|
||||
```bash
|
||||
vim ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
||||
```
|
||||
|
||||
#### Step 3: Server Configuration 🔧
|
||||
You'll then add your servers in the `mcpServers` key. The MCP UI elements will only show up in Claude for Desktop if at least one server is properly configured.
|
||||
|
||||
In this case, we'll add our single Openmanus server like so:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openmanus": {
|
||||
"command": "/ABSOLUTE/PATH/TO/PARENT/FOLDER/uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/ABSOLUTE/PATH/TO/OpenManus/mcp/server",
|
||||
"run",
|
||||
"server.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 **Tip**: You may need to put the full path to the uv executable in the command field. You can get this by running:
|
||||
> - MacOS/Linux: `which uv`
|
||||
> - Windows: `where uv`
|
||||
|
||||
#### Step 4: Understanding the Configuration 📝
|
||||
This tells Claude for Desktop:
|
||||
1. There's an MCP server named "openmanus" 🔌
|
||||
2. To launch it by running `uv --directory /ABSOLUTE/PATH/TO/OpenManus/mcp/server run server.py` 🚀
|
||||
|
||||
#### Step 5: Activation 🔄
|
||||
Save the file, and restart Claude for Desktop.
|
||||
|
||||
#### Step 6: Verification ✨
|
||||
Let's make sure Claude for Desktop is picking up the five tools we've exposed in our `openmanus` server. You can do this by looking for the hammer icon 
|
||||

|
||||
|
||||
After clicking on the hammer icon, you should see tools listed:
|
||||

|
||||
|
||||
#### Ready to Test! 🎉
|
||||
**Now, you can test the openmanus server in Claude for Desktop**:
|
||||
* 🔍 Try to find the recent news about Manus AI agent, and write a post for me!
|
||||
|
||||
|
||||
|
||||
### 💻 2. Testing with simple Client Example
|
||||
|
||||
Check out `client.py` to test the openmanus server using the MCP client.
|
||||
|
||||
#### Demo display
|
||||
https://github.com/user-attachments/assets/aeacd93d-9bec-46d1-831b-20e898c7507b
|
||||
```
|
||||
python mcp/client/client.py
|
||||
```
|
||||
|
||||
|
||||
## 🔒 Security Considerations
|
||||
|
||||
- When using in production, ensure proper authentication and authorization mechanisms are in place
|
||||
- The Python execution tool has timeout limits to prevent long-running code
|
||||
|
||||
## 📄 License
|
||||
|
||||
Same license as the OpenManus project
|
BIN
mcp/assets/1.jpg
Normal file
BIN
mcp/assets/1.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 97 KiB |
BIN
mcp/assets/2.png
Normal file
BIN
mcp/assets/2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 274 KiB |
3
mcp/assets/claude-desktop-mcp-hammer-icon.svg
Normal file
3
mcp/assets/claude-desktop-mcp-hammer-icon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M31.4175 14L22.985 5.51002C20.7329 3.26243 17.6811 2.00012 14.4993 2.00012C11.3176 2.00012 8.26581 3.26243 6.01372 5.51002L6.00247 5.52127L4.28122 7.30002C4.10292 7.49163 4.00685 7.74552 4.01364 8.00717C4.02043 8.26883 4.12954 8.51739 4.31754 8.6995C4.50554 8.88161 4.75745 8.98276 5.01919 8.98122C5.28092 8.97968 5.53163 8.87558 5.71747 8.69127L7.43372 6.91877C8.12421 6.22842 8.91217 5.64303 9.77247 5.18127L15.585 11L3.58497 23C3.39921 23.1857 3.25185 23.4062 3.15131 23.6489C3.05077 23.8916 2.99902 24.1517 2.99902 24.4144C2.99902 24.6771 3.05077 24.9372 3.15131 25.1799C3.25185 25.4225 3.39921 25.643 3.58497 25.8288L6.17122 28.415C6.35694 28.6008 6.57744 28.7481 6.82012 28.8487C7.06281 28.9492 7.32291 29.001 7.5856 29.001C7.84828 29.001 8.10839 28.9492 8.35107 28.8487C8.59375 28.7481 8.81425 28.6008 8.99997 28.415L21 16.415L22.7925 18.2075L25 20.4125C25.1857 20.5983 25.4062 20.7456 25.6489 20.8462C25.8916 20.9467 26.1517 20.9985 26.4143 20.9985C26.677 20.9985 26.9371 20.9467 27.1798 20.8462C27.4225 20.7456 27.643 20.5983 27.8287 20.4125L31.415 16.8263C31.7897 16.4516 32.0005 15.9436 32.0009 15.4137C32.0014 14.8838 31.7915 14.3753 31.4175 14ZM7.58497 27L4.99997 24.4138L13.5 15.9138L16.085 18.5L7.58497 27ZM20.2925 14.29L17.5 17.0838L14.9137 14.5L17.7075 11.7063C17.8004 11.6134 17.8742 11.5031 17.9245 11.3817C17.9749 11.2603 18.0008 11.1302 18.0008 10.9988C18.0008 10.8673 17.9749 10.7372 17.9245 10.6158C17.8742 10.4944 17.8004 10.3841 17.7075 10.2913L11.79 4.37502C13.4996 3.89351 15.3067 3.87606 17.0253 4.32445C18.744 4.77284 20.3122 5.67089 21.5687 6.92627L27.0962 12.49L23.5 16.0825L21.7075 14.29C21.6146 14.197 21.5043 14.1233 21.3829 14.073C21.2615 14.0226 21.1314 13.9967 21 13.9967C20.8686 13.9967 20.7384 14.0226 20.617 14.073C20.4956 14.1233 20.3853 14.197 20.2925 14.29ZM26.4175 18.9975L24.9175 17.4975L28.5 13.9063L30 15.4063L26.4175 18.9975Z" fill="#343330"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.0 KiB |
BIN
mcp/assets/demo.mp4
Normal file
BIN
mcp/assets/demo.mp4
Normal file
Binary file not shown.
217
mcp/client/client.py
Normal file
217
mcp/client/client.py
Normal file
@ -0,0 +1,217 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Optional
|
||||
|
||||
from colorama import Fore, init
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
|
||||
# Add current directory to Python path
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(current_dir)
|
||||
sys.path.insert(0, parent_dir)
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
# Add root directory to Python path
|
||||
root_dir = os.path.dirname(parent_dir)
|
||||
sys.path.insert(0, root_dir)
|
||||
from app.config import config
|
||||
|
||||
|
||||
# Initialize colorama
|
||||
def init_colorama():
|
||||
init(autoreset=True)
|
||||
|
||||
|
||||
class OpenManusClient:
|
||||
def __init__(self):
|
||||
# Load configuration
|
||||
# self.config = load_config()
|
||||
|
||||
# Initialize session and client objects
|
||||
self.session: Optional[ClientSession] = None
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
# Initialize AsyncOpenAI client with config
|
||||
self.llm_config = config.llm["default"]
|
||||
api_key = self.llm_config.api_key or os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"OpenAI API key not found in config.toml or environment variables"
|
||||
)
|
||||
|
||||
self.openai_client = AsyncOpenAI(
|
||||
api_key=api_key, base_url=self.llm_config.base_url
|
||||
)
|
||||
|
||||
async def connect_to_server(self, server_script_path: str = None):
|
||||
"""Connect to the openmanus MCP server"""
|
||||
# Use provided path or default from config
|
||||
script_path = server_script_path
|
||||
|
||||
server_params = StdioServerParameters(
|
||||
command="python", args=[script_path], env=None
|
||||
)
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
stdio_client(server_params)
|
||||
)
|
||||
self.stdio, self.write = stdio_transport
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
ClientSession(self.stdio, self.write)
|
||||
)
|
||||
|
||||
await self.session.initialize()
|
||||
|
||||
# List available tools
|
||||
response = await self.session.list_tools()
|
||||
tools = response.tools
|
||||
print("\nConnected to server with tools:", [tool.name for tool in tools])
|
||||
|
||||
async def chat_loop(self):
|
||||
"""Run an interactive chat loop for testing tools"""
|
||||
print(Fore.CYAN + "\n🚀 OpenManus MCP Client Started!")
|
||||
print(Fore.GREEN + "Type your queries or 'quit' to exit.")
|
||||
print(
|
||||
Fore.YELLOW
|
||||
+ "Example query: 'What is the recent news about the stock market?'\n"
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
query = input(Fore.BLUE + "🔍 Query: ").strip()
|
||||
|
||||
if query.lower() == "quit":
|
||||
print(Fore.RED + "👋 Exiting... Goodbye!")
|
||||
break
|
||||
|
||||
response = await self.process_query(query)
|
||||
print(Fore.MAGENTA + "\n💬 Response: " + response)
|
||||
|
||||
except Exception as e:
|
||||
print(Fore.RED + f"\n❌ Error: {str(e)}")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up resources"""
|
||||
await self.exit_stack.aclose()
|
||||
await self.openai_client.close() # Close the OpenAI client
|
||||
|
||||
async def process_query(self, query: str) -> str:
|
||||
"""Process a query using LLM and available tools"""
|
||||
# Add a system message to set the context for the model
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a general-purpose AI assistant called OpenManus. You can help users complete a wide range of tasks, providing detailed information and assistance as needed. Please include emojis in your responses to make them more engaging.",
|
||||
},
|
||||
{"role": "user", "content": query},
|
||||
]
|
||||
|
||||
response = await self.session.list_tools()
|
||||
available_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.inputSchema,
|
||||
},
|
||||
}
|
||||
for tool in response.tools
|
||||
]
|
||||
# Initial LLM API call
|
||||
response = await self.openai_client.chat.completions.create(
|
||||
model=self.llm_config.model,
|
||||
messages=messages,
|
||||
tools=available_tools,
|
||||
tool_choice="auto",
|
||||
)
|
||||
|
||||
# Process response and handle tool calls
|
||||
final_text = []
|
||||
|
||||
while True:
|
||||
message = response.choices[0].message
|
||||
|
||||
# Add assistant's message to conversation
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": message.content if message.content else None,
|
||||
"tool_calls": message.tool_calls
|
||||
if hasattr(message, "tool_calls")
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
# If no tool calls, we're done
|
||||
if not hasattr(message, "tool_calls") or not message.tool_calls:
|
||||
if message.content:
|
||||
final_text.append(message.content)
|
||||
break
|
||||
|
||||
# Handle tool calls
|
||||
for tool_call in message.tool_calls:
|
||||
tool_name = tool_call.function.name
|
||||
tool_args = tool_call.function.arguments
|
||||
|
||||
# Convert tool_args from string to dictionary if necessary
|
||||
if isinstance(tool_args, str):
|
||||
try:
|
||||
tool_args = json.loads(tool_args)
|
||||
except (ValueError, SyntaxError) as e:
|
||||
print(f"Error converting tool_args to dict: {e}")
|
||||
tool_args = {}
|
||||
|
||||
# Ensure tool_args is a dictionary
|
||||
if not isinstance(tool_args, dict):
|
||||
tool_args = {}
|
||||
|
||||
# Execute tool call
|
||||
print(f"Calling tool {tool_name} with args: {tool_args}")
|
||||
result = await self.session.call_tool(tool_name, tool_args)
|
||||
final_text.append(f"[Calling tool {tool_name}]")
|
||||
# final_text.append(f"Result: {result.content}")
|
||||
|
||||
# Add tool result to messages
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": str(result.content),
|
||||
}
|
||||
)
|
||||
|
||||
# Get next response from LLM
|
||||
response = await self.openai_client.chat.completions.create(
|
||||
model=self.llm_config.model,
|
||||
messages=messages,
|
||||
tools=available_tools,
|
||||
tool_choice="auto",
|
||||
)
|
||||
|
||||
return "\n".join(final_text)
|
||||
|
||||
|
||||
async def main():
|
||||
if len(sys.argv) > 1:
|
||||
server_script = sys.argv[1]
|
||||
else:
|
||||
server_script = "mcp/server/server.py"
|
||||
|
||||
client = OpenManusClient()
|
||||
try:
|
||||
await client.connect_to_server(server_script)
|
||||
await client.chat_loop()
|
||||
finally:
|
||||
await client.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
4
mcp/mcp_requirements.txt
Normal file
4
mcp/mcp_requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
# Core dependencies
|
||||
mcp
|
||||
httpx>=0.27.0
|
||||
tomli>=2.0.0
|
182
mcp/server/server.py
Normal file
182
mcp/server/server.py
Normal file
@ -0,0 +1,182 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from inspect import Parameter, Signature
|
||||
from typing import Any, Optional
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
|
||||
# Add directories to Python path
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(current_dir)
|
||||
root_dir = os.path.dirname(parent_dir)
|
||||
sys.path.insert(0, parent_dir)
|
||||
sys.path.insert(0, current_dir)
|
||||
sys.path.insert(0, root_dir)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger("mcp-server")
|
||||
|
||||
from app.tool.base import BaseTool
|
||||
from app.tool.bash import Bash
|
||||
|
||||
# Import OpenManus tools
|
||||
from app.tool.browser_use_tool import BrowserUseTool
|
||||
from app.tool.str_replace_editor import StrReplaceEditor
|
||||
from app.tool.terminate import Terminate
|
||||
|
||||
|
||||
# Initialize FastMCP server
|
||||
openmanus = FastMCP("openmanus")
|
||||
|
||||
# Initialize tool instances
|
||||
bash_tool = Bash()
|
||||
browser_tool = BrowserUseTool()
|
||||
str_replace_editor_tool = StrReplaceEditor()
|
||||
terminate_tool = Terminate()
|
||||
|
||||
|
||||
def register_tool(tool: BaseTool, method_name: Optional[str] = None) -> None:
|
||||
"""Register a tool with the OpenManus server.
|
||||
|
||||
Args:
|
||||
tool: The tool instance to register
|
||||
method_name: Optional custom name for the tool method
|
||||
"""
|
||||
tool_name = method_name or tool.name
|
||||
|
||||
# Get tool information using its own methods
|
||||
tool_param = tool.to_param()
|
||||
tool_function = tool_param["function"]
|
||||
|
||||
# Define the async function to be registered
|
||||
async def tool_method(**kwargs):
|
||||
logger.info(f"Executing {tool_name}: {kwargs}")
|
||||
result = await tool.execute(**kwargs)
|
||||
|
||||
# Handle different types of results
|
||||
if hasattr(result, "model_dump"):
|
||||
return json.dumps(result.model_dump())
|
||||
elif isinstance(result, dict):
|
||||
return json.dumps(result)
|
||||
return result
|
||||
|
||||
# Set the function name
|
||||
tool_method.__name__ = tool_name
|
||||
|
||||
# Set the function docstring
|
||||
description = tool_function.get("description", "")
|
||||
param_props = tool_function.get("parameters", {}).get("properties", {})
|
||||
required_params = tool_function.get("parameters", {}).get("required", [])
|
||||
|
||||
# Build a proper docstring with parameter descriptions
|
||||
docstring = description
|
||||
|
||||
# Create parameter list separately for the signature
|
||||
parameters = []
|
||||
|
||||
# Add parameters to both docstring and signature
|
||||
if param_props:
|
||||
docstring += "\n\nParameters:\n"
|
||||
for param_name, param_details in param_props.items():
|
||||
required_str = (
|
||||
"(required)" if param_name in required_params else "(optional)"
|
||||
)
|
||||
param_type = param_details.get("type", "any")
|
||||
param_desc = param_details.get("description", "")
|
||||
|
||||
# Add to docstring
|
||||
docstring += (
|
||||
f" {param_name} ({param_type}) {required_str}: {param_desc}\n"
|
||||
)
|
||||
|
||||
# Create parameter for signature
|
||||
default = Parameter.empty if param_name in required_params else None
|
||||
annotation = Any
|
||||
|
||||
# Try to get a better type annotation based on the parameter type
|
||||
if param_type == "string":
|
||||
annotation = str
|
||||
elif param_type == "integer":
|
||||
annotation = int
|
||||
elif param_type == "number":
|
||||
annotation = float
|
||||
elif param_type == "boolean":
|
||||
annotation = bool
|
||||
elif param_type == "object":
|
||||
annotation = dict
|
||||
elif param_type == "array":
|
||||
annotation = list
|
||||
|
||||
# Create parameter
|
||||
param = Parameter(
|
||||
name=param_name,
|
||||
kind=Parameter.KEYWORD_ONLY,
|
||||
default=default,
|
||||
annotation=annotation,
|
||||
)
|
||||
parameters.append(param)
|
||||
|
||||
# Store the full docstring
|
||||
tool_method.__doc__ = docstring
|
||||
|
||||
# Create and set the signature
|
||||
tool_method.__signature__ = Signature(parameters=parameters)
|
||||
|
||||
# Store the complete parameter schema for tools that need to access it programmatically
|
||||
tool_method._parameter_schema = {
|
||||
param_name: {
|
||||
"description": param_details.get("description", ""),
|
||||
"type": param_details.get("type", "any"),
|
||||
"required": param_name in required_params,
|
||||
}
|
||||
for param_name, param_details in param_props.items()
|
||||
}
|
||||
|
||||
# Register the tool with FastMCP
|
||||
openmanus.tool()(tool_method)
|
||||
logger.info(f"Registered tool: {tool_name}")
|
||||
|
||||
|
||||
# Register all tools
|
||||
register_tool(bash_tool)
|
||||
register_tool(browser_tool)
|
||||
register_tool(str_replace_editor_tool)
|
||||
register_tool(terminate_tool)
|
||||
|
||||
|
||||
# Clean up resources
|
||||
async def cleanup():
|
||||
"""Clean up all tool resources"""
|
||||
logger.info("Cleaning up resources")
|
||||
await browser_tool.cleanup()
|
||||
|
||||
|
||||
# Register cleanup function
|
||||
atexit.register(lambda: asyncio.run(cleanup()))
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""Parse command line arguments"""
|
||||
parser = argparse.ArgumentParser(description="OpenManus MCP Server")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
choices=["stdio"],
|
||||
default="stdio",
|
||||
help="Communication method: stdio or http (default: stdio)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
logger.info("Starting OpenManus server (stdio mode)")
|
||||
openmanus.run(transport="stdio")
|
@ -27,9 +27,3 @@ playwright~=1.50.0
|
||||
docker~=7.1.0
|
||||
pytest~=8.3.5
|
||||
pytest-asyncio~=0.25.3
|
||||
|
||||
mcp~=1.4.1
|
||||
httpx>=0.27.0
|
||||
tomli>=2.0.0
|
||||
|
||||
boto3~=1.37.16
|
||||
|
116
run_mcp.py
116
run_mcp.py
@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from app.agent.mcp import MCPAgent
|
||||
from app.config import config
|
||||
from app.logger import logger
|
||||
|
||||
|
||||
class MCPRunner:
|
||||
"""Runner class for MCP Agent with proper path handling and configuration."""
|
||||
|
||||
def __init__(self):
|
||||
self.root_path = config.root_path
|
||||
self.server_reference = "app.mcp.server"
|
||||
self.agent = MCPAgent()
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
connection_type: str,
|
||||
server_url: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the MCP agent with the appropriate connection."""
|
||||
logger.info(f"Initializing MCPAgent with {connection_type} connection...")
|
||||
|
||||
if connection_type == "stdio":
|
||||
await self.agent.initialize(
|
||||
connection_type="stdio",
|
||||
command=sys.executable,
|
||||
args=["-m", self.server_reference],
|
||||
)
|
||||
else: # sse
|
||||
await self.agent.initialize(connection_type="sse", server_url=server_url)
|
||||
|
||||
logger.info(f"Connected to MCP server via {connection_type}")
|
||||
|
||||
async def run_interactive(self) -> None:
|
||||
"""Run the agent in interactive mode."""
|
||||
print("\nMCP Agent Interactive Mode (type 'exit' to quit)\n")
|
||||
while True:
|
||||
user_input = input("\nEnter your request: ")
|
||||
if user_input.lower() in ["exit", "quit", "q"]:
|
||||
break
|
||||
response = await self.agent.run(user_input)
|
||||
print(f"\nAgent: {response}")
|
||||
|
||||
async def run_single_prompt(self, prompt: str) -> None:
|
||||
"""Run the agent with a single prompt."""
|
||||
await self.agent.run(prompt)
|
||||
|
||||
async def run_default(self) -> None:
|
||||
"""Run the agent in default mode."""
|
||||
prompt = input("Enter your prompt: ")
|
||||
if not prompt.strip():
|
||||
logger.warning("Empty prompt provided.")
|
||||
return
|
||||
|
||||
logger.warning("Processing your request...")
|
||||
await self.agent.run(prompt)
|
||||
logger.info("Request processing completed.")
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up agent resources."""
|
||||
await self.agent.cleanup()
|
||||
logger.info("Session ended")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(description="Run the MCP Agent")
|
||||
parser.add_argument(
|
||||
"--connection",
|
||||
"-c",
|
||||
choices=["stdio", "sse"],
|
||||
default="stdio",
|
||||
help="Connection type: stdio or sse",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--server-url",
|
||||
default="http://127.0.0.1:8000/sse",
|
||||
help="URL for SSE connection",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interactive", "-i", action="store_true", help="Run in interactive mode"
|
||||
)
|
||||
parser.add_argument("--prompt", "-p", help="Single prompt to execute and exit")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def run_mcp() -> None:
|
||||
"""Main entry point for the MCP runner."""
|
||||
args = parse_args()
|
||||
runner = MCPRunner()
|
||||
|
||||
try:
|
||||
await runner.initialize(args.connection, args.server_url)
|
||||
|
||||
if args.prompt:
|
||||
await runner.run_single_prompt(args.prompt)
|
||||
elif args.interactive:
|
||||
await runner.run_interactive()
|
||||
else:
|
||||
await runner.run_default()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Program interrupted by user")
|
||||
except Exception as e:
|
||||
logger.error(f"Error running MCPAgent: {str(e)}", exc_info=True)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_mcp())
|
@ -1,11 +0,0 @@
|
||||
# coding: utf-8
|
||||
# A shortcut to launch OpenManus MCP server, where its introduction also solves other import issues.
|
||||
from app.mcp.server import MCPServer, parse_args
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
|
||||
# Create and run server (maintaining original flow)
|
||||
server = MCPServer()
|
||||
server.run(transport=args.transport)
|
Loading…
x
Reference in New Issue
Block a user