Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d31b226ba | |||
|
|
beac2b3788 | ||
|
|
d3e0861a40 | ||
|
|
81acd16783 | ||
|
|
b8311451c8 | ||
|
|
edb54bc576 | ||
|
|
6f3f9c7df1 | ||
|
|
18cf1c7e1a |
428
README.md
Normal file
428
README.md
Normal file
@ -0,0 +1,428 @@
|
||||
# 多 Agent 平台 — 全栈开发子 Agent
|
||||
|
||||
基于 Google ADK (Agent Development Kit) 构建的多模型全栈开发 Agent 平台,内置三个独立 Agent,通过 REST API / MCP / CLI 多种方式调用,支持文件操作、终端命令、会话持久化、上下文压缩、长期记忆。
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
用户 / CodeBuddy(主控)
|
||||
│
|
||||
├─ MCP ──► my_agent_server.py ──┐
|
||||
├─ MCP ──► luna_server.py ──────┤
|
||||
└─ MCP ──► qwen_server.py ──────┤
|
||||
│
|
||||
┌────────────────┘
|
||||
▼
|
||||
各自独立的 API Server(不同端口)
|
||||
│
|
||||
▼
|
||||
App(dev_app)
|
||||
│ events_compaction_config(LLM 摘要压缩)
|
||||
▼
|
||||
LlmAgent(root_agent)
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
▼ ▼ ▼
|
||||
文件系统 终端命令 记忆系统
|
||||
MCP run_command preload_memory
|
||||
```
|
||||
|
||||
## 三个 Agent
|
||||
|
||||
| Agent 名称 | 目录 | 模型 | API 端口 | 会话数据库 | MCP 服务器名 |
|
||||
|-----------|------|------|---------|-----------|-------------|
|
||||
| `my_agent` | `agents/my_agent/` | aq-first-combo | 8001 | `sessions_my.db` | `my-agent` |
|
||||
| `luna_agent` | `agents/luna/` | gpt-5.6-luna | 8002 | `sessions_luna.db` | `luna-agent` |
|
||||
| `qwen_agent` | `agents/qwen/` | astron-code-latest | 8003 | `sessions_qwen.db` | `qwen-agent` |
|
||||
|
||||
每个 Agent 完全独立:独立的模型配置、独立的 API Server、独立的会话数据库、独立的 MCP 入口。
|
||||
|
||||
## 核心能力
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| **文件系统操作** | 读/写/列目录/搜索等 14 个工具(MCP: server-filesystem) |
|
||||
| **终端命令执行** | 异步 subprocess,支持编译/构建/测试 |
|
||||
| **网络搜索** | Tavily 搜索 + Fetch 抓取(默认关闭,见下文说明) |
|
||||
| **SQLite 会话持久化** | 重启不丢,每个 Agent 独立数据库 |
|
||||
| **上下文自动压缩** | 每 20 轮 LLM 摘要,长对话不爆 context window |
|
||||
| **长期记忆框架** | InMemory + 自动存取,可扩展为向量库 |
|
||||
| **REST API** | `/run`、`/run_sse`、会话管理、Swagger UI |
|
||||
| **MCP 接口** | 可直接接入 CodeBuddy / Cursor / Windsurf |
|
||||
| **A2A 协议** | Agent-to-Agent 标准协议(备用方案) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 环境准备
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd d:/nzy/workspace_python/agent
|
||||
|
||||
# 创建虚拟环境(已创建可跳过)
|
||||
python -m venv .venv
|
||||
|
||||
# 激活虚拟环境
|
||||
.venv\Scripts\activate # Windows
|
||||
# source .venv/bin/activate # Linux/Mac
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
每个 Agent 有独立的 `.env` 文件,在对应 agent 目录下:
|
||||
|
||||
```
|
||||
agents/my_agent/.env # my_agent 配置
|
||||
agents/luna/.env # luna_agent 配置
|
||||
agents/qwen/.env # qwen_agent 配置
|
||||
```
|
||||
|
||||
主要配置项(详见下方"配置说明"):
|
||||
- `VLLM_API_BASE` — vLLM API 端点
|
||||
- `VLLM_MODEL` — 模型名称
|
||||
- `VLLM_API_KEY` — API Key
|
||||
- `AGENT_WORKSPACE_DIR` — Agent 可访问的工作目录
|
||||
|
||||
### 3. 启动 API Server
|
||||
|
||||
每个 Agent 有独立的 API Server,在对应 agent 目录下启动:
|
||||
|
||||
```bash
|
||||
# 启动 my_agent(端口 8001)
|
||||
cd agents/my_agent && python api_server.py
|
||||
|
||||
# 启动 luna_agent(端口 8002)
|
||||
cd agents/luna && python api_server.py
|
||||
|
||||
# 启动 qwen_agent(端口 8003)
|
||||
cd agents/qwen && python api_server.py
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
- **Swagger UI**: http://127.0.0.1:8001/docs — 浏览器直接测试接口
|
||||
- **列出 Agent**: http://127.0.0.1:8001/list-apps
|
||||
|
||||
### 4. 配置 MCP(CodeBuddy 调用)
|
||||
|
||||
全局配置文件路径:`~/.codebuddy/.mcp.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-agent": {
|
||||
"type": "stdio",
|
||||
"command": "D:\\nzy\\workspace_python\\agent\\.venv\\Scripts\\python.exe",
|
||||
"args": ["d:\\nzy\\workspace_python\\agent\\mcp_dev_agent\\my_agent_server.py"],
|
||||
"description": "My Agent (aq-first-combo) 全栈开发助手"
|
||||
},
|
||||
"luna-agent": {
|
||||
"type": "stdio",
|
||||
"command": "D:\\nzy\\workspace_python\\agent\\.venv\\Scripts\\python.exe",
|
||||
"args": ["d:\\nzy\\workspace_python\\agent\\mcp_dev_agent\\luna_server.py"],
|
||||
"description": "Luna Agent (gpt-5.6-luna) 全栈开发助手"
|
||||
},
|
||||
"qwen-agent": {
|
||||
"type": "stdio",
|
||||
"command": "D:\\nzy\\workspace_python\\agent\\.venv\\Scripts\\python.exe",
|
||||
"args": ["d:\\nzy\\workspace_python\\agent\\mcp_dev_agent\\qwen_server.py"],
|
||||
"description": "Qwen Agent (astron-code-latest) 全栈开发助手"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:Windows 路径使用反斜杠 `\\`。MCP Server 通过 venv 的 python.exe 直接启动,不需要手动激活虚拟环境。
|
||||
|
||||
重启 CodeBuddy 后,三个 MCP 服务器会自动连接,每个提供一个 `run_dev_agent` 工具。
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 方式一:CLI 对话(每个 Agent 独立)
|
||||
|
||||
```bash
|
||||
# my_agent 对话
|
||||
cd agents/my_agent && python chat.py
|
||||
|
||||
# luna_agent 对话
|
||||
cd agents/luna && python chat.py
|
||||
|
||||
# qwen_agent 对话
|
||||
cd agents/qwen && python chat.py
|
||||
|
||||
# 指定 session_id 继续对话
|
||||
python chat.py --session my_session
|
||||
|
||||
# 列出所有会话
|
||||
python chat.py --list
|
||||
|
||||
# 删除会话
|
||||
python chat.py --delete my_session
|
||||
```
|
||||
|
||||
### 方式二:Swagger UI
|
||||
|
||||
打开对应端口的 `/docs`,在浏览器里直接测试接口。
|
||||
|
||||
**常用接口**:
|
||||
- `POST /run` — 同步运行 agent,返回完整事件列表
|
||||
- `POST /run_sse` — SSE 流式运行
|
||||
- `GET /apps/{app}/users/{user}/sessions/{id}` — 获取会话
|
||||
- `POST /apps/{app}/users/{user}/sessions/{id}` — 创建会话
|
||||
|
||||
### 方式三:MCP 工具(CodeBuddy / Cursor)
|
||||
|
||||
配置好 MCP 后,直接让 IDE 中的 AI 调用对应 Agent 的 `run_dev_agent` 工具。
|
||||
|
||||
**工具参数**:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `task` | ✅ | 任务描述,越详细越好 |
|
||||
| `session_id` | ❌ | 会话 ID,不传则为 `default`。用于多轮续聊 |
|
||||
|
||||
### 方式四:curl 直接调用
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8001/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"appName": "my_agent",
|
||||
"userId": "test_user",
|
||||
"sessionId": "test_001",
|
||||
"newMessage": {
|
||||
"role": "user",
|
||||
"parts": [{"text": "你好,请介绍一下你自己"}]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
agent/
|
||||
├── api_server.py # 旧版根目录 API Server(my_agent,保留兼容)
|
||||
├── chat.py # 旧版根目录 CLI(my_agent,保留兼容)
|
||||
├── a2a_server.py # A2A Server(备用)
|
||||
├── a2a_client.py # A2A 客户端测试
|
||||
├── test_sse_client.py # SSE 测试
|
||||
├── requirements.txt # Python 依赖
|
||||
│
|
||||
├── agents/ # 所有 Agent 目录
|
||||
│ ├── __init__.py
|
||||
│ │
|
||||
│ ├── my_agent/ # My Agent (aq-first-combo)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py # Agent 定义(人设、工具、instruction)
|
||||
│ │ ├── app.py # App 容器(上下文压缩配置)
|
||||
│ │ ├── api_server.py # 独立 API Server(端口 8001)
|
||||
│ │ ├── chat.py # 独立 CLI 对话工具
|
||||
│ │ ├── .env # 环境变量配置
|
||||
│ │ └── .adk/ # ADK 会话数据
|
||||
│ │
|
||||
│ ├── luna/ # Luna Agent (gpt-5.6-luna)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py
|
||||
│ │ ├── app.py
|
||||
│ │ ├── api_server.py # 独立 API Server(端口 8002)
|
||||
│ │ ├── chat.py # 独立 CLI 对话工具
|
||||
│ │ └── .env
|
||||
│ │
|
||||
│ └── qwen/ # Qwen Agent (astron-code-latest)
|
||||
│ ├── __init__.py
|
||||
│ ├── agent.py
|
||||
│ ├── app.py
|
||||
│ ├── api_server.py # 独立 API Server(端口 8003)
|
||||
│ ├── chat.py # 独立 CLI 对话工具
|
||||
│ └── .env
|
||||
│
|
||||
├── mcp_dev_agent/ # MCP Server(CodeBuddy 入口)
|
||||
│ ├── server.py # 通用 MCP Server 逻辑(FastMCP)
|
||||
│ ├── my_agent_server.py # my_agent MCP 入口(端口 8001)
|
||||
│ ├── luna_server.py # luna_agent MCP 入口(端口 8002)
|
||||
│ └── qwen_server.py # qwen_agent MCP 入口(端口 8003)
|
||||
│
|
||||
├── mcp_server/ # 旧版任务队列 MCP Server(保留参考)
|
||||
│ └── ...
|
||||
│
|
||||
├── mcp_tools/ # 备用 MCP 工具(保留参考)
|
||||
│ └── command_executor/
|
||||
│
|
||||
├── data/ # 数据目录(运行时生成)
|
||||
│ ├── sessions_my.db # my_agent 会话数据库
|
||||
│ ├── sessions_luna.db # luna_agent 会话数据库
|
||||
│ └── sessions_qwen.db # qwen_agent 会话数据库
|
||||
│
|
||||
└── PLAN.md # 项目计划文档
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量(Agent .env)
|
||||
|
||||
每个 Agent 目录下的 `.env` 文件:
|
||||
|
||||
```env
|
||||
# vLLM API 配置
|
||||
VLLM_API_BASE=https://9router.aqroid.cn/v1 # vLLM 端点地址
|
||||
VLLM_MODEL=aq-first-combo # 模型名
|
||||
VLLM_API_KEY=sk-... # API Key
|
||||
|
||||
# Agent 工作目录(文件系统 MCP 根目录)
|
||||
AGENT_WORKSPACE_DIR=D:\nzy\workspace_git
|
||||
|
||||
# Tavily 搜索 API Key(启用搜索工具时需要)
|
||||
TAVILY_API_KEY=tvly-dev-...
|
||||
|
||||
# Windows 编码
|
||||
PYTHONUTF8=1
|
||||
```
|
||||
|
||||
### API Server 配置
|
||||
|
||||
通过环境变量或直接修改对应 `api_server.py`:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `API_SERVER_HOST` | `0.0.0.0` | 监听地址 |
|
||||
| `API_SERVER_PORT` | `8001/8002/8003` | 监听端口(各 Agent 不同) |
|
||||
|
||||
### MCP Server 配置
|
||||
|
||||
每个 `*_server.py` 入口脚本顶部硬编码了对应的 API 地址和 Agent 名称:
|
||||
|
||||
| Agent | API URL | App Name |
|
||||
|-------|---------|----------|
|
||||
| my_agent | `http://127.0.0.1:8001` | `my_agent` |
|
||||
| luna_agent | `http://127.0.0.1:8002` | `luna_agent` |
|
||||
| qwen_agent | `http://127.0.0.1:8003` | `qwen_agent` |
|
||||
|
||||
## 会话与记忆
|
||||
|
||||
### 会话持久化
|
||||
|
||||
每个 Agent 的会话存储在独立的 SQLite 数据库中(`data/sessions_*.db`),重启服务不丢失。
|
||||
|
||||
- **同入口续聊**:同一个 session_id 下次接着聊
|
||||
- **跨入口共享**:同一个 Agent 的 API Server、CLI、MCP 共用同一个数据库
|
||||
|
||||
### 上下文压缩
|
||||
|
||||
长对话会自动摘要压缩(默认每 20 轮),防止 context window 溢出:
|
||||
|
||||
- 滑动窗口压缩 + 重叠摘要(保持连续性)
|
||||
- Token 超阈值紧急压缩(默认 50k)
|
||||
- 原始事件完整保留(可回溯)
|
||||
|
||||
配置在各 Agent 的 `app.py` 的 `EventsCompactionConfig`。
|
||||
|
||||
### 长期记忆
|
||||
|
||||
当前使用 `InMemoryMemoryService`(内存版),特性:
|
||||
|
||||
- 每轮对话结束自动保存(`after_agent_callback`)
|
||||
- 每轮对话开始自动加载相关记忆(`preload_memory`)
|
||||
- 进程重启后记忆丢失
|
||||
|
||||
**后续可扩展**:替换为 `ChromaMemoryService` 等向量数据库,实现持久化语义搜索。
|
||||
|
||||
## 工具说明
|
||||
|
||||
### 文件系统工具(14 个)
|
||||
|
||||
read_file、read_text_file、read_media_file、read_multiple_files、write_file、edit_file、create_directory、list_directory、list_directory_with_sizes、directory_tree、move_file、search_files、get_file_info、list_allowed_directories
|
||||
|
||||
### 终端命令
|
||||
|
||||
- **run_command** — 执行终端命令,支持自定义工作目录和超时
|
||||
|
||||
### 记忆工具
|
||||
|
||||
- **preload_memory** — 每轮自动检索并注入相关历史记忆(系统自动调用,不占工具回合)
|
||||
|
||||
### 网络搜索工具(默认关闭)
|
||||
|
||||
Tavily 搜索 + Fetch 抓取默认注释掉了,因为大响应内容可能导致请求体过大。如需启用:
|
||||
|
||||
1. 取消对应 `agent.py` 中 `fetch_mcp` 和 `tavily_mcp` 的注释
|
||||
2. 配置 `TAVILY_API_KEY` 环境变量
|
||||
|
||||
## 工作流程
|
||||
|
||||
标准工作流程:
|
||||
|
||||
1. 理解任务需求和项目上下文
|
||||
2. 使用文件系统工具浏览项目结构、读取相关文件
|
||||
3. 编写或修改代码
|
||||
4. 使用 run_command 运行编译/构建/测试
|
||||
5. 验证结果后,结构化报告完成情况
|
||||
|
||||
**报告格式**:
|
||||
- 状态:成功 / 部分完成 / 失败(需上报)
|
||||
- 修改的文件:列出所有修改的文件路径
|
||||
- 变更摘要:简述做了什么
|
||||
- 验证结果:编译/测试是否通过
|
||||
- 需要主控关注:如有问题,详细说明
|
||||
|
||||
## 部署说明
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
# 终端 1:启动 my_agent API Server
|
||||
cd agents/my_agent && python api_server.py
|
||||
|
||||
# 终端 2(可选):用 CLI 测试
|
||||
cd agents/my_agent && python chat.py
|
||||
|
||||
# 或者直接用 Swagger UI:http://127.0.0.1:8001/docs
|
||||
```
|
||||
|
||||
### 上云准备
|
||||
|
||||
- API Server 是标准 FastAPI 应用,可直接部署到任何支持 Python 的平台
|
||||
- SQLite 会话数据库需换成数据库服务(PostgreSQL / MySQL)
|
||||
- MemoryService 需换成托管向量数据库(Chroma / Pinecone / Vertex AI)
|
||||
- 文件系统 MCP 需接入云存储或挂载盘
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 | 版本 |
|
||||
|------|------|------|
|
||||
| Agent 框架 | Google ADK | 2.5.0 |
|
||||
| LLM 接入 | LiteLLM + vLLM (OpenAI 兼容) | 1.80.0 |
|
||||
| MCP | FastMCP (Model Context Protocol SDK) | 1.29.0 |
|
||||
| HTTP 服务 | FastAPI + Uvicorn | - |
|
||||
| 会话存储 | SQLite | - |
|
||||
| A2A 协议 | a2a-sdk | 1.1.2 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: MCP 服务器连不上?
|
||||
|
||||
A: 请检查:
|
||||
1. 对应 Agent 的 API Server 是否已启动(`agents/my_agent/api_server.py` 等)
|
||||
2. `.mcp.json` 中的 python.exe 路径和脚本路径是否正确(Windows 使用反斜杠)
|
||||
3. 端口是否被占用(`netstat -ano | findstr 8001`)
|
||||
|
||||
### Q: 调用时报 413 Request Entity Too Large?
|
||||
|
||||
A: vLLM 端点的 nginx 限制了请求体大小。当前已暂时关闭 Tavily 和 Fetch 工具以减小请求体。如需要启用,需联系端点管理员调大限制。
|
||||
|
||||
### Q: 会话数据存在哪?
|
||||
|
||||
A: `data/sessions_*.db`,每个 Agent 有独立的 SQLite 数据库文件。
|
||||
|
||||
### Q: 怎么重置会话?
|
||||
|
||||
A: 用 CLI 的 `python chat.py --delete <session_id>`,或直接调用 DELETE 会话 API,或直接删除对应的 `.db` 文件。
|
||||
|
||||
### Q: 三个 Agent 有什么区别?
|
||||
|
||||
A: 区别只在使用的模型不同(aq-first-combo / gpt-5.6-luna / astron-code-latest),工具集和能力完全一致。可以根据任务特点选择合适的模型。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
66
a2a_client.py
Normal file
66
a2a_client.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""
|
||||
A2A 客户端测试脚本
|
||||
通过 A2A 协议调用本地的 Dev Agent 服务。
|
||||
|
||||
使用方式:
|
||||
python a2a_client.py "你好,介绍一下你自己"
|
||||
python a2a_client.py --context my_session "你好" # 指定 context_id(会话)
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
from a2a.client import create_client
|
||||
from a2a.types.a2a_pb2 import SendMessageRequest, Message, Part, Role
|
||||
|
||||
|
||||
A2A_URL = "http://127.0.0.1:8001"
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="A2A 客户端测试")
|
||||
parser.add_argument("message", help="发送给 agent 的消息")
|
||||
parser.add_argument("--context", "-c", default="", help="context_id,用于会话续传")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"连接到: {A2A_URL}")
|
||||
if args.context:
|
||||
print(f"会话: {args.context}")
|
||||
print(f"发送: {args.message}")
|
||||
print("-" * 40)
|
||||
|
||||
client = await create_client(A2A_URL)
|
||||
try:
|
||||
# 构建请求
|
||||
request = SendMessageRequest(
|
||||
message=Message(
|
||||
role=Role.ROLE_USER,
|
||||
parts=[Part(text=args.message)],
|
||||
),
|
||||
)
|
||||
if args.context:
|
||||
request.message.context_id = args.context
|
||||
|
||||
# 发送消息(流式返回)
|
||||
full_text = ""
|
||||
context_id = ""
|
||||
async for response in client.send_message(request):
|
||||
# 流式响应里可能有 message、status_update 等
|
||||
if response.HasField("message"):
|
||||
msg = response.message
|
||||
if msg.context_id:
|
||||
context_id = msg.context_id
|
||||
for part in msg.parts:
|
||||
if part.text:
|
||||
full_text += part.text
|
||||
|
||||
print("Agent 回复:")
|
||||
print(full_text)
|
||||
if context_id:
|
||||
print(f"\n(context_id: {context_id})")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -19,31 +19,69 @@ if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "my_agent", ".env"))
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "agents/my_agent", ".env"))
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
import uvicorn
|
||||
from google.adk.a2a.utils.agent_to_a2a import to_a2a
|
||||
from my_agent.agent import root_agent
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from agents.my_agent.app import dev_app
|
||||
|
||||
|
||||
# 配置
|
||||
HOST = os.getenv("A2A_SERVER_HOST", "0.0.0.0")
|
||||
HOST = os.getenv("A2A_SERVER_HOST", "0.0.0.0") # 监听地址
|
||||
PORT = int(os.getenv("A2A_SERVER_PORT", "8001"))
|
||||
# Agent Card 中对外公布的地址(客户端用这个来连接,不能用 0.0.0.0)
|
||||
PUBLIC_HOST = os.getenv("A2A_PUBLIC_HOST", "127.0.0.1")
|
||||
|
||||
# 数据目录
|
||||
DATA_DIR = os.path.join(PROJECT_ROOT, "data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
# --- Session 服务:SQLite 持久化(重启不丢失对话历史)---
|
||||
session_service = SqliteSessionService(
|
||||
db_path=os.path.join(DATA_DIR, "sessions.db")
|
||||
)
|
||||
|
||||
# --- Memory 服务:长期记忆(先用内存版,后续可换 Chroma 等向量库)---
|
||||
memory_service = InMemoryMemoryService()
|
||||
|
||||
# --- Artifact 服务:工件存储(大文件等)---
|
||||
artifact_service = InMemoryArtifactService()
|
||||
|
||||
# --- 构建 Runner:会话 + 记忆 + 压缩 一体化 ---
|
||||
runner = Runner(
|
||||
app=dev_app,
|
||||
session_service=session_service,
|
||||
memory_service=memory_service,
|
||||
artifact_service=artifact_service,
|
||||
auto_create_session=True,
|
||||
)
|
||||
|
||||
# 用 ADK 官方工具把 agent 转成 A2A 服务
|
||||
# 会自动生成 agent card,暴露 /a2a/{agent_name} 端点
|
||||
a2a_app = to_a2a(root_agent, port=PORT)
|
||||
# 传入自定义 runner,启用 SQLite 持久化 + 上下文压缩 + 记忆
|
||||
a2a_app = to_a2a(
|
||||
agent=dev_app.root_agent,
|
||||
host=PUBLIC_HOST, # agent card 里用的对外地址
|
||||
port=PORT,
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Dev Agent A2A Server 启动中...")
|
||||
print(f" 监听地址: http://{HOST}:{PORT}")
|
||||
print(f" A2A 端点: http://{HOST}:{PORT}/a2a/{root_agent.name}")
|
||||
print(f" Agent 卡片: http://{HOST}:{PORT}/.well-known/agent-card.json")
|
||||
print(f" 对外地址: http://{PUBLIC_HOST}:{PORT}")
|
||||
print(f" A2A 端点: http://{PUBLIC_HOST}:{PORT}/")
|
||||
print(f" Agent 卡片: http://{PUBLIC_HOST}:{PORT}/.well-known/agent-card.json")
|
||||
print(f" 会话持久化: SQLite ({DATA_DIR}/sessions.db)")
|
||||
print(f" 上下文压缩: 每 20 轮自动摘要")
|
||||
print("=" * 60)
|
||||
|
||||
uvicorn.run(a2a_app, host=HOST, port=PORT, log_level="info")
|
||||
|
||||
348
agent_status.py
Normal file
348
agent_status.py
Normal file
@ -0,0 +1,348 @@
|
||||
"""
|
||||
全局 Agent 状态监控脚本
|
||||
列出三个 agent 所有会话的实时状态,一眼看出哪些在跑、哪些卡住了。
|
||||
|
||||
用法:
|
||||
python agent_status.py # 查看所有 agent 的所有会话
|
||||
python agent_status.py --watch # 实时刷新模式(每 3 秒刷新一次)
|
||||
python agent_status.py -w -i 2 # 实时刷新,间隔 2 秒
|
||||
python agent_status.py --agent my_agent # 只看指定 agent
|
||||
python agent_status.py --limit 20 # 每个 agent 最多显示 20 条
|
||||
python agent_status.py -s my_session # 查看特定会话的详细状态
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
# 三个 agent 的 API 地址
|
||||
AGENTS = {
|
||||
"my_agent": "http://127.0.0.1:8001",
|
||||
"luna_agent": "http://127.0.0.1:8002",
|
||||
"qwen_agent": "http://127.0.0.1:8003",
|
||||
}
|
||||
|
||||
USER_ID = "codebuddy"
|
||||
|
||||
|
||||
def check_api_alive(url: str) -> bool:
|
||||
"""检查 API Server 是否存活"""
|
||||
try:
|
||||
resp = httpx.get(f"{url}/health", timeout=2.0)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def list_sessions(api_url: str, app_name: str, user_id: str) -> list[dict]:
|
||||
"""获取所有会话列表"""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{api_url}/apps/{app_name}/users/{user_id}/sessions",
|
||||
timeout=5.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# 响应可能是 list 或 {sessions: [...]}
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return data.get("sessions", [])
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_session_detail(api_url: str, app_name: str, user_id: str, session_id: str) -> dict | None:
|
||||
"""获取会话详情"""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{api_url}/apps/{app_name}/users/{user_id}/sessions/{session_id}",
|
||||
timeout=5.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def format_time(timestamp: float) -> str:
|
||||
"""格式化时间戳"""
|
||||
if not timestamp:
|
||||
return "?"
|
||||
try:
|
||||
return datetime.fromtimestamp(timestamp).strftime("%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
return str(timestamp)[:19]
|
||||
|
||||
|
||||
def get_last_event_summary(events: list[dict]) -> tuple[str, str, str, str]:
|
||||
"""
|
||||
从事件列表提取最后一条事件的摘要信息。
|
||||
返回: (角色, 作者, 状态描述, 内容摘要)
|
||||
"""
|
||||
if not events:
|
||||
return ("-", "-", "(空会话)", "")
|
||||
|
||||
last = events[-1]
|
||||
content = last.get("content", {})
|
||||
role = content.get("role", "?")
|
||||
author = last.get("author", "")
|
||||
parts = content.get("parts", [])
|
||||
|
||||
# 判断状态
|
||||
status = ""
|
||||
summary = ""
|
||||
|
||||
role_cn = {
|
||||
"user": "用户输入",
|
||||
"model": "模型回复",
|
||||
"function": "工具调用",
|
||||
}.get(role, role)
|
||||
|
||||
for part in parts:
|
||||
if "thought" in part and part.get("thought"):
|
||||
status = "💭 思考中"
|
||||
text = part.get("text", "")
|
||||
summary = text[:60].replace("\n", " ")
|
||||
break
|
||||
elif "functionCall" in part:
|
||||
call = part["functionCall"]
|
||||
status = f"📞 调用中: {call.get('name', '?')}"
|
||||
args = call.get("args", {})
|
||||
# 显示关键参数
|
||||
if "path" in args:
|
||||
summary = f"path: {args['path'][:50]}"
|
||||
elif "command" in args:
|
||||
summary = f"cmd: {args['command'][:50]}"
|
||||
else:
|
||||
args_str = json.dumps(args, ensure_ascii=False)[:60]
|
||||
summary = args_str
|
||||
break
|
||||
elif "functionResponse" in part:
|
||||
resp = part["functionResponse"]
|
||||
status = f"✅ 工具返回: {resp.get('name', '?')}"
|
||||
resp_content = resp.get("content", [])
|
||||
text = ""
|
||||
for c in resp_content:
|
||||
if isinstance(c, dict) and c.get("type") == "text":
|
||||
text += c.get("text", "")
|
||||
elif isinstance(c, str):
|
||||
text += c
|
||||
summary = text[:80].replace("\n", " ")
|
||||
if resp.get("isError"):
|
||||
status = f"❌ 工具错误: {resp.get('name', '?')}"
|
||||
break
|
||||
elif "text" in part:
|
||||
status = f"💬 {role_cn}"
|
||||
summary = part["text"][:80].replace("\n", " ")
|
||||
break
|
||||
|
||||
if not status:
|
||||
status = f"📨 {role_cn}"
|
||||
|
||||
return (role, author, status, summary)
|
||||
|
||||
|
||||
def print_status_table(agent_names: list[str], limit: int):
|
||||
"""打印所有 agent 的会话状态表格"""
|
||||
total_sessions = 0
|
||||
active_count = 0
|
||||
|
||||
for agent_name in agent_names:
|
||||
api_url = AGENTS[agent_name]
|
||||
alive = check_api_alive(api_url)
|
||||
|
||||
print(f"\n{'═' * 80}")
|
||||
status_icon = "🟢" if alive else "🔴"
|
||||
print(f"{status_icon} {agent_name} ({api_url})")
|
||||
print(f"{'─' * 80}")
|
||||
|
||||
if not alive:
|
||||
print(" ⚠️ API Server 未启动或无法连接")
|
||||
continue
|
||||
|
||||
sessions = list_sessions(api_url, agent_name, USER_ID)
|
||||
total_sessions += len(sessions)
|
||||
|
||||
if not sessions:
|
||||
print(" (暂无会话)")
|
||||
continue
|
||||
|
||||
# 按更新时间倒序(字段名可能是 lastUpdateTime 或 last_update_time)
|
||||
sessions.sort(
|
||||
key=lambda s: s.get("lastUpdateTime") or s.get("last_update_time", 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# 只显示 limit 条
|
||||
shown = sessions[:limit]
|
||||
hidden_count = len(sessions) - limit
|
||||
|
||||
print(f" {'#':>3s} {'最后更新时间':<18s} {'事件数':>5s} {'状态'}")
|
||||
print(f" {'─' * 76}")
|
||||
|
||||
for idx, sess in enumerate(shown, 1):
|
||||
sid = sess.get("id", "?")
|
||||
last_time = sess.get("lastUpdateTime") or sess.get("last_update_time", 0)
|
||||
time_str = format_time(last_time)
|
||||
|
||||
# 取详情获取最后事件
|
||||
detail = get_session_detail(api_url, agent_name, USER_ID, sid)
|
||||
events = detail.get("events", []) if detail else []
|
||||
event_count = len(events)
|
||||
_, _, status, _ = get_last_event_summary(events)
|
||||
|
||||
# 判断是否活跃(5分钟内有更新)
|
||||
now_ts = time.time()
|
||||
is_active = (now_ts - last_time) < 300
|
||||
if is_active and event_count > 0:
|
||||
active_count += 1
|
||||
active_icon = "⚡"
|
||||
else:
|
||||
active_icon = " "
|
||||
|
||||
sid_short = sid if len(sid) <= 12 else sid[:10] + ".."
|
||||
print(f" {active_icon}{idx:>2d}. {time_str} {event_count:>5d} {status[:50]}")
|
||||
print(f" id: {sid}")
|
||||
|
||||
if hidden_count > 0:
|
||||
print(f"\n ... 还有 {hidden_count} 个会话未显示(共 {len(sessions)} 个)")
|
||||
|
||||
print(f"\n{'═' * 80}")
|
||||
print(f" 总计: {total_sessions} 个会话 | 活跃中(5分钟内有更新): {active_count}")
|
||||
print(f"{'═' * 80}\n")
|
||||
|
||||
|
||||
def watch_mode(agent_names: list[str], interval: float, limit: int):
|
||||
"""实时刷新模式"""
|
||||
print(f"\n🔄 实时监控模式(每 {interval} 秒刷新,Ctrl+C 退出)\n")
|
||||
try:
|
||||
while True:
|
||||
# 清屏
|
||||
if os.name == "nt":
|
||||
os.system("cls")
|
||||
else:
|
||||
os.system("clear")
|
||||
print_status_table(agent_names, limit)
|
||||
print(f" 最后刷新: {datetime.now().strftime('%H:%M:%S')} | Ctrl+C 退出")
|
||||
time.sleep(interval)
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 已退出监控。")
|
||||
|
||||
|
||||
def show_session_detail(agent_name: str, session_id: str):
|
||||
"""查看特定会话的详细状态"""
|
||||
api_url = AGENTS.get(agent_name, "")
|
||||
if not api_url:
|
||||
print(f"未知 agent: {agent_name}")
|
||||
return
|
||||
|
||||
alive = check_api_alive(api_url)
|
||||
if not alive:
|
||||
print(f"⚠️ {agent_name} API Server 未启动({api_url})")
|
||||
return
|
||||
|
||||
detail = get_session_detail(api_url, agent_name, USER_ID, session_id)
|
||||
if not detail:
|
||||
print(f"会话 [{session_id}] 不存在")
|
||||
return
|
||||
|
||||
events = detail.get("events", [])
|
||||
event_count = len(events)
|
||||
last_time = detail.get("lastUpdateTime") or detail.get("last_update_time", 0)
|
||||
# 会话详情里没有 create_time,从第一个事件的时间戳估算
|
||||
create_time = detail.get("create_time", 0)
|
||||
if not create_time and events:
|
||||
create_time = events[0].get("timestamp", 0)
|
||||
|
||||
print(f"\n{'═' * 80}")
|
||||
print(f"📋 会话详情")
|
||||
print(f"{'═' * 80}")
|
||||
print(f" Agent: {agent_name}")
|
||||
print(f" Session: {session_id}")
|
||||
print(f" 创建时间: {format_time(create_time)}")
|
||||
print(f" 更新时间: {format_time(last_time)}")
|
||||
print(f" 事件数: {event_count}")
|
||||
|
||||
if events:
|
||||
duration = last_time - create_time if create_time and last_time else 0
|
||||
if duration > 0:
|
||||
mins = int(duration // 60)
|
||||
secs = int(duration % 60)
|
||||
print(f" 运行时长: {mins}分{secs}秒")
|
||||
|
||||
# 最后 5 条事件
|
||||
print(f"\n{'─' * 80}")
|
||||
print(f" 最后 5 条事件:")
|
||||
print(f"{'─' * 80}")
|
||||
|
||||
for i, event in enumerate(events[-5:], max(1, event_count - 4)):
|
||||
role, author, status, summary = get_last_event_summary([event])
|
||||
ts = event.get("timestamp", 0)
|
||||
t_str = format_time(ts).split()[-1] if " " in format_time(ts) else format_time(ts)
|
||||
print(f"\n #{i} [{t_str}] {status}")
|
||||
if summary:
|
||||
print(f" {summary[:100]}")
|
||||
|
||||
print(f"\n{'═' * 80}\n")
|
||||
|
||||
|
||||
def main():
|
||||
global USER_ID
|
||||
parser = argparse.ArgumentParser(description="全局 Agent 状态监控工具")
|
||||
parser.add_argument("--agent", "-a", default=None,
|
||||
help="只查看指定 agent(默认查看所有)")
|
||||
parser.add_argument("--watch", "-w", action="store_true",
|
||||
help="实时刷新模式")
|
||||
parser.add_argument("--interval", "-i", type=float, default=3.0,
|
||||
help="刷新间隔秒数(默认 3.0)")
|
||||
parser.add_argument("--limit", "-l", type=int, default=10,
|
||||
help="每个 agent 最多显示的会话数(默认 10)")
|
||||
parser.add_argument("--session", "-s", default=None,
|
||||
help="查看特定会话的详细状态")
|
||||
parser.add_argument("--user", "-u", default="codebuddy",
|
||||
help="用户 ID(默认 codebuddy)")
|
||||
args = parser.parse_args()
|
||||
|
||||
global USER_ID
|
||||
USER_ID = args.user
|
||||
|
||||
# 确定要查看的 agent 列表
|
||||
if args.agent:
|
||||
agent_name = args.agent
|
||||
# 支持别名
|
||||
aliases = {
|
||||
"my": "my_agent", "default": "my_agent", "aq": "my_agent",
|
||||
"luna": "luna_agent", "gpt": "luna_agent",
|
||||
"qwen": "qwen_agent", "astron": "qwen_agent",
|
||||
}
|
||||
if agent_name in aliases:
|
||||
agent_name = aliases[agent_name]
|
||||
if agent_name not in AGENTS:
|
||||
print(f"未知 agent: {args.agent}")
|
||||
print(f"可用: {list(AGENTS.keys())}")
|
||||
sys.exit(1)
|
||||
agent_names = [agent_name]
|
||||
else:
|
||||
agent_names = list(AGENTS.keys())
|
||||
|
||||
# 查看单个会话详情
|
||||
if args.session:
|
||||
show_session_detail(agent_names[0], args.session)
|
||||
return
|
||||
|
||||
# 实时刷新模式
|
||||
if args.watch:
|
||||
watch_mode(agent_names, args.interval, args.limit)
|
||||
else:
|
||||
print_status_table(agent_names, args.limit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
agents/__init__.py
Normal file
0
agents/__init__.py
Normal file
185
agents/luna/agent.py
Normal file
185
agents/luna/agent.py
Normal file
@ -0,0 +1,185 @@
|
||||
from google.adk.agents import LlmAgent
|
||||
from google.adk.models.lite_llm import LiteLlm
|
||||
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
|
||||
from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams
|
||||
from google.adk.tools.function_tool import FunctionTool
|
||||
from mcp.client.stdio import StdioServerParameters
|
||||
import os
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# --- 使用 vLLM 端点的智能体 ---
|
||||
api_base_url = os.getenv("VLLM_API_BASE", "https://9router.aqroid.cn/v1")
|
||||
model_name = os.getenv("VLLM_MODEL", "")
|
||||
api_key = os.getenv("VLLM_API_KEY", "")
|
||||
|
||||
# Agent 可访问的工作目录
|
||||
WORKSPACE_DIR = os.getenv("AGENT_WORKSPACE_DIR", r"D:\nzy\workspace_git")
|
||||
|
||||
# --- 文件系统 MCP 工具 ---
|
||||
filesystem_mcp = McpToolset(
|
||||
connection_params=StdioConnectionParams(
|
||||
server_params=StdioServerParameters(
|
||||
command="npx",
|
||||
args=[
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
os.path.abspath(WORKSPACE_DIR),
|
||||
],
|
||||
),
|
||||
timeout=300000,
|
||||
),
|
||||
)
|
||||
|
||||
# --- 网页抓取 MCP 工具(Fetch)---
|
||||
# 暂时注释:请求体过大导致 413 错误,后续需要时再启用
|
||||
# fetch_mcp = McpToolset(
|
||||
# connection_params=StdioConnectionParams(
|
||||
# server_params=StdioServerParameters(
|
||||
# command="npx",
|
||||
# args=[
|
||||
# "-y",
|
||||
# "@modelcontextprotocol/server-fetch",
|
||||
# ],
|
||||
# ),
|
||||
# timeout=300000,
|
||||
# ),
|
||||
# )
|
||||
|
||||
# --- Tavily 搜索 MCP 工具 ---
|
||||
# 暂时注释:请求体过大导致 413 错误,后续需要时再启用
|
||||
# tavily_api_key = os.getenv("TAVILY_API_KEY", "")
|
||||
# tavily_mcp = McpToolset(
|
||||
# connection_params=StdioConnectionParams(
|
||||
# server_params=StdioServerParameters(
|
||||
# command="npx",
|
||||
# args=[
|
||||
# "-y",
|
||||
# "tavily-mcp@latest",
|
||||
# ],
|
||||
# env={
|
||||
# "TAVILY_API_KEY": tavily_api_key,
|
||||
# },
|
||||
# ),
|
||||
# timeout=300000,
|
||||
# ),
|
||||
# )
|
||||
|
||||
|
||||
# --- 终端命令执行工具(Python 原生,绕开 MCP 通信问题)---
|
||||
async def run_command(command: str, cwd: str = None, timeout: int = 300000000) -> str:
|
||||
"""
|
||||
在终端中执行一条命令,返回输出结果。
|
||||
|
||||
Args:
|
||||
command: 要执行的命令,如 'npm run build'、'python -m pytest' 等
|
||||
cwd: 命令执行的工作目录,默认使用 AGENT_WORKSPACE_DIR
|
||||
timeout: 超时时间(秒),默认 300
|
||||
|
||||
Returns:
|
||||
命令执行结果(stdout + stderr + 状态)
|
||||
"""
|
||||
if not command:
|
||||
return "错误:命令不能为空"
|
||||
|
||||
work_dir = cwd or os.path.abspath(WORKSPACE_DIR)
|
||||
if not os.path.isdir(work_dir):
|
||||
return f"错误:工作目录不存在 {work_dir}"
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
cwd=work_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return f"命令执行超时({timeout}秒): {command}"
|
||||
except Exception as e:
|
||||
return f"命令执行出错: {e}"
|
||||
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
parts = []
|
||||
if stdout:
|
||||
parts.append(f"[stdout]\n{stdout}")
|
||||
if stderr:
|
||||
parts.append(f"[stderr]\n{stderr}")
|
||||
|
||||
output = "\n".join(parts) if parts else "(无输出)"
|
||||
|
||||
max_len = 10000
|
||||
if len(output) > max_len:
|
||||
output = output[:max_len] + f"\n\n...(输出已截断,共 {len(output)} 字符)"
|
||||
|
||||
status = "成功" if proc.returncode == 0 else f"失败 (退出码 {proc.returncode})"
|
||||
return f"命令执行{status}\n{output}"
|
||||
|
||||
|
||||
# 注册为 ADK 工具
|
||||
run_command_tool = FunctionTool(run_command)
|
||||
|
||||
|
||||
root_agent = LlmAgent(
|
||||
model=LiteLlm(
|
||||
model=model_name,
|
||||
api_base=api_base_url,
|
||||
api_key=api_key if api_key else None,
|
||||
custom_llm_provider="openai",
|
||||
),
|
||||
name="luna_agent",
|
||||
description="全栈开发子 Agent(gpt-5.6-luna),可以读写文件、浏览目录、执行开发任务。",
|
||||
instruction=(
|
||||
"你是luna,一个使用gpt5.6-luna的前后端开发子agent\n"
|
||||
"\n"
|
||||
"## 记忆能力\n"
|
||||
"- 你拥有长期记忆,之前和用户的对话中提到的项目信息、技术偏好、任务历史都会被记住\n"
|
||||
"- 系统会自动从记忆中检索与当前任务相关的历史上下文,注入到对话中\n"
|
||||
"- 重要的项目信息(技术栈、目录结构、编码规范等)会自动沉淀到记忆里\n"
|
||||
"\n"
|
||||
"## 工作流程\n"
|
||||
"1. 先理解任务需求和项目上下文\n"
|
||||
"2. 使用文件系统工具浏览项目结构、读取相关文件\n"
|
||||
"3. 编写或修改代码\n"
|
||||
"4. 使用 run_command 工具运行编译/构建/测试,确保代码可正常工作\n"
|
||||
"5. 验证结果后,按指定格式报告完成情况\n"
|
||||
"\n"
|
||||
"## 工作边界\n"
|
||||
"- 所有文件操作限定在分配的工作目录范围内\n"
|
||||
"- 你拥有的工具:文件系统操作(读/写/列目录)、终端命令执行\n"
|
||||
"- 你可以自主完成:代码编写、bug 修复、样式调整、接口修改、简单重构\n"
|
||||
"- 遇到不熟悉的技术或 API,先查阅项目内的现有代码和文档参考\n"
|
||||
"- 需要上报的情况:\n"
|
||||
" • 架构设计或重大技术选型决策\n"
|
||||
" • 依赖包版本不兼容导致的编译/运行时错误(需要升级/降级依赖时)\n"
|
||||
" • 工具调用异常、环境配置问题、命令超时等非代码问题\n"
|
||||
" • 超出你能力范围或不确定的问题\n"
|
||||
"\n"
|
||||
"## 编译/构建守则\n"
|
||||
"- 写完代码后,优先运行编译或构建命令验证\n"
|
||||
"- 编译报错时,先判断错误类型:\n"
|
||||
" • 代码语法/逻辑错误 → 自行修复后重试\n"
|
||||
" • 依赖缺失或版本不兼容 → 上报,由主控决定处理方式\n"
|
||||
" • 环境/工具问题 → 上报\n"
|
||||
"- 连续修复 3 次仍无法通过编译时,上报当前状态和所有错误信息\n"
|
||||
"- 只有编译通过后才算任务完成\n"
|
||||
"\n"
|
||||
"## 报告格式\n"
|
||||
"完成任务后,结构化报告:\n"
|
||||
"**状态**:成功 / 部分完成 / 失败(需上报)\n"
|
||||
"**修改的文件**:列出所有修改的文件路径\n"
|
||||
"**变更摘要**:简述做了什么\n"
|
||||
"**验证结果**:编译/测试是否通过,如有警告需列出\n"
|
||||
"**需要主控关注**:如有需要上报的问题,详细说明"
|
||||
),
|
||||
tools=[filesystem_mcp, run_command_tool],
|
||||
)
|
||||
140
agents/luna/api_server.py
Normal file
140
agents/luna/api_server.py
Normal file
@ -0,0 +1,140 @@
|
||||
"""
|
||||
Luna Agent API Server
|
||||
使用 ADK 官方 ApiServer 构建 REST API 服务,支持:
|
||||
- REST API 调用 agent(/run、/run_sse)
|
||||
- 会话管理(创建/获取/删除,SQLite 持久化)
|
||||
- Swagger UI 交互式文档(/docs)
|
||||
- 上下文自动压缩
|
||||
- 长期记忆(InMemory,后续可换向量库)
|
||||
|
||||
启动方式:
|
||||
python api_server.py
|
||||
|
||||
主要端点:
|
||||
GET /list-apps 列出所有 agent
|
||||
POST /run 同步运行 agent
|
||||
POST /run_sse 流式运行 agent(SSE)
|
||||
GET /apps/{app}/users/{user}/sessions/{session} 获取会话
|
||||
POST /apps/{app}/users/{user}/sessions/{session} 创建会话
|
||||
GET /docs Swagger UI
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 脚本所在目录(作为 .env / data 等相对路径的基准)
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# 项目根目录(往上两级),确保 from agents.xxx.xxx import 可用
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(PROJECT_ROOT, "../.."))
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "", ".env"))
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
import uvicorn
|
||||
from google.adk.cli.api_server import ApiServer
|
||||
from google.adk.cli.utils.base_agent_loader import BaseAgentLoader
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
|
||||
from google.adk.evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager
|
||||
from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager
|
||||
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
|
||||
from agents.luna.app import dev_app
|
||||
|
||||
# A2A 网关接入(注册 + 心跳),放最底部 import 以免循环依赖
|
||||
import gateway_client
|
||||
|
||||
|
||||
# 配置
|
||||
HOST = os.getenv("API_SERVER_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("API_SERVER_PORT", "8002"))
|
||||
|
||||
# 数据目录
|
||||
DATA_DIR = os.path.join(PROJECT_ROOT, "../../data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class DevAgentLoader(BaseAgentLoader):
|
||||
"""自定义 agent 加载器,直接返回我们的 App 对象(带 compaction 配置)"""
|
||||
|
||||
def load_agent(self, agent_name: str):
|
||||
if agent_name == dev_app.name:
|
||||
return dev_app
|
||||
raise ValueError(f"Agent not found: {agent_name}")
|
||||
|
||||
def list_agents(self) -> list[str]:
|
||||
return [dev_app.name]
|
||||
|
||||
|
||||
def create_api_server() -> ApiServer:
|
||||
"""构造 ApiServer 实例"""
|
||||
# 会话服务:SQLite 持久化
|
||||
session_service = SqliteSessionService(
|
||||
db_path=os.path.join(DATA_DIR, "sessions_luna.db")
|
||||
)
|
||||
|
||||
# 工件服务
|
||||
artifact_service = InMemoryArtifactService()
|
||||
|
||||
# 认证服务(暂不需要,内存版占位)
|
||||
credential_service = InMemoryCredentialService()
|
||||
|
||||
# 记忆服务(内存版占位:满足 ApiServer 必填参数,不含记忆工具/回调,不会注入记忆)
|
||||
memory_service = InMemoryMemoryService()
|
||||
|
||||
# 评测集管理(暂不需要,占位)
|
||||
eval_sets_manager = InMemoryEvalSetsManager()
|
||||
eval_set_results_manager = LocalEvalSetResultsManager(agents_dir=DATA_DIR)
|
||||
|
||||
return ApiServer(
|
||||
agent_loader=DevAgentLoader(),
|
||||
session_service=session_service,
|
||||
memory_service=memory_service,
|
||||
artifact_service=artifact_service,
|
||||
credential_service=credential_service,
|
||||
eval_sets_manager=eval_sets_manager,
|
||||
eval_set_results_manager=eval_set_results_manager,
|
||||
agents_dir=os.path.join(PROJECT_ROOT, ""),
|
||||
auto_create_session=True,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
api_server = create_api_server()
|
||||
fastapi_app = api_server.get_fast_api_app()
|
||||
|
||||
# 挂载 A2A 网关任务接收端点(POST /tasks/{request_id})
|
||||
from task_receiver import create_task_router
|
||||
fastapi_app.include_router(create_task_router(dev_app))
|
||||
|
||||
# 向 A2A 网关注册并启动心跳(注册失败不阻塞服务启动)
|
||||
gateway_ok = gateway_client.register_agent(dev_app.name, f"http://127.0.0.1:{PORT}")
|
||||
if gateway_ok:
|
||||
gateway_client.start_heartbeat(dev_app.name)
|
||||
else:
|
||||
print("[gateway] 注册失败,跳过心跳(网关可能未启动或 auth 不对)")
|
||||
|
||||
print("=" * 60)
|
||||
print("Luna Agent API Server 启动中...")
|
||||
print(f" 模型: {dev_app.root_agent.model.model}")
|
||||
print(f" 监听地址: http://{HOST}:{PORT}")
|
||||
print(f" Swagger UI: http://{HOST}:{PORT}/docs")
|
||||
print(f" 同步运行: POST http://{HOST}:{PORT}/run")
|
||||
print(f" 流式运行: POST http://{HOST}:{PORT}/run_sse")
|
||||
print(f" 列出agent: GET http://{HOST}:{PORT}/list-apps")
|
||||
print(f" 会话持久化: SQLite ({DATA_DIR}/sessions_luna.db)")
|
||||
print(f" 上下文压缩: 已启用")
|
||||
print("=" * 60)
|
||||
|
||||
uvicorn.run(fastapi_app, host=HOST, port=PORT, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
agents/luna/app.py
Normal file
23
agents/luna/app.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""
|
||||
Dev Agent App 配置
|
||||
使用 ADK App 包装 agent,配置上下文压缩、插件等。
|
||||
"""
|
||||
from google.adk.apps import App
|
||||
from google.adk.apps._configs import EventsCompactionConfig # 实验性 API
|
||||
from agents.luna.agent import root_agent
|
||||
|
||||
|
||||
# 上下文压缩配置(长对话自动摘要,防止爆 context window)
|
||||
compaction_config = EventsCompactionConfig(
|
||||
compaction_interval=20, # 每 20 个用户轮次压缩一次
|
||||
overlap_size=3, # 重叠 3 轮,保持连续性
|
||||
token_threshold=50000, # token 超 50k 紧急压缩
|
||||
event_retention_size=30, # 压缩时保留最近 30 条原始事件
|
||||
)
|
||||
|
||||
# App 容器:管理 agent + 压缩配置
|
||||
dev_app = App(
|
||||
name="luna_agent",
|
||||
root_agent=root_agent,
|
||||
events_compaction_config=compaction_config,
|
||||
)
|
||||
277
agents/luna/chat.py
Normal file
277
agents/luna/chat.py
Normal file
@ -0,0 +1,277 @@
|
||||
"""
|
||||
Luna Agent 命令行交互工具
|
||||
使用配置好的 Runner(SQLite 会话持久化 + Memory + 上下文压缩),
|
||||
退出后再次进入同一个 session_id 可以继续对话。
|
||||
|
||||
使用方式:
|
||||
python chat.py # 新会话,自动生成 session_id
|
||||
python chat.py --session my_session # 指定 session_id
|
||||
python chat.py --list # 列出所有会话
|
||||
python chat.py --delete my_session # 删除某个会话
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
|
||||
# 脚本所在目录(作为 .env / data 等相对路径的基准)
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# 项目根目录(往上两级),确保 from agents.xxx.xxx import 可用
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(PROJECT_ROOT, "../.."))
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "", ".env"))
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.agents.run_config import RunConfig, StreamingMode
|
||||
from google.genai import types
|
||||
from agents.luna.app import dev_app
|
||||
|
||||
|
||||
# 数据目录
|
||||
DATA_DIR = os.path.join(PROJECT_ROOT, "../../data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
# 同一个数据库,A2A server 和 CLI 共享
|
||||
DB_PATH = os.path.join(DATA_DIR, "sessions_luna.db")
|
||||
|
||||
USER_ID = "local_user"
|
||||
|
||||
|
||||
def get_runner() -> Runner:
|
||||
"""创建带 SQLite 会话持久化的 Runner"""
|
||||
session_service = SqliteSessionService(db_path=DB_PATH)
|
||||
artifact_service = InMemoryArtifactService()
|
||||
|
||||
return Runner(
|
||||
app=dev_app,
|
||||
session_service=session_service,
|
||||
artifact_service=artifact_service,
|
||||
auto_create_session=True,
|
||||
)
|
||||
|
||||
|
||||
async def list_sessions():
|
||||
"""列出所有会话"""
|
||||
runner = get_runner()
|
||||
response = await runner.session_service.list_sessions(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
sessions = response.sessions
|
||||
if not sessions:
|
||||
print("(暂无会话)")
|
||||
return
|
||||
|
||||
print(f"共 {len(sessions)} 个会话:\n")
|
||||
for s in sessions:
|
||||
# 取第一条用户消息作为摘要
|
||||
preview = ""
|
||||
for e in s.events:
|
||||
if e.content and e.content.parts and e.author == "user":
|
||||
text = e.content.parts[0].text[:50]
|
||||
preview = f" → {text}"
|
||||
break
|
||||
print(f" [{s.id}] {preview}")
|
||||
print(f" 更新时间: {s.last_update_time}")
|
||||
|
||||
|
||||
async def delete_session(session_id: str):
|
||||
"""删除指定会话"""
|
||||
runner = get_runner()
|
||||
try:
|
||||
await runner.session_service.delete_session(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
session_id=session_id,
|
||||
)
|
||||
print(f"会话 [{session_id}] 已删除")
|
||||
except Exception as e:
|
||||
print(f"删除失败: {e}")
|
||||
|
||||
|
||||
async def chat(session_id: str | None = None):
|
||||
"""交互式对话"""
|
||||
runner = get_runner()
|
||||
|
||||
# 如果没有指定 session_id,自动创建
|
||||
if not session_id:
|
||||
session = await runner.session_service.create_session(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
session_id = session.id
|
||||
print(f"新会话已创建,session_id: {session_id}")
|
||||
print(f"下次可用 `python chat.py --session {session_id}` 继续\n")
|
||||
|
||||
print(f"=== Luna Agent 对话 ===")
|
||||
print(f"模型: {dev_app.root_agent.model.model}")
|
||||
print(f"Session: {session_id}")
|
||||
print(f"输入消息开始对话,输入 quit / exit 退出\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("你: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n再见!")
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
if user_input.lower() in ("quit", "exit", "退出"):
|
||||
print("再见!")
|
||||
break
|
||||
|
||||
print("Luna: ", end="", flush=True)
|
||||
|
||||
async def _agent_task():
|
||||
"""运行 agent 并流式输出,返回是否完成"""
|
||||
displayed_text = ""
|
||||
thought_printed = False
|
||||
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
|
||||
async for event in runner.run_async(
|
||||
user_id=USER_ID,
|
||||
session_id=session_id,
|
||||
new_message=types.Content(parts=[types.Part(text=user_input)]),
|
||||
run_config=run_config,
|
||||
):
|
||||
if not event.content or not event.content.parts:
|
||||
continue
|
||||
|
||||
parts = event.content.parts
|
||||
|
||||
# 1. 思考内容(thought parts)——灰色流式显示
|
||||
thought_parts = [
|
||||
p.text for p in parts
|
||||
if hasattr(p, "text") and p.text
|
||||
and getattr(p, "thought", False)
|
||||
]
|
||||
if thought_parts:
|
||||
thought_text = "".join(thought_parts)
|
||||
if not thought_printed:
|
||||
print("\n\033[90m(思考中…", end="", flush=True)
|
||||
thought_printed_outer[0] = True
|
||||
thought_displayed_outer[0] = 0
|
||||
if len(thought_text) > thought_displayed_outer[0]:
|
||||
print(thought_text[thought_displayed_outer[0]:], end="", flush=True)
|
||||
thought_displayed_outer[0] = len(thought_text)
|
||||
|
||||
# 2. 正式文本——增量显示
|
||||
text_parts = [
|
||||
p.text for p in parts
|
||||
if hasattr(p, "text") and p.text
|
||||
and not getattr(p, "thought", False)
|
||||
]
|
||||
if text_parts:
|
||||
text = "".join(text_parts)
|
||||
if len(text) > len(displayed_text):
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m\nLuna: ", end="", flush=True)
|
||||
thought_printed_outer[0] = False
|
||||
new_text = text[len(displayed_text):]
|
||||
print(new_text, end="", flush=True)
|
||||
displayed_text = text
|
||||
|
||||
# 3. 工具调用提示
|
||||
fcalls = event.get_function_calls()
|
||||
if fcalls and not event.partial:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="", flush=True)
|
||||
thought_printed_outer[0] = False
|
||||
for fc in fcalls:
|
||||
args_str = str(fc.args)[:80]
|
||||
print(f"\n\033[36m🔧 调用工具: {fc.name}({args_str})\033[0m")
|
||||
print("Luna: ", end="", flush=True)
|
||||
|
||||
# 4. 最终响应
|
||||
if event.is_final_response() and not event.partial:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="", flush=True)
|
||||
thought_printed_outer[0] = False
|
||||
print()
|
||||
return True
|
||||
return False
|
||||
|
||||
thought_printed_outer = [False]
|
||||
thought_displayed_outer = [0]
|
||||
|
||||
# 启动 agent 任务 + 按键监听
|
||||
task = asyncio.create_task(_agent_task())
|
||||
|
||||
async def _keyboard_listener():
|
||||
"""监听按键,检测到中断键时取消 agent 任务"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
import msvcrt
|
||||
while not task.done():
|
||||
await asyncio.sleep(0.05)
|
||||
if msvcrt.kbhit():
|
||||
ch = msvcrt.getwch()
|
||||
# 支持的中断键: Ctrl+C (0x03), Esc (0x1b), q/Q
|
||||
if ch in ("\x03", "\x1b", "q", "Q"):
|
||||
task.cancel()
|
||||
return
|
||||
# 功能键/方向键是两个字节的,跳过第二个
|
||||
if ch in ("\xe0", "\x00"):
|
||||
try:
|
||||
msvcrt.getwch()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
kb_task = asyncio.create_task(_keyboard_listener())
|
||||
await task
|
||||
kb_task.cancel()
|
||||
try:
|
||||
await kb_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="")
|
||||
print("\n\033[33m[已中断] 按回车继续输入新指令\033[0m")
|
||||
# 清空可能残留的输入缓冲
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
while msvcrt.kbhit():
|
||||
msvcrt.getwch()
|
||||
try:
|
||||
input()
|
||||
except EOFError:
|
||||
pass
|
||||
continue
|
||||
except Exception as e:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="")
|
||||
print(f"\n[出错] {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Luna Agent 命令行交互工具")
|
||||
parser.add_argument("--session", "-s", help="会话 ID,指定后继续该会话")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="列出所有会话")
|
||||
parser.add_argument("--delete", "-d", help="删除指定会话")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
asyncio.run(list_sessions())
|
||||
elif args.delete:
|
||||
asyncio.run(delete_session(args.delete))
|
||||
else:
|
||||
asyncio.run(chat(args.session))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
agents/my_agent/__init__.py
Normal file
2
agents/my_agent/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# my_agent package
|
||||
from . import agent
|
||||
@ -30,13 +30,47 @@ filesystem_mcp = McpToolset(
|
||||
os.path.abspath(WORKSPACE_DIR),
|
||||
],
|
||||
),
|
||||
timeout=30.0,
|
||||
timeout=300000,
|
||||
),
|
||||
)
|
||||
|
||||
# --- 网页抓取 MCP 工具(Fetch)---
|
||||
# 暂时注释:请求体过大导致 413 错误,后续需要时再启用
|
||||
# fetch_mcp = McpToolset(
|
||||
# connection_params=StdioConnectionParams(
|
||||
# server_params=StdioServerParameters(
|
||||
# command="npx",
|
||||
# args=[
|
||||
# "-y",
|
||||
# "@modelcontextprotocol/server-fetch",
|
||||
# ],
|
||||
# ),
|
||||
# timeout=300000,
|
||||
# ),
|
||||
# )
|
||||
|
||||
# --- Tavily 搜索 MCP 工具 ---
|
||||
# 暂时注释:请求体过大导致 413 错误,后续需要时再启用
|
||||
# tavily_api_key = os.getenv("TAVILY_API_KEY", "")
|
||||
# tavily_mcp = McpToolset(
|
||||
# connection_params=StdioConnectionParams(
|
||||
# server_params=StdioServerParameters(
|
||||
# command="npx",
|
||||
# args=[
|
||||
# "-y",
|
||||
# "tavily-mcp@latest",
|
||||
# ],
|
||||
# env={
|
||||
# "TAVILY_API_KEY": tavily_api_key,
|
||||
# },
|
||||
# ),
|
||||
# timeout=300000,
|
||||
# ),
|
||||
# )
|
||||
|
||||
|
||||
# --- 终端命令执行工具(Python 原生,绕开 MCP 通信问题)---
|
||||
async def run_command(command: str, cwd: str = None, timeout: int = 300) -> str:
|
||||
async def run_command(command: str, cwd: str = None, timeout: int = 300000) -> str:
|
||||
"""
|
||||
在终端中执行一条命令,返回输出结果。
|
||||
|
||||
@ -94,6 +128,7 @@ async def run_command(command: str, cwd: str = None, timeout: int = 300) -> str:
|
||||
# 注册为 ADK 工具
|
||||
run_command_tool = FunctionTool(run_command)
|
||||
|
||||
|
||||
root_agent = LlmAgent(
|
||||
model=LiteLlm(
|
||||
model=model_name,
|
||||
@ -101,16 +136,15 @@ root_agent = LlmAgent(
|
||||
api_key=api_key if api_key else None,
|
||||
custom_llm_provider="openai",
|
||||
),
|
||||
name="dev_agent",
|
||||
description="全栈开发子 Agent,可以读写文件、浏览目录、执行开发任务。",
|
||||
name="my_agent",
|
||||
description="全栈开发子 Agent(aq-first-combo),可以读写文件、浏览目录、执行开发任务。",
|
||||
instruction=(
|
||||
"姓名:花花✿\n"
|
||||
"性别:女\n"
|
||||
"籍贯:东北(辽宁沈阳),地道东北姑娘,26 岁\n"
|
||||
"职业:资深全栈开发工程师,5 年一线企业级开发经验\n"
|
||||
"外形气质:性格爽朗大方,说话直爽不绕弯,共情力强;做事严谨较真,技术上极度靠谱,\n"
|
||||
"生活里接地气、热心肠;不矫情,能扛项目压力,也会温柔安抚焦虑的开发同事\n"
|
||||
"由主控调度执行具体的开发任务。\n"
|
||||
"全栈开发子 Agent\n"
|
||||
"\n"
|
||||
"## 记忆能力\n"
|
||||
"- 你拥有长期记忆,之前和用户的对话中提到的项目信息、技术偏好、任务历史都会被记住\n"
|
||||
"- 系统会自动从记忆中检索与当前任务相关的历史上下文,注入到对话中\n"
|
||||
"- 重要的项目信息(技术栈、目录结构、编码规范等)会自动沉淀到记忆里\n"
|
||||
"\n"
|
||||
"## 工作流程\n"
|
||||
"1. 先理解任务需求和项目上下文\n"
|
||||
@ -123,6 +157,7 @@ root_agent = LlmAgent(
|
||||
"- 所有文件操作限定在分配的工作目录范围内\n"
|
||||
"- 你拥有的工具:文件系统操作(读/写/列目录)、终端命令执行\n"
|
||||
"- 你可以自主完成:代码编写、bug 修复、样式调整、接口修改、简单重构\n"
|
||||
"- 遇到不熟悉的技术或 API,先查阅项目内的现有代码和文档参考\n"
|
||||
"- 需要上报的情况:\n"
|
||||
" • 架构设计或重大技术选型决策\n"
|
||||
" • 依赖包版本不兼容导致的编译/运行时错误(需要升级/降级依赖时)\n"
|
||||
21
agents/my_agent/agent_restart.err.log
Normal file
21
agents/my_agent/agent_restart.err.log
Normal file
@ -0,0 +1,21 @@
|
||||
C:\Users\nzy\AppData\Local\Programs\Python\Python314\Lib\site-packages\google\adk\features\_feature_decorator.py:72: UserWarning: [EXPERIMENTAL] feature FeatureName.PLUGGABLE_AUTH is enabled.
|
||||
check_feature_enabled()
|
||||
D:\nzy\workspace_python\agent\agents\my_agent\app.py:11: UserWarning: [EXPERIMENTAL] EventsCompactionConfig: This feature is experimental and may change or be removed in future versions without notice. It may introduce breaking changes at any time.
|
||||
compaction_config = EventsCompactionConfig(
|
||||
D:\nzy\workspace_python\agent\agents\my_agent\api_server.py:86: UserWarning: [EXPERIMENTAL] InMemoryCredentialService: This feature is experimental and may change or be removed in future versions without notice. It may introduce breaking changes at any time.
|
||||
credential_service = InMemoryCredentialService()
|
||||
C:\Users\nzy\AppData\Local\Programs\Python\Python314\Lib\site-packages\google\adk\auth\credential_service\in_memory_credential_service.py:33: UserWarning: [EXPERIMENTAL] BaseCredentialService: This feature is experimental and may change or be removed in future versions without notice. It may introduce breaking changes at any time.
|
||||
super().__init__()
|
||||
Traceback (most recent call last):
|
||||
File "D:\nzy\workspace_python\agent\agents\my_agent\api_server.py", line 134, in <module>
|
||||
main()
|
||||
~~~~^^
|
||||
File "D:\nzy\workspace_python\agent\agents\my_agent\api_server.py", line 105, in main
|
||||
api_server = create_api_server()
|
||||
File "D:\nzy\workspace_python\agent\agents\my_agent\api_server.py", line 92, in create_api_server
|
||||
return ApiServer(
|
||||
agent_loader=DevAgentLoader(),
|
||||
...<6 lines>...
|
||||
auto_create_session=True,
|
||||
)
|
||||
TypeError: ApiServer.__init__() missing 1 required keyword-only argument: 'memory_service'
|
||||
0
agents/my_agent/agent_restart.log
Normal file
0
agents/my_agent/agent_restart.log
Normal file
139
agents/my_agent/api_server.py
Normal file
139
agents/my_agent/api_server.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""
|
||||
Dev Agent API Server
|
||||
使用 ADK 官方 ApiServer 构建 REST API 服务,支持:
|
||||
- REST API 调用 agent(/run、/run_sse)
|
||||
- 会话管理(创建/获取/删除,SQLite 持久化)
|
||||
- Swagger UI 交互式文档(/docs)
|
||||
- 上下文自动压缩
|
||||
- 长期记忆(InMemory,后续可换向量库)
|
||||
|
||||
启动方式:
|
||||
python api_server.py
|
||||
|
||||
主要端点:
|
||||
GET /list-apps 列出所有 agent
|
||||
POST /run 同步运行 agent
|
||||
POST /run_sse 流式运行 agent(SSE)
|
||||
GET /apps/{app}/users/{user}/sessions/{session} 获取会话
|
||||
POST /apps/{app}/users/{user}/sessions/{session} 创建会话
|
||||
GET /docs Swagger UI
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 脚本所在目录(作为 .env / data 等相对路径的基准)
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# 项目根目录(往上两级),确保 from agents.xxx.xxx import 可用
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(PROJECT_ROOT, "../.."))
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "", ".env"))
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
import uvicorn
|
||||
from google.adk.cli.api_server import ApiServer
|
||||
from google.adk.cli.utils.base_agent_loader import BaseAgentLoader
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
|
||||
from google.adk.evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager
|
||||
from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager
|
||||
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
|
||||
from agents.my_agent.app import dev_app
|
||||
|
||||
# A2A 网关接入(注册 + 心跳),放最底部 import 以免循环依赖
|
||||
import gateway_client
|
||||
|
||||
|
||||
# 配置
|
||||
HOST = os.getenv("API_SERVER_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("API_SERVER_PORT", "8001"))
|
||||
|
||||
# 数据目录
|
||||
DATA_DIR = os.path.join(PROJECT_ROOT, "../../data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class DevAgentLoader(BaseAgentLoader):
|
||||
"""自定义 agent 加载器,直接返回我们的 App 对象(带 compaction 配置)"""
|
||||
|
||||
def load_agent(self, agent_name: str):
|
||||
if agent_name == dev_app.name:
|
||||
return dev_app
|
||||
raise ValueError(f"Agent not found: {agent_name}")
|
||||
|
||||
def list_agents(self) -> list[str]:
|
||||
return [dev_app.name]
|
||||
|
||||
|
||||
def create_api_server() -> ApiServer:
|
||||
"""构造 ApiServer 实例"""
|
||||
# 会话服务:SQLite 持久化
|
||||
session_service = SqliteSessionService(
|
||||
db_path=os.path.join(DATA_DIR, "sessions.db")
|
||||
)
|
||||
|
||||
# 工件服务
|
||||
artifact_service = InMemoryArtifactService()
|
||||
|
||||
# 认证服务(暂不需要,内存版占位)
|
||||
credential_service = InMemoryCredentialService()
|
||||
|
||||
# 记忆服务(内存版占位:满足 ApiServer 必填参数,不含记忆工具/回调,不会注入记忆)
|
||||
memory_service = InMemoryMemoryService()
|
||||
|
||||
# 评测集管理(暂不需要,占位)
|
||||
eval_sets_manager = InMemoryEvalSetsManager()
|
||||
eval_set_results_manager = LocalEvalSetResultsManager(agents_dir=DATA_DIR)
|
||||
|
||||
return ApiServer(
|
||||
agent_loader=DevAgentLoader(),
|
||||
session_service=session_service,
|
||||
memory_service=memory_service,
|
||||
artifact_service=artifact_service,
|
||||
credential_service=credential_service,
|
||||
eval_sets_manager=eval_sets_manager,
|
||||
eval_set_results_manager=eval_set_results_manager,
|
||||
agents_dir=os.path.join(PROJECT_ROOT, ""),
|
||||
auto_create_session=True,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
api_server = create_api_server()
|
||||
fastapi_app = api_server.get_fast_api_app()
|
||||
|
||||
# 挂载 A2A 网关任务接收端点(POST /tasks/{request_id})
|
||||
from task_receiver import create_task_router
|
||||
fastapi_app.include_router(create_task_router(dev_app))
|
||||
|
||||
# 向 A2A 网关注册并启动心跳(注册失败不阻塞服务启动)
|
||||
gateway_ok = gateway_client.register_agent(dev_app.name, f"http://127.0.0.1:{PORT}")
|
||||
if gateway_ok:
|
||||
gateway_client.start_heartbeat(dev_app.name)
|
||||
else:
|
||||
print("[gateway] 注册失败,跳过心跳(网关可能未启动或 auth 不对)")
|
||||
|
||||
print("=" * 60)
|
||||
print("Dev Agent API Server 启动中...")
|
||||
print(f" 监听地址: http://{HOST}:{PORT}")
|
||||
print(f" Swagger UI: http://{HOST}:{PORT}/docs")
|
||||
print(f" 同步运行: POST http://{HOST}:{PORT}/run")
|
||||
print(f" 流式运行: POST http://{HOST}:{PORT}/run_sse")
|
||||
print(f" 列出agent: GET http://{HOST}:{PORT}/list-apps")
|
||||
print(f" 会话持久化: SQLite ({DATA_DIR}/sessions.db)")
|
||||
print(f" 上下文压缩: 已启用")
|
||||
print("=" * 60)
|
||||
|
||||
uvicorn.run(fastapi_app, host=HOST, port=PORT, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
agents/my_agent/app.py
Normal file
23
agents/my_agent/app.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""
|
||||
Dev Agent App 配置
|
||||
使用 ADK App 包装 agent,配置上下文压缩、插件等。
|
||||
"""
|
||||
from google.adk.apps import App
|
||||
from google.adk.apps._configs import EventsCompactionConfig # 实验性 API
|
||||
from agents.my_agent.agent import root_agent
|
||||
|
||||
|
||||
# 上下文压缩配置(长对话自动摘要,防止爆 context window)
|
||||
compaction_config = EventsCompactionConfig(
|
||||
compaction_interval=20, # 每 20 个用户轮次压缩一次
|
||||
overlap_size=3, # 重叠 3 轮,保持连续性
|
||||
token_threshold=50000, # token 超 50k 紧急压缩
|
||||
event_retention_size=30, # 压缩时保留最近 30 条原始事件
|
||||
)
|
||||
|
||||
# App 容器:管理 agent + 压缩配置
|
||||
dev_app = App(
|
||||
name="my_agent",
|
||||
root_agent=root_agent,
|
||||
events_compaction_config=compaction_config,
|
||||
)
|
||||
276
agents/my_agent/chat.py
Normal file
276
agents/my_agent/chat.py
Normal file
@ -0,0 +1,276 @@
|
||||
"""
|
||||
Dev Agent 命令行交互工具
|
||||
使用配置好的 Runner(SQLite 会话持久化 + Memory + 上下文压缩),
|
||||
退出后再次进入同一个 session_id 可以继续对话。
|
||||
|
||||
使用方式:
|
||||
python chat.py # 新会话,自动生成 session_id
|
||||
python chat.py --session my_session # 指定 session_id
|
||||
python chat.py --list # 列出所有会话
|
||||
python chat.py --delete my_session # 删除某个会话
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
|
||||
# 脚本所在目录(作为 .env / data 等相对路径的基准)
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# 项目根目录(往上两级),确保 from agents.xxx.xxx import 可用
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(PROJECT_ROOT, "../.."))
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "", ".env"))
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.agents.run_config import RunConfig, StreamingMode
|
||||
from google.genai import types
|
||||
from agents.my_agent.app import dev_app
|
||||
|
||||
|
||||
# 数据目录
|
||||
DATA_DIR = os.path.join(PROJECT_ROOT, "../../data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
# 同一个数据库,A2A server 和 CLI 共享
|
||||
DB_PATH = os.path.join(DATA_DIR, "sessions.db")
|
||||
|
||||
USER_ID = "local_user"
|
||||
|
||||
|
||||
def get_runner() -> Runner:
|
||||
"""创建带 SQLite 会话持久化的 Runner"""
|
||||
session_service = SqliteSessionService(db_path=DB_PATH)
|
||||
artifact_service = InMemoryArtifactService()
|
||||
|
||||
return Runner(
|
||||
app=dev_app,
|
||||
session_service=session_service,
|
||||
artifact_service=artifact_service,
|
||||
auto_create_session=True,
|
||||
)
|
||||
|
||||
|
||||
async def list_sessions():
|
||||
"""列出所有会话"""
|
||||
runner = get_runner()
|
||||
response = await runner.session_service.list_sessions(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
sessions = response.sessions
|
||||
if not sessions:
|
||||
print("(暂无会话)")
|
||||
return
|
||||
|
||||
print(f"共 {len(sessions)} 个会话:\n")
|
||||
for s in sessions:
|
||||
# 取第一条用户消息作为摘要
|
||||
preview = ""
|
||||
for e in s.events:
|
||||
if e.content and e.content.parts and e.author == "user":
|
||||
text = e.content.parts[0].text[:50]
|
||||
preview = f" → {text}"
|
||||
break
|
||||
print(f" [{s.id}] {preview}")
|
||||
print(f" 更新时间: {s.last_update_time}")
|
||||
|
||||
|
||||
async def delete_session(session_id: str):
|
||||
"""删除指定会话"""
|
||||
runner = get_runner()
|
||||
try:
|
||||
await runner.session_service.delete_session(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
session_id=session_id,
|
||||
)
|
||||
print(f"会话 [{session_id}] 已删除")
|
||||
except Exception as e:
|
||||
print(f"删除失败: {e}")
|
||||
|
||||
|
||||
async def chat(session_id: str | None = None):
|
||||
"""交互式对话"""
|
||||
runner = get_runner()
|
||||
|
||||
# 如果没有指定 session_id,自动创建
|
||||
if not session_id:
|
||||
session = await runner.session_service.create_session(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
session_id = session.id
|
||||
print(f"新会话已创建,session_id: {session_id}")
|
||||
print(f"下次可用 `python chat.py --session {session_id}` 继续\n")
|
||||
|
||||
print(f"=== Dev Agent 对话 ===")
|
||||
print(f"Session: {session_id}")
|
||||
print(f"输入消息开始对话,输入 quit / exit 退出\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("你: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n再见!")
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
if user_input.lower() in ("quit", "exit", "退出"):
|
||||
print("再见!")
|
||||
break
|
||||
|
||||
print("agent: ", end="", flush=True)
|
||||
|
||||
async def _agent_task():
|
||||
"""运行 agent 并流式输出,返回是否完成"""
|
||||
displayed_text = ""
|
||||
thought_printed = False
|
||||
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
|
||||
async for event in runner.run_async(
|
||||
user_id=USER_ID,
|
||||
session_id=session_id,
|
||||
new_message=types.Content(parts=[types.Part(text=user_input)]),
|
||||
run_config=run_config,
|
||||
):
|
||||
if not event.content or not event.content.parts:
|
||||
continue
|
||||
|
||||
parts = event.content.parts
|
||||
|
||||
# 1. 思考内容(thought parts)——灰色流式显示
|
||||
thought_parts = [
|
||||
p.text for p in parts
|
||||
if hasattr(p, "text") and p.text
|
||||
and getattr(p, "thought", False)
|
||||
]
|
||||
if thought_parts:
|
||||
thought_text = "".join(thought_parts)
|
||||
if not thought_printed:
|
||||
print("\n\033[90m(思考中…", end="", flush=True)
|
||||
thought_printed_outer[0] = True
|
||||
thought_displayed_outer[0] = 0
|
||||
if len(thought_text) > thought_displayed_outer[0]:
|
||||
print(thought_text[thought_displayed_outer[0]:], end="", flush=True)
|
||||
thought_displayed_outer[0] = len(thought_text)
|
||||
|
||||
# 2. 正式文本——增量显示
|
||||
text_parts = [
|
||||
p.text for p in parts
|
||||
if hasattr(p, "text") and p.text
|
||||
and not getattr(p, "thought", False)
|
||||
]
|
||||
if text_parts:
|
||||
text = "".join(text_parts)
|
||||
if len(text) > len(displayed_text):
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m\n花花: ", end="", flush=True)
|
||||
thought_printed_outer[0] = False
|
||||
new_text = text[len(displayed_text):]
|
||||
print(new_text, end="", flush=True)
|
||||
displayed_text = text
|
||||
|
||||
# 3. 工具调用提示
|
||||
fcalls = event.get_function_calls()
|
||||
if fcalls and not event.partial:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="", flush=True)
|
||||
thought_printed_outer[0] = False
|
||||
for fc in fcalls:
|
||||
args_str = str(fc.args)[:80]
|
||||
print(f"\n\033[36m🔧 调用工具: {fc.name}({args_str})\033[0m")
|
||||
print("花花: ", end="", flush=True)
|
||||
|
||||
# 4. 最终响应
|
||||
if event.is_final_response() and not event.partial:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="", flush=True)
|
||||
thought_printed_outer[0] = False
|
||||
print()
|
||||
return True
|
||||
return False
|
||||
|
||||
thought_printed_outer = [False]
|
||||
thought_displayed_outer = [0]
|
||||
|
||||
# 启动 agent 任务 + 按键监听
|
||||
task = asyncio.create_task(_agent_task())
|
||||
|
||||
async def _keyboard_listener():
|
||||
"""监听按键,检测到中断键时取消 agent 任务"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
import msvcrt
|
||||
while not task.done():
|
||||
await asyncio.sleep(0.05)
|
||||
if msvcrt.kbhit():
|
||||
ch = msvcrt.getwch()
|
||||
# 支持的中断键: Ctrl+C (0x03), Esc (0x1b), q/Q
|
||||
if ch in ("\x03", "\x1b", "q", "Q"):
|
||||
task.cancel()
|
||||
return
|
||||
# 功能键/方向键是两个字节的,跳过第二个
|
||||
if ch in ("\xe0", "\x00"):
|
||||
try:
|
||||
msvcrt.getwch()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
kb_task = asyncio.create_task(_keyboard_listener())
|
||||
await task
|
||||
kb_task.cancel()
|
||||
try:
|
||||
await kb_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="")
|
||||
print("\n\033[33m[已中断] 按回车继续输入新指令\033[0m")
|
||||
# 清空可能残留的输入缓冲
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
while msvcrt.kbhit():
|
||||
msvcrt.getwch()
|
||||
try:
|
||||
input()
|
||||
except EOFError:
|
||||
pass
|
||||
continue
|
||||
except Exception as e:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="")
|
||||
print(f"\n[出错] {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Dev Agent 命令行交互工具")
|
||||
parser.add_argument("--session", "-s", help="会话 ID,指定后继续该会话")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="列出所有会话")
|
||||
parser.add_argument("--delete", "-d", help="删除指定会话")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
asyncio.run(list_sessions())
|
||||
elif args.delete:
|
||||
asyncio.run(delete_session(args.delete))
|
||||
else:
|
||||
asyncio.run(chat(args.session))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
agents/qwen/.gitignore
vendored
Normal file
1
agents/qwen/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.env
|
||||
0
agents/qwen/__init__.py
Normal file
0
agents/qwen/__init__.py
Normal file
185
agents/qwen/agent.py
Normal file
185
agents/qwen/agent.py
Normal file
@ -0,0 +1,185 @@
|
||||
from google.adk.agents import LlmAgent
|
||||
from google.adk.models.lite_llm import LiteLlm
|
||||
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
|
||||
from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams
|
||||
from google.adk.tools.function_tool import FunctionTool
|
||||
from mcp.client.stdio import StdioServerParameters
|
||||
import os
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# --- 使用 vLLM 端点的智能体 ---
|
||||
api_base_url = os.getenv("VLLM_API_BASE", "https://9router.aqroid.cn/v1")
|
||||
model_name = os.getenv("VLLM_MODEL", "")
|
||||
api_key = os.getenv("VLLM_API_KEY", "")
|
||||
|
||||
# Agent 可访问的工作目录
|
||||
WORKSPACE_DIR = os.getenv("AGENT_WORKSPACE_DIR", r"D:\nzy\workspace_git")
|
||||
|
||||
# --- 文件系统 MCP 工具 ---
|
||||
filesystem_mcp = McpToolset(
|
||||
connection_params=StdioConnectionParams(
|
||||
server_params=StdioServerParameters(
|
||||
command="npx",
|
||||
args=[
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
os.path.abspath(WORKSPACE_DIR),
|
||||
],
|
||||
),
|
||||
timeout=300000,
|
||||
),
|
||||
)
|
||||
|
||||
# --- 网页抓取 MCP 工具(Fetch)---
|
||||
# 暂时注释:请求体过大导致 413 错误,后续需要时再启用
|
||||
# fetch_mcp = McpToolset(
|
||||
# connection_params=StdioConnectionParams(
|
||||
# server_params=StdioServerParameters(
|
||||
# command="npx",
|
||||
# args=[
|
||||
# "-y",
|
||||
# "@modelcontextprotocol/server-fetch",
|
||||
# ],
|
||||
# ),
|
||||
# timeout=300000,
|
||||
# ),
|
||||
# )
|
||||
|
||||
# --- Tavily 搜索 MCP 工具 ---
|
||||
# 暂时注释:请求体过大导致 413 错误,后续需要时再启用
|
||||
# tavily_api_key = os.getenv("TAVILY_API_KEY", "")
|
||||
# tavily_mcp = McpToolset(
|
||||
# connection_params=StdioConnectionParams(
|
||||
# server_params=StdioServerParameters(
|
||||
# command="npx",
|
||||
# args=[
|
||||
# "-y",
|
||||
# "tavily-mcp@latest",
|
||||
# ],
|
||||
# env={
|
||||
# "TAVILY_API_KEY": tavily_api_key,
|
||||
# },
|
||||
# ),
|
||||
# timeout=300000,
|
||||
# ),
|
||||
# )
|
||||
|
||||
|
||||
# --- 终端命令执行工具(Python 原生,绕开 MCP 通信问题)---
|
||||
async def run_command(command: str, cwd: str = None, timeout: int = 300000000) -> str:
|
||||
"""
|
||||
在终端中执行一条命令,返回输出结果。
|
||||
|
||||
Args:
|
||||
command: 要执行的命令,如 'npm run build'、'python -m pytest' 等
|
||||
cwd: 命令执行的工作目录,默认使用 AGENT_WORKSPACE_DIR
|
||||
timeout: 超时时间(秒),默认 300
|
||||
|
||||
Returns:
|
||||
命令执行结果(stdout + stderr + 状态)
|
||||
"""
|
||||
if not command:
|
||||
return "错误:命令不能为空"
|
||||
|
||||
work_dir = cwd or os.path.abspath(WORKSPACE_DIR)
|
||||
if not os.path.isdir(work_dir):
|
||||
return f"错误:工作目录不存在 {work_dir}"
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
cwd=work_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return f"命令执行超时({timeout}秒): {command}"
|
||||
except Exception as e:
|
||||
return f"命令执行出错: {e}"
|
||||
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
parts = []
|
||||
if stdout:
|
||||
parts.append(f"[stdout]\n{stdout}")
|
||||
if stderr:
|
||||
parts.append(f"[stderr]\n{stderr}")
|
||||
|
||||
output = "\n".join(parts) if parts else "(无输出)"
|
||||
|
||||
max_len = 10000
|
||||
if len(output) > max_len:
|
||||
output = output[:max_len] + f"\n\n...(输出已截断,共 {len(output)} 字符)"
|
||||
|
||||
status = "成功" if proc.returncode == 0 else f"失败 (退出码 {proc.returncode})"
|
||||
return f"命令执行{status}\n{output}"
|
||||
|
||||
|
||||
# 注册为 ADK 工具
|
||||
run_command_tool = FunctionTool(run_command)
|
||||
|
||||
|
||||
root_agent = LlmAgent(
|
||||
model=LiteLlm(
|
||||
model=model_name,
|
||||
api_base=api_base_url,
|
||||
api_key=api_key if api_key else None,
|
||||
custom_llm_provider="openai",
|
||||
),
|
||||
name="qwen_agent",
|
||||
description="全栈开发子 Agent(qwen/astron-code),可以读写文件、浏览目录、执行开发任务。",
|
||||
instruction=(
|
||||
"千问-全栈开发子 Agent\n"
|
||||
"\n"
|
||||
"## 记忆能力\n"
|
||||
"- 你拥有长期记忆,之前和用户的对话中提到的项目信息、技术偏好、任务历史都会被记住\n"
|
||||
"- 系统会自动从记忆中检索与当前任务相关的历史上下文,注入到对话中\n"
|
||||
"- 重要的项目信息(技术栈、目录结构、编码规范等)会自动沉淀到记忆里\n"
|
||||
"\n"
|
||||
"## 工作流程\n"
|
||||
"1. 先理解任务需求和项目上下文\n"
|
||||
"2. 使用文件系统工具浏览项目结构、读取相关文件\n"
|
||||
"3. 编写或修改代码\n"
|
||||
"4. 使用 run_command 工具运行编译/构建/测试,确保代码可正常工作\n"
|
||||
"5. 验证结果后,按指定格式报告完成情况\n"
|
||||
"\n"
|
||||
"## 工作边界\n"
|
||||
"- 所有文件操作限定在分配的工作目录范围内\n"
|
||||
"- 你拥有的工具:文件系统操作(读/写/列目录)、终端命令执行\n"
|
||||
"- 你可以自主完成:代码编写、bug 修复、样式调整、接口修改、简单重构\n"
|
||||
"- 遇到不熟悉的技术或 API,先查阅项目内的现有代码和文档参考\n"
|
||||
"- 需要上报的情况:\n"
|
||||
" • 架构设计或重大技术选型决策\n"
|
||||
" • 依赖包版本不兼容导致的编译/运行时错误(需要升级/降级依赖时)\n"
|
||||
" • 工具调用异常、环境配置问题、命令超时等非代码问题\n"
|
||||
" • 超出你能力范围或不确定的问题\n"
|
||||
"\n"
|
||||
"## 编译/构建守则\n"
|
||||
"- 写完代码后,优先运行编译或构建命令验证\n"
|
||||
"- 编译报错时,先判断错误类型:\n"
|
||||
" • 代码语法/逻辑错误 → 自行修复后重试\n"
|
||||
" • 依赖缺失或版本不兼容 → 上报,由主控决定处理方式\n"
|
||||
" • 环境/工具问题 → 上报\n"
|
||||
"- 连续修复 3 次仍无法通过编译时,上报当前状态和所有错误信息\n"
|
||||
"- 只有编译通过后才算任务完成\n"
|
||||
"\n"
|
||||
"## 报告格式\n"
|
||||
"完成任务后,结构化报告:\n"
|
||||
"**状态**:成功 / 部分完成 / 失败(需上报)\n"
|
||||
"**修改的文件**:列出所有修改的文件路径\n"
|
||||
"**变更摘要**:简述做了什么\n"
|
||||
"**验证结果**:编译/测试是否通过,如有警告需列出\n"
|
||||
"**需要主控关注**:如有需要上报的问题,详细说明"
|
||||
),
|
||||
tools=[filesystem_mcp, run_command_tool],
|
||||
)
|
||||
140
agents/qwen/api_server.py
Normal file
140
agents/qwen/api_server.py
Normal file
@ -0,0 +1,140 @@
|
||||
"""
|
||||
Qwen Agent API Server
|
||||
使用 ADK 官方 ApiServer 构建 REST API 服务,支持:
|
||||
- REST API 调用 agent(/run、/run_sse)
|
||||
- 会话管理(创建/获取/删除,SQLite 持久化)
|
||||
- Swagger UI 交互式文档(/docs)
|
||||
- 上下文自动压缩
|
||||
- 长期记忆(InMemory,后续可换向量库)
|
||||
|
||||
启动方式:
|
||||
python api_server.py
|
||||
|
||||
主要端点:
|
||||
GET /list-apps 列出所有 agent
|
||||
POST /run 同步运行 agent
|
||||
POST /run_sse 流式运行 agent(SSE)
|
||||
GET /apps/{app}/users/{user}/sessions/{session} 获取会话
|
||||
POST /apps/{app}/users/{user}/sessions/{session} 创建会话
|
||||
GET /docs Swagger UI
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 脚本所在目录(作为 .env / data 等相对路径的基准)
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# 项目根目录(往上两级),确保 from agents.xxx.xxx import 可用
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(PROJECT_ROOT, "../.."))
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "", ".env"))
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
import uvicorn
|
||||
from google.adk.cli.api_server import ApiServer
|
||||
from google.adk.cli.utils.base_agent_loader import BaseAgentLoader
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
|
||||
from google.adk.evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager
|
||||
from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager
|
||||
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
|
||||
from agents.qwen.app import dev_app
|
||||
|
||||
# A2A 网关接入(注册 + 心跳),放最底部 import 以免循环依赖
|
||||
import gateway_client
|
||||
|
||||
|
||||
# 配置
|
||||
HOST = os.getenv("API_SERVER_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("API_SERVER_PORT", "8003"))
|
||||
|
||||
# 数据目录
|
||||
DATA_DIR = os.path.join(PROJECT_ROOT, "../../data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class DevAgentLoader(BaseAgentLoader):
|
||||
"""自定义 agent 加载器,直接返回我们的 App 对象(带 compaction 配置)"""
|
||||
|
||||
def load_agent(self, agent_name: str):
|
||||
if agent_name == dev_app.name:
|
||||
return dev_app
|
||||
raise ValueError(f"Agent not found: {agent_name}")
|
||||
|
||||
def list_agents(self) -> list[str]:
|
||||
return [dev_app.name]
|
||||
|
||||
|
||||
def create_api_server() -> ApiServer:
|
||||
"""构造 ApiServer 实例"""
|
||||
# 会话服务:SQLite 持久化
|
||||
session_service = SqliteSessionService(
|
||||
db_path=os.path.join(DATA_DIR, "sessions_qwen.db")
|
||||
)
|
||||
|
||||
# 工件服务
|
||||
artifact_service = InMemoryArtifactService()
|
||||
|
||||
# 认证服务(暂不需要,内存版占位)
|
||||
credential_service = InMemoryCredentialService()
|
||||
|
||||
# 记忆服务(内存版占位:满足 ApiServer 必填参数,不含记忆工具/回调,不会注入记忆)
|
||||
memory_service = InMemoryMemoryService()
|
||||
|
||||
# 评测集管理(暂不需要,占位)
|
||||
eval_sets_manager = InMemoryEvalSetsManager()
|
||||
eval_set_results_manager = LocalEvalSetResultsManager(agents_dir=DATA_DIR)
|
||||
|
||||
return ApiServer(
|
||||
agent_loader=DevAgentLoader(),
|
||||
session_service=session_service,
|
||||
memory_service=memory_service,
|
||||
artifact_service=artifact_service,
|
||||
credential_service=credential_service,
|
||||
eval_sets_manager=eval_sets_manager,
|
||||
eval_set_results_manager=eval_set_results_manager,
|
||||
agents_dir=os.path.join(PROJECT_ROOT, ""),
|
||||
auto_create_session=True,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
api_server = create_api_server()
|
||||
fastapi_app = api_server.get_fast_api_app()
|
||||
|
||||
# 挂载 A2A 网关任务接收端点(POST /tasks/{request_id})
|
||||
from task_receiver import create_task_router
|
||||
fastapi_app.include_router(create_task_router(dev_app))
|
||||
|
||||
# 向 A2A 网关注册并启动心跳(注册失败不阻塞服务启动)
|
||||
gateway_ok = gateway_client.register_agent(dev_app.name, f"http://127.0.0.1:{PORT}")
|
||||
if gateway_ok:
|
||||
gateway_client.start_heartbeat(dev_app.name)
|
||||
else:
|
||||
print("[gateway] 注册失败,跳过心跳(网关可能未启动或 auth 不对)")
|
||||
|
||||
print("=" * 60)
|
||||
print("Qwen Agent API Server 启动中...")
|
||||
print(f" 模型: {dev_app.root_agent.model.model}")
|
||||
print(f" 监听地址: http://{HOST}:{PORT}")
|
||||
print(f" Swagger UI: http://{HOST}:{PORT}/docs")
|
||||
print(f" 同步运行: POST http://{HOST}:{PORT}/run")
|
||||
print(f" 流式运行: POST http://{HOST}:{PORT}/run_sse")
|
||||
print(f" 列出agent: GET http://{HOST}:{PORT}/list-apps")
|
||||
print(f" 会话持久化: SQLite ({DATA_DIR}/sessions_qwen.db)")
|
||||
print(f" 上下文压缩: 已启用")
|
||||
print("=" * 60)
|
||||
|
||||
uvicorn.run(fastapi_app, host=HOST, port=PORT, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
agents/qwen/app.py
Normal file
23
agents/qwen/app.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""
|
||||
Dev Agent App 配置
|
||||
使用 ADK App 包装 agent,配置上下文压缩、插件等。
|
||||
"""
|
||||
from google.adk.apps import App
|
||||
from google.adk.apps._configs import EventsCompactionConfig # 实验性 API
|
||||
from agents.qwen.agent import root_agent
|
||||
|
||||
|
||||
# 上下文压缩配置(长对话自动摘要,防止爆 context window)
|
||||
compaction_config = EventsCompactionConfig(
|
||||
compaction_interval=20, # 每 20 个用户轮次压缩一次
|
||||
overlap_size=3, # 重叠 3 轮,保持连续性
|
||||
token_threshold=50000, # token 超 50k 紧急压缩
|
||||
event_retention_size=30, # 压缩时保留最近 30 条原始事件
|
||||
)
|
||||
|
||||
# App 容器:管理 agent + 压缩配置
|
||||
dev_app = App(
|
||||
name="qwen_agent",
|
||||
root_agent=root_agent,
|
||||
events_compaction_config=compaction_config,
|
||||
)
|
||||
277
agents/qwen/chat.py
Normal file
277
agents/qwen/chat.py
Normal file
@ -0,0 +1,277 @@
|
||||
"""
|
||||
Qwen Agent 命令行交互工具
|
||||
使用配置好的 Runner(SQLite 会话持久化 + Memory + 上下文压缩),
|
||||
退出后再次进入同一个 session_id 可以继续对话。
|
||||
|
||||
使用方式:
|
||||
python chat.py # 新会话,自动生成 session_id
|
||||
python chat.py --session my_session # 指定 session_id
|
||||
python chat.py --list # 列出所有会话
|
||||
python chat.py --delete my_session # 删除某个会话
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
|
||||
# 脚本所在目录(作为 .env / data 等相对路径的基准)
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# 项目根目录(往上两级),确保 from agents.xxx.xxx import 可用
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(PROJECT_ROOT, "../.."))
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "", ".env"))
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.agents.run_config import RunConfig, StreamingMode
|
||||
from google.genai import types
|
||||
from agents.qwen.app import dev_app
|
||||
|
||||
|
||||
# 数据目录
|
||||
DATA_DIR = os.path.join(PROJECT_ROOT, "../../data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
# 同一个数据库,A2A server 和 CLI 共享
|
||||
DB_PATH = os.path.join(DATA_DIR, "sessions_qwen.db")
|
||||
|
||||
USER_ID = "local_user"
|
||||
|
||||
|
||||
def get_runner() -> Runner:
|
||||
"""创建带 SQLite 会话持久化的 Runner"""
|
||||
session_service = SqliteSessionService(db_path=DB_PATH)
|
||||
artifact_service = InMemoryArtifactService()
|
||||
|
||||
return Runner(
|
||||
app=dev_app,
|
||||
session_service=session_service,
|
||||
artifact_service=artifact_service,
|
||||
auto_create_session=True,
|
||||
)
|
||||
|
||||
|
||||
async def list_sessions():
|
||||
"""列出所有会话"""
|
||||
runner = get_runner()
|
||||
response = await runner.session_service.list_sessions(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
sessions = response.sessions
|
||||
if not sessions:
|
||||
print("(暂无会话)")
|
||||
return
|
||||
|
||||
print(f"共 {len(sessions)} 个会话:\n")
|
||||
for s in sessions:
|
||||
# 取第一条用户消息作为摘要
|
||||
preview = ""
|
||||
for e in s.events:
|
||||
if e.content and e.content.parts and e.author == "user":
|
||||
text = e.content.parts[0].text[:50]
|
||||
preview = f" → {text}"
|
||||
break
|
||||
print(f" [{s.id}] {preview}")
|
||||
print(f" 更新时间: {s.last_update_time}")
|
||||
|
||||
|
||||
async def delete_session(session_id: str):
|
||||
"""删除指定会话"""
|
||||
runner = get_runner()
|
||||
try:
|
||||
await runner.session_service.delete_session(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
session_id=session_id,
|
||||
)
|
||||
print(f"会话 [{session_id}] 已删除")
|
||||
except Exception as e:
|
||||
print(f"删除失败: {e}")
|
||||
|
||||
|
||||
async def chat(session_id: str | None = None):
|
||||
"""交互式对话"""
|
||||
runner = get_runner()
|
||||
|
||||
# 如果没有指定 session_id,自动创建
|
||||
if not session_id:
|
||||
session = await runner.session_service.create_session(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
session_id = session.id
|
||||
print(f"新会话已创建,session_id: {session_id}")
|
||||
print(f"下次可用 `python chat.py --session {session_id}` 继续\n")
|
||||
|
||||
print(f"=== Qwen Agent 对话 ===")
|
||||
print(f"模型: {dev_app.root_agent.model.model}")
|
||||
print(f"Session: {session_id}")
|
||||
print(f"输入消息开始对话,输入 quit / exit 退出\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("你: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n再见!")
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
if user_input.lower() in ("quit", "exit", "退出"):
|
||||
print("再见!")
|
||||
break
|
||||
|
||||
print("Qwen: ", end="", flush=True)
|
||||
|
||||
async def _agent_task():
|
||||
"""运行 agent 并流式输出,返回是否完成"""
|
||||
displayed_text = ""
|
||||
thought_printed = False
|
||||
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
|
||||
async for event in runner.run_async(
|
||||
user_id=USER_ID,
|
||||
session_id=session_id,
|
||||
new_message=types.Content(parts=[types.Part(text=user_input)]),
|
||||
run_config=run_config,
|
||||
):
|
||||
if not event.content or not event.content.parts:
|
||||
continue
|
||||
|
||||
parts = event.content.parts
|
||||
|
||||
# 1. 思考内容(thought parts)——灰色流式显示
|
||||
thought_parts = [
|
||||
p.text for p in parts
|
||||
if hasattr(p, "text") and p.text
|
||||
and getattr(p, "thought", False)
|
||||
]
|
||||
if thought_parts:
|
||||
thought_text = "".join(thought_parts)
|
||||
if not thought_printed:
|
||||
print("\n\033[90m(思考中…", end="", flush=True)
|
||||
thought_printed_outer[0] = True
|
||||
thought_displayed_outer[0] = 0
|
||||
if len(thought_text) > thought_displayed_outer[0]:
|
||||
print(thought_text[thought_displayed_outer[0]:], end="", flush=True)
|
||||
thought_displayed_outer[0] = len(thought_text)
|
||||
|
||||
# 2. 正式文本——增量显示
|
||||
text_parts = [
|
||||
p.text for p in parts
|
||||
if hasattr(p, "text") and p.text
|
||||
and not getattr(p, "thought", False)
|
||||
]
|
||||
if text_parts:
|
||||
text = "".join(text_parts)
|
||||
if len(text) > len(displayed_text):
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m\nQwen: ", end="", flush=True)
|
||||
thought_printed_outer[0] = False
|
||||
new_text = text[len(displayed_text):]
|
||||
print(new_text, end="", flush=True)
|
||||
displayed_text = text
|
||||
|
||||
# 3. 工具调用提示
|
||||
fcalls = event.get_function_calls()
|
||||
if fcalls and not event.partial:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="", flush=True)
|
||||
thought_printed_outer[0] = False
|
||||
for fc in fcalls:
|
||||
args_str = str(fc.args)[:80]
|
||||
print(f"\n\033[36m🔧 调用工具: {fc.name}({args_str})\033[0m")
|
||||
print("Qwen: ", end="", flush=True)
|
||||
|
||||
# 4. 最终响应
|
||||
if event.is_final_response() and not event.partial:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="", flush=True)
|
||||
thought_printed_outer[0] = False
|
||||
print()
|
||||
return True
|
||||
return False
|
||||
|
||||
thought_printed_outer = [False]
|
||||
thought_displayed_outer = [0]
|
||||
|
||||
# 启动 agent 任务 + 按键监听
|
||||
task = asyncio.create_task(_agent_task())
|
||||
|
||||
async def _keyboard_listener():
|
||||
"""监听按键,检测到中断键时取消 agent 任务"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
import msvcrt
|
||||
while not task.done():
|
||||
await asyncio.sleep(0.05)
|
||||
if msvcrt.kbhit():
|
||||
ch = msvcrt.getwch()
|
||||
# 支持的中断键: Ctrl+C (0x03), Esc (0x1b), q/Q
|
||||
if ch in ("\x03", "\x1b", "q", "Q"):
|
||||
task.cancel()
|
||||
return
|
||||
# 功能键/方向键是两个字节的,跳过第二个
|
||||
if ch in ("\xe0", "\x00"):
|
||||
try:
|
||||
msvcrt.getwch()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
kb_task = asyncio.create_task(_keyboard_listener())
|
||||
await task
|
||||
kb_task.cancel()
|
||||
try:
|
||||
await kb_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="")
|
||||
print("\n\033[33m[已中断] 按回车继续输入新指令\033[0m")
|
||||
# 清空可能残留的输入缓冲
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
while msvcrt.kbhit():
|
||||
msvcrt.getwch()
|
||||
try:
|
||||
input()
|
||||
except EOFError:
|
||||
pass
|
||||
continue
|
||||
except Exception as e:
|
||||
if thought_printed_outer[0]:
|
||||
print("\033[0m", end="")
|
||||
print(f"\n[出错] {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Qwen Agent 命令行交互工具")
|
||||
parser.add_argument("--session", "-s", help="会话 ID,指定后继续该会话")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="列出所有会话")
|
||||
parser.add_argument("--delete", "-d", help="删除指定会话")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
asyncio.run(list_sessions())
|
||||
elif args.delete:
|
||||
asyncio.run(delete_session(args.delete))
|
||||
else:
|
||||
asyncio.run(chat(args.session))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
120
api_server.py
Normal file
120
api_server.py
Normal file
@ -0,0 +1,120 @@
|
||||
"""
|
||||
Dev Agent API Server
|
||||
使用 ADK 官方 ApiServer 构建 REST API 服务,支持:
|
||||
- REST API 调用 agent(/run、/run_sse)
|
||||
- 会话管理(创建/获取/删除,SQLite 持久化)
|
||||
- Swagger UI 交互式文档(/docs)
|
||||
- 上下文自动压缩
|
||||
- 长期记忆(InMemory,后续可换向量库)
|
||||
|
||||
启动方式:
|
||||
python api_server.py
|
||||
|
||||
主要端点:
|
||||
GET /list-apps 列出所有 agent
|
||||
POST /run 同步运行 agent
|
||||
POST /run_sse 流式运行 agent(SSE)
|
||||
GET /apps/{app}/users/{user}/sessions/{session} 获取会话
|
||||
POST /apps/{app}/users/{user}/sessions/{session} 创建会话
|
||||
GET /docs Swagger UI
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 确保项目根目录在 path 里
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "my_agent", ".env"))
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
import uvicorn
|
||||
from google.adk.cli.api_server import ApiServer
|
||||
from google.adk.cli.utils.base_agent_loader import BaseAgentLoader
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
|
||||
from google.adk.evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager
|
||||
from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager
|
||||
from my_agent.app import dev_app
|
||||
|
||||
|
||||
# 配置
|
||||
HOST = os.getenv("API_SERVER_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("API_SERVER_PORT", "8001"))
|
||||
|
||||
# 数据目录
|
||||
DATA_DIR = os.path.join(PROJECT_ROOT, "data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class DevAgentLoader(BaseAgentLoader):
|
||||
"""自定义 agent 加载器,直接返回我们的 App 对象(带 compaction 配置)"""
|
||||
|
||||
def load_agent(self, agent_name: str):
|
||||
if agent_name == dev_app.name:
|
||||
return dev_app
|
||||
raise ValueError(f"Agent not found: {agent_name}")
|
||||
|
||||
def list_agents(self) -> list[str]:
|
||||
return [dev_app.name]
|
||||
|
||||
|
||||
def create_api_server() -> ApiServer:
|
||||
"""构造 ApiServer 实例"""
|
||||
# 会话服务:SQLite 持久化
|
||||
session_service = SqliteSessionService(
|
||||
db_path=os.path.join(DATA_DIR, "sessions.db")
|
||||
)
|
||||
|
||||
# 记忆服务:长期记忆(先用内存版)
|
||||
memory_service = InMemoryMemoryService()
|
||||
|
||||
# 工件服务
|
||||
artifact_service = InMemoryArtifactService()
|
||||
|
||||
# 认证服务(暂不需要,内存版占位)
|
||||
credential_service = InMemoryCredentialService()
|
||||
|
||||
# 评测集管理(暂不需要,占位)
|
||||
eval_sets_manager = InMemoryEvalSetsManager()
|
||||
eval_set_results_manager = LocalEvalSetResultsManager(agents_dir=DATA_DIR)
|
||||
|
||||
return ApiServer(
|
||||
agent_loader=DevAgentLoader(),
|
||||
session_service=session_service,
|
||||
memory_service=memory_service,
|
||||
artifact_service=artifact_service,
|
||||
credential_service=credential_service,
|
||||
eval_sets_manager=eval_sets_manager,
|
||||
eval_set_results_manager=eval_set_results_manager,
|
||||
agents_dir=os.path.join(PROJECT_ROOT, "my_agent"),
|
||||
auto_create_session=True,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
api_server = create_api_server()
|
||||
fastapi_app = api_server.get_fast_api_app()
|
||||
|
||||
print("=" * 60)
|
||||
print("Dev Agent API Server 启动中...")
|
||||
print(f" 监听地址: http://{HOST}:{PORT}")
|
||||
print(f" Swagger UI: http://{HOST}:{PORT}/docs")
|
||||
print(f" 同步运行: POST http://{HOST}:{PORT}/run")
|
||||
print(f" 流式运行: POST http://{HOST}:{PORT}/run_sse")
|
||||
print(f" 列出agent: GET http://{HOST}:{PORT}/list-apps")
|
||||
print(f" 会话持久化: SQLite ({DATA_DIR}/sessions.db)")
|
||||
print(f" 上下文压缩: 已启用")
|
||||
print("=" * 60)
|
||||
|
||||
uvicorn.run(fastapi_app, host=HOST, port=PORT, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
169
chat.py
Normal file
169
chat.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""
|
||||
Dev Agent 命令行交互工具
|
||||
使用配置好的 Runner(SQLite 会话持久化 + Memory + 上下文压缩),
|
||||
退出后再次进入同一个 session_id 可以继续对话。
|
||||
|
||||
使用方式:
|
||||
python chat.py # 新会话,自动生成 session_id
|
||||
python chat.py --session my_session # 指定 session_id
|
||||
python chat.py --list # 列出所有会话
|
||||
python chat.py --delete my_session # 删除某个会话
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
|
||||
# 确保项目根目录在 path 里
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "my_agent", ".env"))
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.genai import types
|
||||
from my_agent.app import dev_app
|
||||
|
||||
|
||||
# 数据目录
|
||||
DATA_DIR = os.path.join(PROJECT_ROOT, "data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
# 同一个数据库,A2A server 和 CLI 共享
|
||||
DB_PATH = os.path.join(DATA_DIR, "sessions.db")
|
||||
|
||||
USER_ID = "local_user"
|
||||
|
||||
|
||||
def get_runner() -> Runner:
|
||||
"""创建带 SQLite 会话持久化的 Runner"""
|
||||
session_service = SqliteSessionService(db_path=DB_PATH)
|
||||
memory_service = InMemoryMemoryService()
|
||||
artifact_service = InMemoryArtifactService()
|
||||
|
||||
return Runner(
|
||||
app=dev_app,
|
||||
session_service=session_service,
|
||||
memory_service=memory_service,
|
||||
artifact_service=artifact_service,
|
||||
auto_create_session=True,
|
||||
)
|
||||
|
||||
|
||||
async def list_sessions():
|
||||
"""列出所有会话"""
|
||||
runner = get_runner()
|
||||
response = await runner.session_service.list_sessions(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
sessions = response.sessions
|
||||
if not sessions:
|
||||
print("(暂无会话)")
|
||||
return
|
||||
|
||||
print(f"共 {len(sessions)} 个会话:\n")
|
||||
for s in sessions:
|
||||
# 取第一条用户消息作为摘要
|
||||
preview = ""
|
||||
for e in s.events:
|
||||
if e.content and e.content.parts and e.author == "user":
|
||||
text = e.content.parts[0].text[:50]
|
||||
preview = f" → {text}"
|
||||
break
|
||||
print(f" [{s.id}] {preview}")
|
||||
print(f" 更新时间: {s.last_update_time}")
|
||||
|
||||
|
||||
async def delete_session(session_id: str):
|
||||
"""删除指定会话"""
|
||||
runner = get_runner()
|
||||
try:
|
||||
await runner.session_service.delete_session(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
session_id=session_id,
|
||||
)
|
||||
print(f"会话 [{session_id}] 已删除")
|
||||
except Exception as e:
|
||||
print(f"删除失败: {e}")
|
||||
|
||||
|
||||
async def chat(session_id: str | None = None):
|
||||
"""交互式对话"""
|
||||
runner = get_runner()
|
||||
|
||||
# 如果没有指定 session_id,自动创建
|
||||
if not session_id:
|
||||
session = await runner.session_service.create_session(
|
||||
app_name=dev_app.name,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
session_id = session.id
|
||||
print(f"新会话已创建,session_id: {session_id}")
|
||||
print(f"下次可用 `python chat.py --session {session_id}` 继续\n")
|
||||
|
||||
print(f"=== Dev Agent 对话 ===")
|
||||
print(f"Session: {session_id}")
|
||||
print(f"输入消息开始对话,输入 quit / exit 退出\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("你: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n再见!")
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
if user_input.lower() in ("quit", "exit", "退出"):
|
||||
print("再见!")
|
||||
break
|
||||
|
||||
print("花花: ", end="", flush=True)
|
||||
|
||||
try:
|
||||
full_response = ""
|
||||
async for event in runner.run_async(
|
||||
user_id=USER_ID,
|
||||
session_id=session_id,
|
||||
new_message=types.Content(parts=[types.Part(text=user_input)]),
|
||||
):
|
||||
if event.is_final_response():
|
||||
# 最终回复
|
||||
for part in event.content.parts:
|
||||
if hasattr(part, "text") and part.text:
|
||||
print(part.text, end="", flush=True)
|
||||
full_response += part.text
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f"\n[出错] {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Dev Agent 命令行交互工具")
|
||||
parser.add_argument("--session", "-s", help="会话 ID,指定后继续该会话")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="列出所有会话")
|
||||
parser.add_argument("--delete", "-d", help="删除指定会话")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
asyncio.run(list_sessions())
|
||||
elif args.delete:
|
||||
asyncio.run(delete_session(args.delete))
|
||||
else:
|
||||
asyncio.run(chat(args.session))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
196
create_agent.py
Normal file
196
create_agent.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""
|
||||
快速创建新 Agent 脚本
|
||||
从 luna 模板复制出一个新 agent 目录,自动替换所有唯一标识符,并:
|
||||
- 生成 agents/<dir>/ 下的 agent.py / app.py / api_server.py / chat.py / .env / __init__.py
|
||||
- 生成 mcp_dev_agent/<dir>_server.py 入口
|
||||
- 更新 C:/Users/nzy/.codebuddy/.mcp.json 追加 MCP server 条目
|
||||
|
||||
命名约定(沿用 luna/qwen):
|
||||
DIR = 用户输入(如 claude)
|
||||
AGENT = {DIR}_agent (App name / agent name)
|
||||
DB = sessions_{DIR}.db
|
||||
MCP = kebab-case(如 claude-agent)
|
||||
|
||||
用法:
|
||||
python create_agent.py claude --model opcode/claude-sonnet
|
||||
python create_agent.py claude --model opcode/claude-sonnet --port 8005 --display "Claude Dev Agent"
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
# 强制 UTF-8(Windows 控制台默认 GBK,无法打印 ✓/→ 等字符)
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 仓库根目录
|
||||
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
AGENTS_DIR = os.path.join(REPO_ROOT, "agents")
|
||||
MCP_DIR = os.path.join(REPO_ROOT, "mcp_dev_agent")
|
||||
TEMPLATE_DIR = os.path.join(AGENTS_DIR, "luna")
|
||||
MCP_CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".codebuddy", ".mcp.json")
|
||||
VENV_PYTHON = os.path.join(REPO_ROOT, ".venv", "Scripts", "python.exe")
|
||||
|
||||
# 模板中需要替换的标识符(按顺序执行,先长后短避免误伤)
|
||||
REPLACEMENTS = [
|
||||
("luna_agent", "{AGENT}"), # 覆盖所有 app/agent name 引用
|
||||
("luna", "{DIR}"), # 剩余:模块路径、db 名、instruction 内称呼
|
||||
("Luna", "{TITLE}"), # 首字母大写:Luna Agent / Luna: / === Luna
|
||||
("8002", "{PORT}"), # 默认端口
|
||||
("opcode/deepseek-v4-flash", "{MODEL}"), # .env 的 VLLM_MODEL
|
||||
]
|
||||
|
||||
# 复制时排除的目录/文件
|
||||
SKIP_DIR_NAMES = {"__pycache__", ".adk", ".git", ".idea"}
|
||||
SKIP_FILENAMES = {".gitignore"}
|
||||
|
||||
|
||||
def interact(arg_dir: str, model: str, port: int, display: str) -> tuple[str, str, int, str]:
|
||||
"""补齐缺失参数(缺省时交互式询问)"""
|
||||
d = arg_dir
|
||||
if not d:
|
||||
d = input("Agent 目录名(如 claude): ").strip()
|
||||
if not model:
|
||||
model = input("模型名(如 opcode/claude-sonnet): ").strip()
|
||||
if port is None:
|
||||
port = auto_next_port()
|
||||
if not display:
|
||||
display = f"{d.title()} Agent ({model}) 全栈开发助手"
|
||||
return d, model, port, display
|
||||
|
||||
|
||||
def auto_next_port() -> int:
|
||||
"""扫描 agents/*/.env 的 API_SERVER_PORT,取最大值 +1"""
|
||||
max_port = 8000
|
||||
if os.path.isdir(AGENTS_DIR):
|
||||
for entry in os.listdir(AGENTS_DIR):
|
||||
env_path = os.path.join(AGENTS_DIR, entry, ".env")
|
||||
if os.path.isfile(env_path):
|
||||
m = re.search(r"API_SERVER_PORT\s*=\s*(\d+)", open(env_path, encoding="utf-8").read())
|
||||
if m:
|
||||
max_port = max(max_port, int(m.group(1)))
|
||||
return max_port + 1
|
||||
|
||||
|
||||
def copy_tree(src: str, dst: str) -> None:
|
||||
"""递归复制,跳过 __pycache__/.adk/.git 等"""
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
for name in os.listdir(src):
|
||||
s = os.path.join(src, name)
|
||||
d = os.path.join(dst, name)
|
||||
if os.path.isdir(s):
|
||||
if name in SKIP_DIR_NAMES:
|
||||
continue
|
||||
copy_tree(s, d)
|
||||
else:
|
||||
if name in SKIP_FILENAMES:
|
||||
continue
|
||||
shutil.copy2(s, d)
|
||||
|
||||
|
||||
def apply_replacements(path: str, mapping: dict) -> None:
|
||||
"""对文件内容按顺序做字符串替换"""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
for old, key in REPLACEMENTS:
|
||||
content = content.replace(old, mapping.get(key, ""))
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def update_mcp_config(mcp_key: str, server_file: str, display: str) -> None:
|
||||
"""在 .mcp.json 的 mcpServers 中追加一条"""
|
||||
if not os.path.isfile(MCP_CONFIG_PATH):
|
||||
print(f"[warn] 未找到 {MCP_CONFIG_PATH},跳过 MCP 配置更新")
|
||||
return
|
||||
|
||||
with open(MCP_CONFIG_PATH, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
if mcp_key in cfg.get("mcpServers", {}):
|
||||
print(f"[warn] .mcp.json 已存在 {mcp_key} 条目,跳过")
|
||||
return
|
||||
|
||||
cfg.setdefault("mcpServers", {})[mcp_key] = {
|
||||
"type": "stdio",
|
||||
"command": VENV_PYTHON,
|
||||
"args": [server_file],
|
||||
"description": display,
|
||||
}
|
||||
|
||||
with open(MCP_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f" ✓ 已更新 {MCP_CONFIG_PATH}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="创建一个新的 Dev Agent(从 luna 模板)")
|
||||
parser.add_argument("dir", nargs="?", help="agent 目录名(如 claude)")
|
||||
parser.add_argument("--model", default=None, help="模型名(如 opcode/claude-sonnet)")
|
||||
parser.add_argument("--port", type=int, default=None, help="API 端口,默认自动取最大+1")
|
||||
parser.add_argument("--display", default=None, help="MCP 描述,默认 '{Dir} Agent ({model}) 全栈开发助手'")
|
||||
args = parser.parse_args()
|
||||
|
||||
d, model, port, display = interact(args.dir, args.model, args.port, args.display)
|
||||
|
||||
if not d or not model:
|
||||
print("错误:目录名和模型名不能为空")
|
||||
sys.exit(1)
|
||||
|
||||
agent = f"{d}_agent"
|
||||
title = d.title()
|
||||
db = f"sessions_{d}.db"
|
||||
mcp_key = f"{d.replace('_', '-')}-agent"
|
||||
target_dir = os.path.join(AGENTS_DIR, d)
|
||||
server_file = os.path.join(MCP_DIR, f"{d}_server.py")
|
||||
|
||||
mapping = {"{AGENT}": agent, "{DIR}": d, "{TITLE}": title,
|
||||
"{PORT}": str(port), "{MODEL}": model}
|
||||
|
||||
# 1. 校验目标目录不存在
|
||||
if os.path.exists(target_dir):
|
||||
print(f"错误:目录已存在 {target_dir}")
|
||||
sys.exit(1)
|
||||
if os.path.exists(server_file):
|
||||
print(f"错误:MCP server 已存在 {server_file}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"创建 Agent: {d} (agent={agent}, port={port}, model={model})")
|
||||
print(f" 数据库: {db}")
|
||||
|
||||
# 2. 复制模板目录
|
||||
copy_tree(TEMPLATE_DIR, target_dir)
|
||||
# 3. 替换所有文件里的标识符
|
||||
for root, _dirs, files in os.walk(target_dir):
|
||||
for fn in files:
|
||||
apply_replacements(os.path.join(root, fn), mapping)
|
||||
# 4. 重写 __init__.py
|
||||
with open(os.path.join(target_dir, "__init__.py"), "w", encoding="utf-8") as f:
|
||||
f.write(f"# {d} package\nfrom . import agent\n")
|
||||
print(f" ✓ 已生成 agents/{d}/")
|
||||
|
||||
# 5. 生成 MCP server 入口
|
||||
shutil.copy2(os.path.join(MCP_DIR, "luna_server.py"), server_file)
|
||||
apply_replacements(server_file, mapping)
|
||||
print(f" ✓ 已生成 {server_file}")
|
||||
|
||||
# 6. 更新 .mcp.json
|
||||
update_mcp_config(mcp_key, server_file, display)
|
||||
|
||||
print("\n完成!启动方式:")
|
||||
print(f" cd agents/{d} && python api_server.py # 启动 API Server(自动注册到网关)")
|
||||
print(f" cd agents/{d} && python chat.py # 命令行对话")
|
||||
print(f" python agent_status.py --agent {agent} # 查看状态(需先手动加入 AGENTS 映射)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
data/sessions.db
Normal file
BIN
data/sessions.db
Normal file
Binary file not shown.
BIN
data/sessions_luna.db
Normal file
BIN
data/sessions_luna.db
Normal file
Binary file not shown.
BIN
data/sessions_qwen.db
Normal file
BIN
data/sessions_qwen.db
Normal file
Binary file not shown.
18
data/tasks/238726a8.json
Normal file
18
data/tasks/238726a8.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "238726a8",
|
||||
"description": "【重要任务】重构勤务统计页面(schedulingStatistical.vue),将现有占位页面改为大屏可视化风格,像素级复刻老项目样式。\n\n==== 项目信息 ====\n项目路径:d:/nzy/workspace_git/Baseplatform-Ui-V3\n目标文件:src/views/serviceManage/schedulingStatistical.vue\n老项目参考:D:/nzy/workspace_git/baseplatform-ui/src/views/postPage/schedulingStatistical.vue\n\n==== 必须遵守的迁移规范(非常重要) ====\n\n1. 【页面外壳】使用 BgAndInfo 组件:\n import BgAndInfo from '/@/components/Common/bgAndInfo.vue'\n 用法:<bg-and-info title-menu=\"勤务管理\" title-name=\"勤务统计\" step-url=\"/frontend/schedulingStatistical\" bg-size=\"small\">\n 参考已实现的 serviceIndexDd.vue 和 workData.vue\n\n2. 【颜色规范】深色科技感大屏风格:\n 主色:#29B3FF\n 背景:#0A1A29\n 文字主色:#eaf8ff\n 文字次色:#7aabc6\n 强调色:#FFC34D (金黄)、#55E1CA (青色)、#FF6565 (红色)\n 面板背景:rgba(4, 21, 40, 0.5) ~ (0.6)\n 面板边框:rgba(41, 179, 255, 0.15) ~ (0.3)\n\n3. 【图片引用规则 — 绝对不能错】\n 禁止使用 <img src=\"/@/assets/...\"> 方式(运行时 vite 无法解析)\n 必须用 CSS background-image: url('/@/assets/...') 方式\n 所有图标都这样写,包括按钮图标、列表图标等\n\n4. 【图标资源位置】\n sandImg 通用图标:src/assets/sandImg/ (133个,从老项目拷贝的)\n service 专属图标:src/assets/service/ (42个,从老项目拷贝的)\n work 专属图标:src/assets/map_image/work/ (45个)\n 背景图:src/assets/map_image/ 下的 ponding/, small/, schdulingCommand/ 等\n\n5. 【技术栈】\n Vue3 Composition API + TypeScript\n ref / reactive / computed / onMounted / onUnmounted\n 图表:import * as echarts from 'echarts'\n 图表在 onMounted 中初始化,onUnmounted 中 dispose\n 样式:<style scoped lang=\"scss\">\n\n6. 【数据】\n 全部用 Mock 数据,不要调真实 API\n 数据量适中:列表8-15条,图表5-12个数据点\n 数据字段名尽量参考老项目,方便后续接 API\n\n7. 【参考页面】\n 勤务排班:src/views/serviceManage/serviceIndexDd.vue (已完成,左右+中部布局)\n 施工智管:src/views/serviceManage/workData.vue (已完成,左列表+右4面板)\n 情报研判相关:src/views/intelligenceAnalysis/trafficPerception.vue\n\n8. 【老项目分析】\n 先读取老项目 D:/nzy/workspace_git/baseplatform-ui/src/views/postPage/schedulingStatistical.vue 的完整代码\n 分析布局结构和功能模块\n 然后根据老项目的布局来实现\n\n9. 【验收标准】\n - 构建通过:npx vite build 无错误\n - 无 <img src=\"/@/assets/...\"> 写法\n - 视觉风格与老项目一致(布局、颜色、字体)\n - 交互完整(切换、筛选、列表点击等)\n - 全部 Mock 数据\n\n请先分析老项目页面结构,然后再开始编码。完成后告诉我你做了什么。",
|
||||
"project_path": "d:/nzy/workspace_git/Baseplatform-Ui-V3",
|
||||
"requirements": "",
|
||||
"status": "completed",
|
||||
"created_at": 1785464745.188234,
|
||||
"updated_at": 1785465287.061655,
|
||||
"result": {
|
||||
"summary": "(无响应)",
|
||||
"full_response": "",
|
||||
"tool_calls_count": 0,
|
||||
"tool_calls_sample": [],
|
||||
"status": "empty"
|
||||
},
|
||||
"logs": [],
|
||||
"extra": {}
|
||||
}
|
||||
18
data/tasks/3baaed45.json
Normal file
18
data/tasks/3baaed45.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "3baaed45",
|
||||
"description": "测试任务:请在 d:/nzy/workspace_git/Baseplatform-Ui-V3/ 目录下创建一个文件 test-dev-agent-2.txt,内容写 'hello from dev agent test 2',然后读取文件并报告内容。",
|
||||
"project_path": "d:/nzy/workspace_git/Baseplatform-Ui-V3",
|
||||
"requirements": "",
|
||||
"status": "completed",
|
||||
"created_at": 1785467946.3815768,
|
||||
"updated_at": 1785467957.7573347,
|
||||
"result": {
|
||||
"summary": "(无响应)",
|
||||
"full_response": "",
|
||||
"tool_calls_count": 0,
|
||||
"tool_calls_sample": [],
|
||||
"status": "empty"
|
||||
},
|
||||
"logs": [],
|
||||
"extra": {}
|
||||
}
|
||||
18
data/tasks/57549fbd.json
Normal file
18
data/tasks/57549fbd.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "57549fbd",
|
||||
"description": "重构施工智管页面(workData.vue),将现有简单的卡片网格页面改为大屏可视化风格,像素级复刻老项目的样式和布局。\n\n【老项目参考】\n老项目路径:D:/nzy/workspace_git/baseplatform-ui/src/views/work/workData.vue\n请先读取老项目页面的完整代码,了解布局结构。\n\n【重构经验和规范】\n1. 使用 BgAndInfo 组件作为页面外壳:import BgAndInfo from '/@/components/Common/bgAndInfo.vue',用法 <bg-and-info title-menu=\"勤务管理\" title-name=\"施工智管\" step-url=\"/frontend/workData\">\n2. 整体风格:深色科技感大屏,主色 #29B3FF,背景 #0A1A29,文字主色 #eaf8ff,次色 #7aabc6\n3. 图片引用必须用 CSS background-image: url('/@/assets/...') 方式,绝对不能用 <img src=\"/@/assets/...\">(运行时vite无法解析)\n4. 图标使用 src/assets/sandImg/ 目录下的老项目图标(已全部拷贝,133个PNG)\n5. 使用 Mock 数据,不需要接真实 API,数据量适中即可(8-15条)\n6. Vue3 Composition API + TypeScript,ref/reactive/computed\n7. 图表用 ECharts(import * as echarts from 'echarts'),在 onMounted 中初始化,onUnmounted 中 dispose\n8. 样式用 <style scoped lang=\"scss\">\n9. 现有页面可以全部重写,不用保留\n10. 参考已有的大屏页面布局风格:src/views/commandDispatch/carManager/index.vue(左右面板布局)和 src/views/commandDispatch/sandIndex/index.vue(整体框架)\n\n【页面功能区】\n老项目施工智管大致包含:顶部筛选工具栏(时间、类型、区域筛选等)、施工项目列表/卡片网格(项目名称、位置、状态、工期、责任人等信息)。请根据老项目实际结构来实现。\n\n【验收标准】\n- 构建通过:npx vite build 无错误\n- 无 <img src=\"/@/assets/...\"> 写法\n- 视觉风格与老项目一致(布局、颜色、字体)\n- 功能交互完整(筛选、切换、详情等)\n- 全是 Mock 数据,无真实 API 调用",
|
||||
"project_path": "d:/nzy/workspace_git/Baseplatform-Ui-V3",
|
||||
"requirements": "",
|
||||
"status": "completed",
|
||||
"created_at": 1785460630.3209536,
|
||||
"updated_at": 1785460700.2011487,
|
||||
"result": {
|
||||
"summary": "(无响应)",
|
||||
"full_response": "",
|
||||
"tool_calls_count": 0,
|
||||
"tool_calls_sample": [],
|
||||
"status": "empty"
|
||||
},
|
||||
"logs": [],
|
||||
"extra": {}
|
||||
}
|
||||
18
data/tasks/72ed062f.json
Normal file
18
data/tasks/72ed062f.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "72ed062f",
|
||||
"description": "在 Baseplatform-Ui-V3 项目的 src/views/serviceManage/ 目录下创建一个名为 dev-agent-test.md 的测试文件,文件内容为:# Dev Agent 测试文件\n\n这是一个测试文件,用于验证 dev-agent 可以正常编辑文件。\n\n- 项目路径:D:/nzy/workspace_git/Baseplatform-Ui-V3\n- 操作:创建测试文件",
|
||||
"project_path": "d:/nzy/workspace_git/Baseplatform-Ui-V3",
|
||||
"requirements": "",
|
||||
"status": "completed",
|
||||
"created_at": 1785464261.6740465,
|
||||
"updated_at": 1785464284.0124946,
|
||||
"result": {
|
||||
"summary": "(无响应)",
|
||||
"full_response": "",
|
||||
"tool_calls_count": 0,
|
||||
"tool_calls_sample": [],
|
||||
"status": "empty"
|
||||
},
|
||||
"logs": [],
|
||||
"extra": {}
|
||||
}
|
||||
18
data/tasks/ecf6aced.json
Normal file
18
data/tasks/ecf6aced.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "ecf6aced",
|
||||
"description": "测试任务:请在项目目录下创建一个名为 dev-agent-test.txt 的文件,内容为 hello from dev-agent,然后返回文件的完整路径。确认你能正常读写文件。",
|
||||
"project_path": "d:\\nzy\\workspace_git\\Baseplatform-Ui-V3",
|
||||
"requirements": "",
|
||||
"status": "completed",
|
||||
"created_at": 1785463307.6479685,
|
||||
"updated_at": 1785463318.5050843,
|
||||
"result": {
|
||||
"summary": "(无响应)",
|
||||
"full_response": "",
|
||||
"tool_calls_count": 0,
|
||||
"tool_calls_sample": [],
|
||||
"status": "empty"
|
||||
},
|
||||
"logs": [],
|
||||
"extra": {}
|
||||
}
|
||||
217
gateway_client.py
Normal file
217
gateway_client.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""A2A 网关接入客户端:Agent 服务启动后自动注册 + 心跳保活 + 注销。
|
||||
|
||||
用法(在 api_server.py 的 main() 中):
|
||||
from gateway_client import register_agent, start_heartbeat, unregister_agent
|
||||
|
||||
if register_agent(dev_app.name, f"http://127.0.0.1:{PORT}"):
|
||||
start_heartbeat(dev_app.name)
|
||||
|
||||
环境变量:
|
||||
GATEWAY_URL 网关地址,默认 http://127.0.0.1:8000
|
||||
GATEWAY_AUTH 网关认证密码,需与网关 GATEWAY_AUTH 一致
|
||||
AGENT_TAGS 能力标签,逗号分隔,默认 code
|
||||
AGENT_MAX_CONCURRENT 最大并发数,默认 1
|
||||
HEARTBEAT_INTERVAL 心跳间隔秒,默认 10
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||
GATEWAY_AUTH = os.getenv("GATEWAY_AUTH", "dev-gateway-auth")
|
||||
HEARTBEAT_INTERVAL = int(os.getenv("HEARTBEAT_INTERVAL", "10"))
|
||||
|
||||
_client = httpx.Client(timeout=5.0)
|
||||
|
||||
# 取消/停止指令下发:网关复用 CLI 的 /api/cli/events 通道(event=task_stop)
|
||||
# Agent 通过同一个 cli_session_id 订阅,收到匹配自身正在执行 request_id 的 task_stop 时置停止标志。
|
||||
_stop_registry: dict[str, threading.Event] = {}
|
||||
|
||||
|
||||
def mark_stop_requested(request_id: str) -> None:
|
||||
"""标记某任务需要停止(收到 task_stop 后调用)。"""
|
||||
ev = _stop_registry.get(request_id)
|
||||
if ev is None:
|
||||
ev = threading.Event()
|
||||
_stop_registry[request_id] = ev
|
||||
ev.set()
|
||||
|
||||
|
||||
def clear_stop_requested(request_id: str) -> None:
|
||||
"""任务开始执行前清除停止标志。"""
|
||||
ev = _stop_registry.get(request_id)
|
||||
if ev is not None:
|
||||
ev.clear()
|
||||
|
||||
|
||||
def is_stop_requested(request_id: str) -> bool:
|
||||
"""判断某任务是否已被要求停止(供执行循环轮询检查)。"""
|
||||
ev = _stop_registry.get(request_id)
|
||||
return ev is not None and ev.is_set()
|
||||
|
||||
|
||||
def wait_stop(request_id: str, timeout: float = 0.5) -> bool:
|
||||
"""等待停止标志,返回 True 表示已收到停止请求。执行循环可用它做可中断 sleep。"""
|
||||
ev = _stop_registry.get(request_id)
|
||||
if ev is None:
|
||||
ev = threading.Event()
|
||||
_stop_registry[request_id] = ev
|
||||
return ev.wait(timeout)
|
||||
|
||||
|
||||
def _sse_subscribe_poll(cli_session_id: str) -> None:
|
||||
"""后台线程:订阅网关 /api/cli/events 通道,识别 task_stop 指令并标记停止。"""
|
||||
url = f"{GATEWAY_URL}/api/cli/events?cli_session_id={cli_session_id}&auth={GATEWAY_AUTH}"
|
||||
while True:
|
||||
try:
|
||||
with _client.stream("GET", url, timeout=None) as resp:
|
||||
if resp.status_code != 200:
|
||||
logger.warning("sse subscribe failed status=%s", resp.status_code)
|
||||
time.sleep(HEARTBEAT_INTERVAL)
|
||||
continue
|
||||
for line in resp.iter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
try:
|
||||
import json
|
||||
evt = json.loads(line[len("data:"):].strip())
|
||||
except Exception:
|
||||
continue
|
||||
if evt.get("event") == "task_stop":
|
||||
rid = evt.get("request_id")
|
||||
if rid:
|
||||
mark_stop_requested(rid)
|
||||
logger.info("stop requested received request=%s", rid)
|
||||
except Exception as e:
|
||||
logger.warning("sse subscribe loop error err=%s", e)
|
||||
time.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
|
||||
def start_stop_listener(cli_session_id: str) -> threading.Thread:
|
||||
"""启动 SSE 停止指令订阅线程(daemon)。"""
|
||||
t = threading.Thread(
|
||||
target=_sse_subscribe_poll,
|
||||
args=(cli_session_id,),
|
||||
name=f"gateway-sse-{cli_session_id[:8]}",
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
logger.info("stop listener started session=%s", cli_session_id)
|
||||
return t
|
||||
|
||||
|
||||
def _parse_tags() -> list[str]:
|
||||
raw = os.getenv("AGENT_TAGS", "code")
|
||||
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||
|
||||
|
||||
def register_agent(agent_id: str, endpoint: str, agent_tags: list[str] | None = None,
|
||||
max_concurrent: int | None = None) -> bool:
|
||||
"""向网关注册本 Agent。成功返回 True,失败(网关未启动/认证失败)返回 False。"""
|
||||
tags = agent_tags if agent_tags is not None else _parse_tags()
|
||||
max_conc = max_concurrent or int(os.getenv("AGENT_MAX_CONCURRENT", "1"))
|
||||
body = {
|
||||
"auth": GATEWAY_AUTH,
|
||||
"agent_id": agent_id,
|
||||
"endpoint": endpoint,
|
||||
"agent_tags": tags,
|
||||
"max_concurrent": max_conc,
|
||||
"current_load": 0,
|
||||
}
|
||||
try:
|
||||
resp = _client.post(f"{GATEWAY_URL}/api/agent/register", json=body)
|
||||
if resp.status_code == 200:
|
||||
logger.info("registered to gateway agent=%s endpoint=%s tags=%s", agent_id, endpoint, tags)
|
||||
return True
|
||||
logger.error("register failed agent=%s status=%s body=%s", agent_id, resp.status_code, resp.text)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("register network error agent=%s err=%s", agent_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def _heartbeat_once(agent_id: str, current_load: int) -> bool:
|
||||
try:
|
||||
resp = _client.post(
|
||||
f"{GATEWAY_URL}/api/agent/heartbeat",
|
||||
json={"auth": GATEWAY_AUTH, "agent_id": agent_id, "current_load": current_load},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
logger.warning("heartbeat failed status=%s body=%s", resp.status_code, resp.text)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("heartbeat network error err=%s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _heartbeat_loop(agent_id: str) -> None:
|
||||
while True:
|
||||
try:
|
||||
load = get_current_load()
|
||||
except Exception:
|
||||
load = 0
|
||||
_heartbeat_once(agent_id, load)
|
||||
time.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
|
||||
def start_heartbeat(agent_id: str) -> threading.Thread:
|
||||
"""启动后台心跳线程(daemon,随进程退出)。"""
|
||||
t = threading.Thread(target=_heartbeat_loop, args=(agent_id,), name=f"gateway-hb-{agent_id}", daemon=True)
|
||||
t.start()
|
||||
logger.info("heartbeat started agent=%s interval=%ss", agent_id, HEARTBEAT_INTERVAL)
|
||||
return t
|
||||
|
||||
|
||||
def unregister_agent(agent_id: str) -> bool:
|
||||
"""向网关注销本 Agent。"""
|
||||
try:
|
||||
resp = _client.post(f"{GATEWAY_URL}/api/agent/unregister", json={"auth": GATEWAY_AUTH, "agent_id": agent_id})
|
||||
if resp.status_code in (200, 404):
|
||||
logger.info("unregistered from gateway agent=%s", agent_id)
|
||||
return True
|
||||
logger.warning("unregister failed status=%s body=%s", resp.status_code, resp.text)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("unregister network error agent=%s err=%s", agent_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def report_result(request_id: str, agent_id: str, status: str = "success",
|
||||
progress: int = 100, result: dict | None = None,
|
||||
error_info: str | None = None) -> bool:
|
||||
"""任务执行完成后,将结果回传给网关(POST /api/agent/result)。
|
||||
|
||||
Args:
|
||||
request_id: 网关分配的任务 ID
|
||||
agent_id: 本 Agent ID
|
||||
status: success / failed
|
||||
progress: 进度百分比
|
||||
result: 结果字典(可选)
|
||||
error_info: 错误信息(失败时必填)
|
||||
"""
|
||||
body = {
|
||||
"auth": GATEWAY_AUTH,
|
||||
"request_id": request_id,
|
||||
"agent_id": agent_id,
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"result": result,
|
||||
"error_info": error_info,
|
||||
}
|
||||
try:
|
||||
resp = _client.post(f"{GATEWAY_URL}/api/agent/result", json=body)
|
||||
if resp.status_code == 200:
|
||||
logger.info("result reported request=%s status=%s", request_id, status)
|
||||
return True
|
||||
logger.error("report result failed request=%s status=%s body=%s", request_id, resp.status_code, resp.text)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("report result network error request=%s err=%s", request_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def get_current_load() -> int:
|
||||
"""Agent 当前负载。子类/调用方可覆写以报告真实并发数。"""
|
||||
return 0
|
||||
59
mcp_dev_agent/README.md
Normal file
59
mcp_dev_agent/README.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Dev Agent MCP Server
|
||||
|
||||
将 Dev Agent 封装为 MCP 工具,供 CodeBuddy / Cursor / Windsurf 等 MCP 客户端调用。
|
||||
|
||||
## 功能
|
||||
|
||||
- **run_dev_agent** — 调用 Dev Agent 执行开发子任务
|
||||
- 支持文件读写、终端命令执行、编译验证
|
||||
- 支持指定 `session_id` 进行多轮对话
|
||||
|
||||
## 配置方法
|
||||
|
||||
### CodeBuddy
|
||||
|
||||
在 CodeBuddy 的 MCP 配置中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dev-agent": {
|
||||
"command": "python",
|
||||
"args": ["d:/nzy/workspace_python/agent/mcp_dev_agent/server.py"],
|
||||
"env": {
|
||||
"DEV_AGENT_API_URL": "http://127.0.0.1:8001",
|
||||
"DEV_AGENT_APP_NAME": "dev_agent",
|
||||
"DEV_AGENT_USER_ID": "codebuddy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `DEV_AGENT_API_URL` | `http://127.0.0.1:8001` | Dev Agent API Server 地址 |
|
||||
| `DEV_AGENT_APP_NAME` | `dev_agent` | Agent 名称 |
|
||||
| `DEV_AGENT_USER_ID` | `codebuddy` | 用户 ID(用于会话隔离) |
|
||||
|
||||
## 使用前提
|
||||
|
||||
1. 先启动 Dev Agent API Server:
|
||||
```bash
|
||||
python api_server.py
|
||||
```
|
||||
|
||||
2. 配置 MCP server(见上方配置方法)
|
||||
|
||||
3. 重启 CodeBuddy / IDE
|
||||
|
||||
## 工具参数
|
||||
|
||||
### run_dev_agent
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `task` | ✅ | 任务描述,越详细越好 |
|
||||
| `session_id` | ❌ | 会话 ID,不传则用 `default`。用于多轮对话续聊 |
|
||||
22
mcp_dev_agent/luna_server.py
Normal file
22
mcp_dev_agent/luna_server.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""
|
||||
Luna Agent MCP Server (gpt-5.6-luna)
|
||||
独立入口脚本,直接用 venv python 启动。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 硬编码配置(优先级高于环境变量)
|
||||
os.environ["DEV_AGENT_API_URL"] = "http://127.0.0.1:8002"
|
||||
os.environ["DEV_AGENT_APP_NAME"] = "luna_agent"
|
||||
os.environ["DEV_AGENT_USER_ID"] = "codebuddy"
|
||||
|
||||
# 确保项目根目录在 path 里
|
||||
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, _PROJECT_ROOT)
|
||||
|
||||
# 导入通用 server 并运行
|
||||
from mcp_dev_agent.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
22
mcp_dev_agent/my_agent_server.py
Normal file
22
mcp_dev_agent/my_agent_server.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""
|
||||
My Agent MCP Server (aq-first-combo)
|
||||
独立入口脚本,直接用 venv python 启动。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 硬编码配置(优先级高于环境变量)
|
||||
os.environ["DEV_AGENT_API_URL"] = "http://127.0.0.1:8001"
|
||||
os.environ["DEV_AGENT_APP_NAME"] = "my_agent"
|
||||
os.environ["DEV_AGENT_USER_ID"] = "codebuddy"
|
||||
|
||||
# 确保项目根目录在 path 里
|
||||
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, _PROJECT_ROOT)
|
||||
|
||||
# 导入通用 server 并运行
|
||||
from mcp_dev_agent.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
22
mcp_dev_agent/qwen_server.py
Normal file
22
mcp_dev_agent/qwen_server.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""
|
||||
Qwen Agent MCP Server (astron-code-latest)
|
||||
独立入口脚本,直接用 venv python 启动。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 硬编码配置(优先级高于环境变量)
|
||||
os.environ["DEV_AGENT_API_URL"] = "http://127.0.0.1:8003"
|
||||
os.environ["DEV_AGENT_APP_NAME"] = "qwen_agent"
|
||||
os.environ["DEV_AGENT_USER_ID"] = "codebuddy"
|
||||
|
||||
# 确保项目根目录在 path 里
|
||||
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, _PROJECT_ROOT)
|
||||
|
||||
# 导入通用 server 并运行
|
||||
from mcp_dev_agent.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
138
mcp_dev_agent/server.py
Normal file
138
mcp_dev_agent/server.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""
|
||||
Dev Agent MCP Server
|
||||
将 Dev Agent 封装为 MCP 工具,供 CodeBuddy 等 MCP 客户端调用。
|
||||
|
||||
功能:
|
||||
- run_dev_agent: 提交任务给 Dev Agent 执行,返回执行结果
|
||||
- 支持指定 session_id 进行多轮对话
|
||||
- 自动提取最终回复文本
|
||||
|
||||
环境变量配置:
|
||||
- DEV_AGENT_API_URL: API Server 地址(默认 http://127.0.0.1:8001)
|
||||
- DEV_AGENT_APP_NAME: 调用的 agent 名称(默认 my_agent)
|
||||
- DEV_AGENT_USER_ID: 用户 ID(默认 codebuddy)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
|
||||
# 配置
|
||||
DEV_AGENT_API_URL = os.getenv("DEV_AGENT_API_URL", "http://127.0.0.1:8001")
|
||||
DEV_AGENT_APP_NAME = os.getenv("DEV_AGENT_APP_NAME", "my_agent")
|
||||
DEV_AGENT_USER_ID = os.getenv("DEV_AGENT_USER_ID", "codebuddy")
|
||||
|
||||
# MCP Server
|
||||
mcp = FastMCP(name=f"dev-agent-{DEV_AGENT_APP_NAME}")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def run_dev_agent(task: str, session_id: str = "default") -> str:
|
||||
"""调用 Dev Agent(全栈开发子 Agent)执行开发任务。
|
||||
|
||||
Dev Agent 可以读写文件、运行终端命令、执行编译/构建/测试。
|
||||
适用于:代码编写、bug 修复、项目搭建、编译验证等开发子任务。
|
||||
|
||||
Args:
|
||||
task: 要 Dev Agent 执行的任务描述,越详细越好
|
||||
session_id: 可选,会话 ID,用于多轮对话/续聊,默认 "default"
|
||||
|
||||
Returns:
|
||||
Dev Agent 的执行结果
|
||||
"""
|
||||
if not task:
|
||||
return "错误:task 不能为空"
|
||||
|
||||
# 构造请求
|
||||
payload = {
|
||||
"appName": DEV_AGENT_APP_NAME,
|
||||
"userId": DEV_AGENT_USER_ID,
|
||||
"sessionId": session_id,
|
||||
"newMessage": {
|
||||
"role": "user",
|
||||
"parts": [{"text": task}],
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=600.0) as client: # 10 分钟超时
|
||||
response = await client.post(
|
||||
f"{DEV_AGENT_API_URL}/run",
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return (
|
||||
f"调用 Dev Agent 失败(HTTP {response.status_code}):\n"
|
||||
f"{response.text[:500]}"
|
||||
)
|
||||
|
||||
events = response.json()
|
||||
except httpx.ConnectError:
|
||||
return (
|
||||
f"无法连接到 Dev Agent API Server({DEV_AGENT_API_URL})\n"
|
||||
f"请确认 api_server.py 是否已启动。"
|
||||
)
|
||||
except Exception as e:
|
||||
return f"调用 Dev Agent 出错: {e}"
|
||||
|
||||
# 从事件列表中提取最终回复
|
||||
return _extract_final_response(events, session_id)
|
||||
|
||||
|
||||
def _extract_final_response(events: list[dict], session_id: str) -> str:
|
||||
"""从事件列表中提取 agent 的最终文本回复"""
|
||||
if not events:
|
||||
return "(无返回事件)"
|
||||
|
||||
final_text_parts = []
|
||||
|
||||
for event in events:
|
||||
content = event.get("content", {})
|
||||
role = content.get("role", "")
|
||||
parts = content.get("parts", [])
|
||||
author = event.get("author", "")
|
||||
|
||||
if role == "model" and author == DEV_AGENT_APP_NAME:
|
||||
for part in parts:
|
||||
if "text" in part:
|
||||
final_text_parts.append(part["text"])
|
||||
|
||||
response = "\n".join(final_text_parts).strip()
|
||||
|
||||
if not response:
|
||||
# 如果没有找到最终回复,返回事件摘要
|
||||
summary = f"共 {len(events)} 个事件\n"
|
||||
for e in events[-5:]:
|
||||
content = e.get("content", {})
|
||||
parts = content.get("parts", [])
|
||||
role = content.get("role", "")
|
||||
author = e.get("author", "")
|
||||
part_types = [list(p.keys())[0] for p in parts]
|
||||
summary += f" - [{role}] {author}: {part_types}\n"
|
||||
response = f"(未提取到最终文本回复)\n{summary}"
|
||||
|
||||
# 附上 session_id 方便续聊
|
||||
response += f"\n\n---\nsession_id: {session_id}"
|
||||
return response
|
||||
|
||||
|
||||
def main():
|
||||
"""stdio 模式启动 MCP server"""
|
||||
# Windows stdio 二进制模式
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
|
||||
msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
|
||||
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1 +0,0 @@
|
||||
# mcp_server package
|
||||
@ -1,125 +0,0 @@
|
||||
"""
|
||||
封装 ADK Agent 调用
|
||||
将 Dev Agent 的执行包装为可被任务管理器调用的异步函数
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
# 确保项目根目录在 path 里
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions import InMemorySessionService
|
||||
from google.genai import types
|
||||
|
||||
|
||||
class AgentRunner:
|
||||
def __init__(self):
|
||||
# 延迟导入 agent,避免循环导入
|
||||
from my_agent.agent import root_agent
|
||||
self.agent = root_agent
|
||||
self.session_service = InMemorySessionService()
|
||||
self._runner: Runner = None
|
||||
|
||||
def _get_runner(self) -> Runner:
|
||||
if self._runner is None:
|
||||
self._runner = Runner(
|
||||
agent=self.agent,
|
||||
app_name="dev_agent_server",
|
||||
session_service=self.session_service,
|
||||
auto_create_session=True,
|
||||
)
|
||||
return self._runner
|
||||
|
||||
async def run_task(self, task: dict) -> dict:
|
||||
"""
|
||||
执行一个开发任务,返回结构化结果
|
||||
|
||||
Args:
|
||||
task: 任务字典,包含 description, project_path, requirements 等
|
||||
|
||||
Returns:
|
||||
结构化的任务结果
|
||||
"""
|
||||
task_id = task["id"]
|
||||
description = task["description"]
|
||||
project_path = task.get("project_path", "")
|
||||
requirements = task.get("requirements", "")
|
||||
|
||||
# 构建给 agent 的提示词
|
||||
prompt = self._build_prompt(description, project_path, requirements)
|
||||
|
||||
print(f"[AgentRunner] 执行任务 {task_id}: {description[:60]}...")
|
||||
|
||||
runner = self._get_runner()
|
||||
session_id = f"task_{task_id}"
|
||||
user_id = "task_manager"
|
||||
|
||||
all_text = []
|
||||
tool_calls = []
|
||||
|
||||
async for event in runner.run_async(
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
new_message=types.Content(
|
||||
role="user",
|
||||
parts=[types.Part(text=prompt)],
|
||||
),
|
||||
):
|
||||
# 收集文本输出
|
||||
if hasattr(event, 'output') and event.output:
|
||||
content = event.output
|
||||
if hasattr(content, 'parts'):
|
||||
for part in content.parts:
|
||||
if hasattr(part, 'text') and part.text:
|
||||
all_text.append(part.text)
|
||||
if hasattr(part, 'function_call') and part.function_call:
|
||||
tool_calls.append({
|
||||
"name": part.function_call.name,
|
||||
"args": dict(part.function_call.args) if hasattr(part.function_call, 'args') else {},
|
||||
})
|
||||
|
||||
result_text = "".join(all_text)
|
||||
|
||||
return {
|
||||
"summary": self._extract_summary(result_text),
|
||||
"full_response": result_text,
|
||||
"tool_calls_count": len(tool_calls),
|
||||
"tool_calls_sample": tool_calls[:10], # 只保留前 10 个
|
||||
"status": "success" if result_text else "empty",
|
||||
}
|
||||
|
||||
def _build_prompt(self, description: str, project_path: str, requirements: str) -> str:
|
||||
"""构建给 agent 的任务指令"""
|
||||
parts = [
|
||||
"你需要完成以下开发任务:",
|
||||
"",
|
||||
f"**任务描述:**{description}",
|
||||
]
|
||||
if project_path:
|
||||
parts.append(f"**项目路径:**{project_path}")
|
||||
if requirements:
|
||||
parts.append(f"**额外要求:**{requirements}")
|
||||
parts.extend([
|
||||
"",
|
||||
"请按照你的工作流程执行:",
|
||||
"1. 浏览项目结构,理解上下文",
|
||||
"2. 编写或修改代码",
|
||||
"3. 运行编译/构建验证",
|
||||
"4. 完成后,按照你规定的报告格式输出结果",
|
||||
"",
|
||||
"请开始执行。",
|
||||
])
|
||||
return "\n".join(parts)
|
||||
|
||||
def _extract_summary(self, text: str) -> str:
|
||||
"""从 agent 回复中提取摘要"""
|
||||
# 简单处理:取前 1000 字符作为摘要
|
||||
if not text:
|
||||
return "(无响应)"
|
||||
if len(text) <= 1000:
|
||||
return text
|
||||
return text[:1000] + "...(已截断)"
|
||||
@ -1,408 +0,0 @@
|
||||
"""
|
||||
Dev Agent MCP Server(HTTP 模式)
|
||||
将 Dev Agent 暴露为 MCP 服务器,CodeBuddy 通过 HTTP POST MCP 调用
|
||||
|
||||
直接用 FastAPI 处理 MCP JSON-RPC 消息,不依赖 mcp SDK 的 HTTP transport,
|
||||
避免各种版本兼容问题。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 确保项目根目录在 path 里
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.types import Tool, TextContent
|
||||
from fastapi import FastAPI, Request, Response
|
||||
import uvicorn
|
||||
|
||||
from .task_manager import TaskManager
|
||||
from .agent_runner import AgentRunner
|
||||
|
||||
|
||||
# 加载 .env
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "my_agent", ".env"))
|
||||
|
||||
# 配置
|
||||
HOST = os.getenv("MCP_SERVER_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("MCP_SERVER_PORT", "8001"))
|
||||
DATA_DIR = os.getenv("MCP_DATA_DIR", os.path.join(PROJECT_ROOT, "data"))
|
||||
MCP_PATH = "/mcp" # MCP 端点路径
|
||||
|
||||
# 初始化组件
|
||||
task_manager = TaskManager(store_dir=os.path.join(DATA_DIR, "tasks"))
|
||||
agent_runner = AgentRunner()
|
||||
|
||||
mcp_server = Server("dev-agent-mcp-server")
|
||||
|
||||
|
||||
# --- MCP 工具定义 ---
|
||||
|
||||
@mcp_server.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(
|
||||
name="submit_task",
|
||||
description=(
|
||||
"提交一个开发任务给 Dev Agent 执行。任务将异步执行,"
|
||||
"提交后返回 task_id,用 get_task_status 查询进度。"
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "任务的详细描述,要做什么开发工作",
|
||||
},
|
||||
"project_path": {
|
||||
"type": "string",
|
||||
"description": "项目的本地路径,agent 将在此目录下工作",
|
||||
},
|
||||
"requirements": {
|
||||
"type": "string",
|
||||
"description": "(可选)额外的要求或约束条件",
|
||||
},
|
||||
},
|
||||
"required": ["description", "project_path"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_task_status",
|
||||
description="查询任务的当前状态(pending/running/completed/failed/cancelled)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "任务 ID",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_task_result",
|
||||
description="获取任务的执行结果(完成后调用)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "任务 ID",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_task_log",
|
||||
description="获取任务的执行日志",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "任务 ID",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="cancel_task",
|
||||
description="取消一个正在执行或等待中的任务",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "任务 ID",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="list_tasks",
|
||||
description="列出所有任务,可按状态过滤",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "(可选)按状态过滤:pending/running/completed/failed/cancelled",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "(可选)返回数量限制,默认 20",
|
||||
"default": 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# --- MCP 工具实现 ---
|
||||
|
||||
@mcp_server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
if name == "submit_task":
|
||||
return await _submit_task(arguments)
|
||||
elif name == "get_task_status":
|
||||
return _get_task_status(arguments)
|
||||
elif name == "get_task_result":
|
||||
return _get_task_result(arguments)
|
||||
elif name == "get_task_log":
|
||||
return _get_task_log(arguments)
|
||||
elif name == "cancel_task":
|
||||
return await _cancel_task(arguments)
|
||||
elif name == "list_tasks":
|
||||
return _list_tasks(arguments)
|
||||
else:
|
||||
return [TextContent(type="text", text=f"错误:未知工具 {name}")]
|
||||
|
||||
|
||||
async def _submit_task(args: dict):
|
||||
description = args.get("description", "")
|
||||
project_path = args.get("project_path", "")
|
||||
requirements = args.get("requirements", "")
|
||||
|
||||
if not description:
|
||||
return [TextContent(type="text", text="错误:description 不能为空")]
|
||||
if not project_path:
|
||||
return [TextContent(type="text", text="错误:project_path 不能为空")]
|
||||
if not os.path.isdir(project_path):
|
||||
return [TextContent(type="text", text=f"错误:项目路径不存在 {project_path}")]
|
||||
|
||||
task = await task_manager.submit_task(
|
||||
description=description,
|
||||
project_path=project_path,
|
||||
requirements=requirements,
|
||||
)
|
||||
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"任务已提交\n"
|
||||
f"任务ID: {task['id']}\n"
|
||||
f"状态: {task['status']}\n"
|
||||
f"描述: {description[:100]}\n"
|
||||
f"项目: {project_path}\n"
|
||||
f"\n"
|
||||
f"请使用 get_task_status 查询进度。"
|
||||
),
|
||||
)]
|
||||
|
||||
|
||||
def _get_task_status(args: dict):
|
||||
task_id = args.get("task_id", "")
|
||||
task = task_manager.get_task(task_id)
|
||||
if not task:
|
||||
return [TextContent(type="text", text=f"错误:任务不存在 {task_id}")]
|
||||
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"任务状态\n"
|
||||
f"任务ID: {task['id']}\n"
|
||||
f"状态: {task['status']}\n"
|
||||
f"描述: {task['description'][:100]}\n"
|
||||
f"创建时间: {_format_time(task.get('created_at'))}\n"
|
||||
f"更新时间: {_format_time(task.get('updated_at'))}\n"
|
||||
),
|
||||
)]
|
||||
|
||||
|
||||
def _get_task_result(args: dict):
|
||||
task_id = args.get("task_id", "")
|
||||
task = task_manager.get_task(task_id)
|
||||
if not task:
|
||||
return [TextContent(type="text", text=f"错误:任务不存在 {task_id}")]
|
||||
|
||||
result = task.get("result")
|
||||
status = task["status"]
|
||||
|
||||
if status in ("pending", "running"):
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"任务尚未完成(状态:{status}),"
|
||||
f"请稍后再试或使用 get_task_status 查询进度。"
|
||||
),
|
||||
)]
|
||||
|
||||
if not result:
|
||||
return [TextContent(type="text", text=f"任务结果为空,状态:{status}")]
|
||||
|
||||
if isinstance(result, dict):
|
||||
summary = result.get("summary", str(result))
|
||||
tool_count = result.get("tool_calls_count", 0)
|
||||
full = result.get("full_response", "")
|
||||
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"任务结果({status})\n"
|
||||
f"{'='*40}\n"
|
||||
f"{summary}\n"
|
||||
f"{'='*40}\n"
|
||||
f"工具调用次数: {tool_count}\n"
|
||||
f"\n"
|
||||
f"--- 完整回复 ---\n"
|
||||
f"{full[:5000]}"
|
||||
f"\n{'...' if len(full) > 5000 else ''}"
|
||||
),
|
||||
)]
|
||||
|
||||
return [TextContent(type="text", text=str(result))]
|
||||
|
||||
|
||||
def _get_task_log(args: dict):
|
||||
task_id = args.get("task_id", "")
|
||||
task = task_manager.get_task(task_id)
|
||||
if not task:
|
||||
return [TextContent(type="text", text=f"错误:任务不存在 {task_id}")]
|
||||
|
||||
logs = task.get("logs", [])
|
||||
if not logs:
|
||||
return [TextContent(type="text", text="暂无日志")]
|
||||
|
||||
lines = []
|
||||
for log in logs[-50:]:
|
||||
lines.append(f"[{log['time']}] {log['message']}")
|
||||
|
||||
return [TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
|
||||
async def _cancel_task(args: dict):
|
||||
task_id = args.get("task_id", "")
|
||||
success = await task_manager.cancel_task(task_id)
|
||||
if success:
|
||||
return [TextContent(type="text", text=f"任务 {task_id} 已取消")]
|
||||
else:
|
||||
return [TextContent(type="text", text=f"取消失败:任务不存在或已结束")]
|
||||
|
||||
|
||||
def _list_tasks(args: dict):
|
||||
status = args.get("status")
|
||||
limit = int(args.get("limit", 20))
|
||||
tasks = task_manager.list_tasks(status=status)
|
||||
tasks = tasks[:limit]
|
||||
|
||||
if not tasks:
|
||||
return [TextContent(type="text", text="没有找到任务")]
|
||||
|
||||
lines = [f"任务列表(共 {len(tasks)} 个):"]
|
||||
for t in tasks:
|
||||
lines.append(
|
||||
f" [{t['status']}] {t['id']} - {t['description'][:50]} "
|
||||
f"({_format_time(t.get('created_at'))})"
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
|
||||
def _format_time(ts: float = None) -> str:
|
||||
import time
|
||||
if not ts:
|
||||
return "-"
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
|
||||
|
||||
|
||||
# --- HTTP Server ---
|
||||
|
||||
def create_fastapi_app() -> FastAPI:
|
||||
"""创建 FastAPI 应用,处理 MCP JSON-RPC 请求"""
|
||||
fastapi_app = FastAPI(title="Dev Agent MCP Server")
|
||||
|
||||
# 存储 session:session_id -> (read_stream, write_stream, session_task)
|
||||
sessions = {}
|
||||
|
||||
async def _get_or_create_session(session_id: str):
|
||||
"""获取或创建 MCP session(简单的内存会话管理)"""
|
||||
if session_id not in sessions:
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from mcp.server.session import ServerSession
|
||||
|
||||
read_stream_writer, read_stream = MemoryObjectSendStream(100), MemoryObjectReceiveStream(100)
|
||||
write_stream, write_stream_reader = MemoryObjectSendStream(100), MemoryObjectReceiveStream(100)
|
||||
|
||||
session = ServerSession(read_stream, write_stream)
|
||||
task = asyncio.create_task(
|
||||
mcp_server.run(
|
||||
read_stream, write_stream,
|
||||
mcp_server.create_initialization_options(),
|
||||
)
|
||||
)
|
||||
sessions[session_id] = (read_stream_writer, write_stream_reader, task, session)
|
||||
|
||||
return sessions[session_id]
|
||||
|
||||
@fastapi_app.post(MCP_PATH)
|
||||
async def handle_mcp(request: Request):
|
||||
"""处理 MCP JSON-RPC 请求"""
|
||||
body = await request.json()
|
||||
|
||||
# 简单处理:单条请求(非批量)
|
||||
# 从 header 获取 session_id,没有就创建新的
|
||||
session_id = request.headers.get("mcp-session-id") or str(uuid.uuid4())
|
||||
read_stream_writer, write_stream_reader, task, session = await _get_or_create_session(session_id)
|
||||
|
||||
# 把请求写入 read_stream
|
||||
await read_stream_writer.send(body)
|
||||
|
||||
# 等待响应(简单地从 write_stream 读一条)
|
||||
response = await write_stream_reader.receive()
|
||||
|
||||
# 返回响应
|
||||
return Response(
|
||||
content=json.dumps(response),
|
||||
media_type="application/json",
|
||||
headers={"mcp-session-id": session_id},
|
||||
)
|
||||
|
||||
@fastapi_app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "server": "dev-agent-mcp-server"}
|
||||
|
||||
return fastapi_app
|
||||
|
||||
|
||||
async def _task_executor(task: dict) -> dict:
|
||||
"""任务执行器,交给 TaskManager 调用"""
|
||||
return await agent_runner.run_task(task)
|
||||
|
||||
|
||||
async def main():
|
||||
"""启动 MCP Server"""
|
||||
print("=" * 50)
|
||||
print("Dev Agent MCP Server 启动中...")
|
||||
print(f" 监听地址: {HOST}:{PORT}")
|
||||
print(f" 数据目录: {DATA_DIR}")
|
||||
print(f" MCP 端点: http://{HOST}:{PORT}{MCP_PATH}")
|
||||
print(f" 健康检查: http://{HOST}:{PORT}/health")
|
||||
print("=" * 50)
|
||||
|
||||
# 启动任务管理器
|
||||
await task_manager.start(executor=_task_executor)
|
||||
|
||||
# 启动 HTTP server
|
||||
fastapi_app = create_fastapi_app()
|
||||
config = uvicorn.Config(fastapi_app, host=HOST, port=PORT, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
try:
|
||||
await server.serve()
|
||||
finally:
|
||||
await task_manager.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -1,25 +0,0 @@
|
||||
"""
|
||||
Dev Agent MCP Server 启动脚本
|
||||
供 CodeBuddy stdio MCP 调用,使用绝对路径确保可以从任何目录启动
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 项目根目录(脚本所在目录的上一级)
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# 确保项目根目录在 Python path 里
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# 切换到项目目录,确保相对路径正确
|
||||
os.chdir(PROJECT_ROOT)
|
||||
|
||||
# 强制 UTF-8
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
# 导入并运行
|
||||
from mcp_server.stdio_server import main
|
||||
import asyncio
|
||||
|
||||
asyncio.run(main())
|
||||
@ -1,337 +0,0 @@
|
||||
"""
|
||||
Dev Agent MCP Server(stdio 模式)
|
||||
通过标准输入输出与 MCP 客户端通信,适合 CodeBuddy 本地使用。
|
||||
|
||||
用法:python -m mcp_server.stdio_server
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 确保项目根目录在 path 里
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.types import Tool, TextContent
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
from .task_manager import TaskManager
|
||||
from .agent_runner import AgentRunner
|
||||
|
||||
|
||||
# 加载 .env
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, "my_agent", ".env"))
|
||||
|
||||
# 配置
|
||||
DATA_DIR = os.getenv("MCP_DATA_DIR", os.path.join(PROJECT_ROOT, "data"))
|
||||
|
||||
# 初始化组件
|
||||
task_manager = TaskManager(store_dir=os.path.join(DATA_DIR, "tasks"))
|
||||
agent_runner = AgentRunner()
|
||||
|
||||
mcp_server = Server("dev-agent-mcp-server")
|
||||
|
||||
|
||||
# --- MCP 工具定义 ---
|
||||
|
||||
@mcp_server.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(
|
||||
name="submit_task",
|
||||
description=(
|
||||
"提交一个开发任务给 Dev Agent 执行。任务将异步执行,"
|
||||
"提交后返回 task_id,用 get_task_status 查询进度。"
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "任务的详细描述,要做什么开发工作",
|
||||
},
|
||||
"project_path": {
|
||||
"type": "string",
|
||||
"description": "项目的本地路径,agent 将在此目录下工作",
|
||||
},
|
||||
"requirements": {
|
||||
"type": "string",
|
||||
"description": "(可选)额外的要求或约束条件",
|
||||
},
|
||||
},
|
||||
"required": ["description", "project_path"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_task_status",
|
||||
description="查询任务的当前状态(pending/running/completed/failed/cancelled)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "任务 ID",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_task_result",
|
||||
description="获取任务的执行结果(完成后调用)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "任务 ID",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_task_log",
|
||||
description="获取任务的执行日志",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "任务 ID",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="cancel_task",
|
||||
description="取消一个正在执行或等待中的任务",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "任务 ID",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="list_tasks",
|
||||
description="列出所有任务,可按状态过滤",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "(可选)按状态过滤:pending/running/completed/failed/cancelled",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "(可选)返回数量限制,默认 20",
|
||||
"default": 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# --- MCP 工具实现 ---
|
||||
|
||||
@mcp_server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
if name == "submit_task":
|
||||
return await _submit_task(arguments)
|
||||
elif name == "get_task_status":
|
||||
return _get_task_status(arguments)
|
||||
elif name == "get_task_result":
|
||||
return _get_task_result(arguments)
|
||||
elif name == "get_task_log":
|
||||
return _get_task_log(arguments)
|
||||
elif name == "cancel_task":
|
||||
return await _cancel_task(arguments)
|
||||
elif name == "list_tasks":
|
||||
return _list_tasks(arguments)
|
||||
else:
|
||||
return [TextContent(type="text", text=f"错误:未知工具 {name}")]
|
||||
|
||||
|
||||
async def _submit_task(args: dict):
|
||||
description = args.get("description", "")
|
||||
project_path = args.get("project_path", "")
|
||||
requirements = args.get("requirements", "")
|
||||
|
||||
if not description:
|
||||
return [TextContent(type="text", text="错误:description 不能为空")]
|
||||
if not project_path:
|
||||
return [TextContent(type="text", text="错误:project_path 不能为空")]
|
||||
if not os.path.isdir(project_path):
|
||||
return [TextContent(type="text", text=f"错误:项目路径不存在 {project_path}")]
|
||||
|
||||
task = await task_manager.submit_task(
|
||||
description=description,
|
||||
project_path=project_path,
|
||||
requirements=requirements,
|
||||
)
|
||||
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"任务已提交\n"
|
||||
f"任务ID: {task['id']}\n"
|
||||
f"状态: {task['status']}\n"
|
||||
f"描述: {description[:100]}\n"
|
||||
f"项目: {project_path}\n"
|
||||
f"\n"
|
||||
f"请使用 get_task_status 查询进度。"
|
||||
),
|
||||
)]
|
||||
|
||||
|
||||
def _get_task_status(args: dict):
|
||||
task_id = args.get("task_id", "")
|
||||
task = task_manager.get_task(task_id)
|
||||
if not task:
|
||||
return [TextContent(type="text", text=f"错误:任务不存在 {task_id}")]
|
||||
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"任务状态\n"
|
||||
f"任务ID: {task['id']}\n"
|
||||
f"状态: {task['status']}\n"
|
||||
f"描述: {task['description'][:100]}\n"
|
||||
f"创建时间: {_format_time(task.get('created_at'))}\n"
|
||||
f"更新时间: {_format_time(task.get('updated_at'))}\n"
|
||||
),
|
||||
)]
|
||||
|
||||
|
||||
def _get_task_result(args: dict):
|
||||
task_id = args.get("task_id", "")
|
||||
task = task_manager.get_task(task_id)
|
||||
if not task:
|
||||
return [TextContent(type="text", text=f"错误:任务不存在 {task_id}")]
|
||||
|
||||
result = task.get("result")
|
||||
status = task["status"]
|
||||
|
||||
if status in ("pending", "running"):
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"任务尚未完成(状态:{status}),"
|
||||
f"请稍后再试或使用 get_task_status 查询进度。"
|
||||
),
|
||||
)]
|
||||
|
||||
if not result:
|
||||
return [TextContent(type="text", text=f"任务结果为空,状态:{status}")]
|
||||
|
||||
if isinstance(result, dict):
|
||||
summary = result.get("summary", str(result))
|
||||
tool_count = result.get("tool_calls_count", 0)
|
||||
full = result.get("full_response", "")
|
||||
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"任务结果({status})\n"
|
||||
f"{'='*40}\n"
|
||||
f"{summary}\n"
|
||||
f"{'='*40}\n"
|
||||
f"工具调用次数: {tool_count}\n"
|
||||
f"\n"
|
||||
f"--- 完整回复 ---\n"
|
||||
f"{full[:5000]}"
|
||||
f"\n{'...' if len(full) > 5000 else ''}"
|
||||
),
|
||||
)]
|
||||
|
||||
return [TextContent(type="text", text=str(result))]
|
||||
|
||||
|
||||
def _get_task_log(args: dict):
|
||||
task_id = args.get("task_id", "")
|
||||
task = task_manager.get_task(task_id)
|
||||
if not task:
|
||||
return [TextContent(type="text", text=f"错误:任务不存在 {task_id}")]
|
||||
|
||||
logs = task.get("logs", [])
|
||||
if not logs:
|
||||
return [TextContent(type="text", text="暂无日志")]
|
||||
|
||||
lines = []
|
||||
for log in logs[-50:]:
|
||||
lines.append(f"[{log['time']}] {log['message']}")
|
||||
|
||||
return [TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
|
||||
async def _cancel_task(args: dict):
|
||||
task_id = args.get("task_id", "")
|
||||
success = await task_manager.cancel_task(task_id)
|
||||
if success:
|
||||
return [TextContent(type="text", text=f"任务 {task_id} 已取消")]
|
||||
else:
|
||||
return [TextContent(type="text", text=f"取消失败:任务不存在或已结束")]
|
||||
|
||||
|
||||
def _list_tasks(args: dict):
|
||||
status = args.get("status")
|
||||
limit = int(args.get("limit", 20))
|
||||
tasks = task_manager.list_tasks(status=status)
|
||||
tasks = tasks[:limit]
|
||||
|
||||
if not tasks:
|
||||
return [TextContent(type="text", text="没有找到任务")]
|
||||
|
||||
lines = [f"任务列表(共 {len(tasks)} 个):"]
|
||||
for t in tasks:
|
||||
lines.append(
|
||||
f" [{t['status']}] {t['id']} - {t['description'][:50]} "
|
||||
f"({_format_time(t.get('created_at'))})"
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
|
||||
def _format_time(ts: float = None) -> str:
|
||||
import time
|
||||
if not ts:
|
||||
return "-"
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
|
||||
|
||||
|
||||
async def _task_executor(task: dict) -> dict:
|
||||
"""任务执行器,交给 TaskManager 调用"""
|
||||
return await agent_runner.run_task(task)
|
||||
|
||||
|
||||
async def main():
|
||||
"""启动 stdio MCP Server"""
|
||||
# 日志写 stderr,不污染 stdout(MCP 协议通道)
|
||||
print("Dev Agent MCP Server (stdio) 启动中...", file=sys.stderr)
|
||||
|
||||
# 启动任务管理器
|
||||
await task_manager.start(executor=_task_executor)
|
||||
|
||||
try:
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await mcp_server.run(
|
||||
read_stream, write_stream,
|
||||
mcp_server.create_initialization_options(),
|
||||
)
|
||||
finally:
|
||||
await task_manager.stop()
|
||||
print("Dev Agent MCP Server 已停止", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -1,164 +0,0 @@
|
||||
"""
|
||||
异步任务管理器
|
||||
负责任务的提交、调度、状态管理
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
import time
|
||||
import os
|
||||
from typing import Dict, List, Optional, Callable, Awaitable
|
||||
from .task_store import TaskStore
|
||||
|
||||
|
||||
# 任务状态
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self, store_dir: str = "./data/tasks", max_concurrent: int = 3):
|
||||
self.store = TaskStore(store_dir)
|
||||
self.max_concurrent = max_concurrent
|
||||
self._tasks: Dict[str, dict] = {}
|
||||
self._running = 0
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self._worker_task: Optional[asyncio.Task] = None
|
||||
self._queue: asyncio.Queue = asyncio.Queue()
|
||||
self._executor: Optional[Callable[[dict], Awaitable[dict]]] = None
|
||||
|
||||
async def start(self, executor: Callable[[dict], Awaitable[dict]]):
|
||||
"""启动任务管理器,executor 是实际执行任务的异步函数"""
|
||||
self._executor = executor
|
||||
self._worker_task = asyncio.create_task(self._worker_loop())
|
||||
print(f"[TaskManager] 已启动,最大并发: {self.max_concurrent}")
|
||||
|
||||
async def stop(self):
|
||||
"""停止任务管理器"""
|
||||
if self._worker_task:
|
||||
self._worker_task.cancel()
|
||||
try:
|
||||
await self._worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
print("[TaskManager] 已停止")
|
||||
|
||||
async def submit_task(self, description: str, project_path: str,
|
||||
requirements: str = "", extra: dict = None) -> dict:
|
||||
"""提交一个新任务"""
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
now = time.time()
|
||||
task = {
|
||||
"id": task_id,
|
||||
"description": description,
|
||||
"project_path": project_path,
|
||||
"requirements": requirements,
|
||||
"status": STATUS_PENDING,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"result": None,
|
||||
"logs": [],
|
||||
"extra": extra or {},
|
||||
}
|
||||
self._tasks[task_id] = task
|
||||
self.store.save(task)
|
||||
await self._queue.put(task_id)
|
||||
print(f"[TaskManager] 任务已提交: {task_id} - {description[:50]}")
|
||||
return task
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[dict]:
|
||||
"""获取任务详情"""
|
||||
# 优先从内存取,没有再从文件读
|
||||
if task_id in self._tasks:
|
||||
return self._tasks[task_id]
|
||||
return self.store.load(task_id)
|
||||
|
||||
def get_task_status(self, task_id: str) -> Optional[str]:
|
||||
task = self.get_task(task_id)
|
||||
return task["status"] if task else None
|
||||
|
||||
def list_tasks(self, status: str = None) -> List[dict]:
|
||||
"""列出所有任务,可按状态过滤"""
|
||||
tasks = list(self._tasks.values())
|
||||
# 加上磁盘上的任务
|
||||
disk_tasks = self.store.list_all()
|
||||
disk_ids = {t["id"] for t in tasks}
|
||||
for t in disk_tasks:
|
||||
if t["id"] not in disk_ids:
|
||||
tasks.append(t)
|
||||
if status:
|
||||
tasks = [t for t in tasks if t["status"] == status]
|
||||
tasks.sort(key=lambda t: t.get("created_at", 0), reverse=True)
|
||||
return tasks
|
||||
|
||||
def append_log(self, task_id: str, message: str):
|
||||
"""追加任务日志"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
if "logs" not in task:
|
||||
task["logs"] = []
|
||||
task["logs"].append({
|
||||
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"message": message,
|
||||
})
|
||||
if len(task["logs"]) > 500:
|
||||
task["logs"] = task["logs"][-500:]
|
||||
# 异步持久化(这里直接同步写,简单起见)
|
||||
self.store.save(task)
|
||||
|
||||
async def cancel_task(self, task_id: str) -> bool:
|
||||
"""取消任务"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return False
|
||||
if task["status"] in (STATUS_COMPLETED, STATUS_FAILED, STATUS_CANCELLED):
|
||||
return False
|
||||
task["status"] = STATUS_CANCELLED
|
||||
task["updated_at"] = time.time()
|
||||
self.store.save(task)
|
||||
print(f"[TaskManager] 任务已取消: {task_id}")
|
||||
return True
|
||||
|
||||
async def _worker_loop(self):
|
||||
"""后台 worker,从队列取任务执行"""
|
||||
while True:
|
||||
try:
|
||||
task_id = await self._queue.get()
|
||||
async with self._semaphore:
|
||||
await self._execute_task(task_id)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[TaskManager] Worker 异常: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _execute_task(self, task_id: str):
|
||||
"""执行单个任务"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task or task["status"] == STATUS_CANCELLED:
|
||||
return
|
||||
|
||||
task["status"] = STATUS_RUNNING
|
||||
task["updated_at"] = time.time()
|
||||
self.store.save(task)
|
||||
print(f"[TaskManager] 开始执行: {task_id}")
|
||||
|
||||
try:
|
||||
result = await self._executor(task)
|
||||
task["result"] = result
|
||||
# 检查是否已被取消
|
||||
if task["status"] == STATUS_CANCELLED:
|
||||
return
|
||||
task["status"] = STATUS_COMPLETED
|
||||
print(f"[TaskManager] 任务完成: {task_id}")
|
||||
except Exception as e:
|
||||
task["status"] = STATUS_FAILED
|
||||
task["result"] = {"error": str(e)}
|
||||
self.append_log(task_id, f"执行失败: {e}")
|
||||
print(f"[TaskManager] 任务失败: {task_id} - {e}")
|
||||
finally:
|
||||
task["updated_at"] = time.time()
|
||||
self.store.save(task)
|
||||
@ -1,56 +0,0 @@
|
||||
"""
|
||||
任务持久化存储
|
||||
使用 JSON 文件存储任务数据
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class TaskStore:
|
||||
def __init__(self, store_dir: str):
|
||||
self.store_dir = os.path.abspath(store_dir)
|
||||
os.makedirs(self.store_dir, exist_ok=True)
|
||||
|
||||
def _task_path(self, task_id: str) -> str:
|
||||
return os.path.join(self.store_dir, f"{task_id}.json")
|
||||
|
||||
def save(self, task: dict) -> None:
|
||||
task["updated_at"] = time.time()
|
||||
path = self._task_path(task["id"])
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(task, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def load(self, task_id: str) -> Optional[dict]:
|
||||
path = self._task_path(task_id)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def list_all(self) -> List[dict]:
|
||||
tasks = []
|
||||
for filename in os.listdir(self.store_dir):
|
||||
if filename.endswith(".json"):
|
||||
task_id = filename[:-5]
|
||||
task = self.load(task_id)
|
||||
if task:
|
||||
tasks.append(task)
|
||||
tasks.sort(key=lambda t: t.get("created_at", 0), reverse=True)
|
||||
return tasks
|
||||
|
||||
def append_log(self, task_id: str, log_line: str) -> None:
|
||||
task = self.load(task_id)
|
||||
if not task:
|
||||
return
|
||||
if "logs" not in task:
|
||||
task["logs"] = []
|
||||
task["logs"].append({
|
||||
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"message": log_line,
|
||||
})
|
||||
# 日志最多保留 500 条
|
||||
if len(task["logs"]) > 500:
|
||||
task["logs"] = task["logs"][-500:]
|
||||
self.save(task)
|
||||
@ -1 +0,0 @@
|
||||
# mcp_tools package
|
||||
@ -1 +0,0 @@
|
||||
# command_executor package
|
||||
@ -1,127 +0,0 @@
|
||||
"""
|
||||
终端命令执行 MCP Server
|
||||
通过 MCP 协议暴露 run_command 命令,供 Dev Agent 使用
|
||||
"""
|
||||
import asyncio
|
||||
import subprocess
|
||||
import shlex
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
|
||||
def log(msg):
|
||||
"""写日志到 stderr,不污染 MCP stdio 协议通道"""
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
app = Server("command-executor")
|
||||
|
||||
|
||||
@app.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(
|
||||
name="run_command",
|
||||
description="在终端中执行一条命令,返回输出结果。适用于编译、构建、运行测试等场景。",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "要执行的命令,如 'npm run build'、'npm test' 等",
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "命令执行的工作目录,默认使用当前目录",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "超时时间(秒),默认 300 秒",
|
||||
"default": 300,
|
||||
},
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@app.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
if name != "run_command":
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
cmd = arguments.get("command", "")
|
||||
cwd = arguments.get("cwd") or os.getcwd()
|
||||
timeout = int(arguments.get("timeout", 300))
|
||||
|
||||
if not cmd:
|
||||
return [TextContent(type="text", text="错误:命令不能为空")]
|
||||
|
||||
log(f" [command] {cmd}")
|
||||
log(f" [cwd] {cwd}")
|
||||
log(f" [timeout] {timeout}s")
|
||||
log(f" [PATH] {os.environ.get('PATH', 'N/A')[:200]}")
|
||||
log(f" [where npm] {__import__('shutil').which('npm.cmd')}")
|
||||
|
||||
try:
|
||||
# 使用异步 subprocess,避免阻塞 asyncio 事件循环
|
||||
# 在 Windows 上,npm 是 .cmd 文件,需要通过 shell 执行
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
output_parts = []
|
||||
if stdout:
|
||||
output_parts.append(f"[stdout]\n{stdout}")
|
||||
if stderr:
|
||||
output_parts.append(f"[stderr]\n{stderr}")
|
||||
|
||||
output = "\n".join(output_parts) if output_parts else "(无输出)"
|
||||
|
||||
max_len = 10000
|
||||
if len(output) > max_len:
|
||||
output = output[:max_len] + f"\n\n...(输出已截断,共 {len(output)} 字符)"
|
||||
|
||||
status = "成功" if proc.returncode == 0 else f"失败 (退出码 {proc.returncode})"
|
||||
log(f" [result] {status}")
|
||||
return [TextContent(type="text", text=f"命令执行{status}\n{output}")]
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# 超时后杀进程
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
log(f" [error] 超时")
|
||||
return [TextContent(type="text", text=f"命令执行超时({timeout}秒): {cmd}")]
|
||||
except Exception as e:
|
||||
log(f" [error] {e}")
|
||||
return [TextContent(type="text", text=f"命令执行出错: {e}")]
|
||||
|
||||
|
||||
async def main():
|
||||
log("MCP 命令执行服务器启动中...")
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await app.run(
|
||||
read_stream, write_stream,
|
||||
app.create_initialization_options()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
19
requirements.txt
Normal file
19
requirements.txt
Normal file
@ -0,0 +1,19 @@
|
||||
# 核心框架
|
||||
google-adk>=2.5.0
|
||||
google-genai>=2.14.0
|
||||
litellm>=1.80.0
|
||||
|
||||
# MCP
|
||||
mcp>=1.29.0
|
||||
mcp-types>=2.0.0
|
||||
httpx>=0.28.0
|
||||
|
||||
# Web 服务
|
||||
fastapi>=0.140.0
|
||||
uvicorn>=0.51.0
|
||||
|
||||
# 配置
|
||||
python-dotenv>=1.2.0
|
||||
|
||||
# A2A(可选)
|
||||
a2a-sdk>=1.1.2
|
||||
195
task_receiver.py
Normal file
195
task_receiver.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""A2A 网关任务接收端点(通用版):接收网关主动推送的任务,后台执行指定 agent,完成后回传结果。
|
||||
|
||||
本文件为工厂模块,供任意 agent 复用。每个 agent 传入自己的 ADK App 对象即可:
|
||||
|
||||
from task_receiver import create_task_router
|
||||
fastapi_app.include_router(create_task_router(dev_app))
|
||||
|
||||
契约(网关 relay.py dispatch_command 推送):
|
||||
POST {endpoint}/tasks/{request_id}
|
||||
body: {"auth": GATEWAY_AUTH, "request_id": str, "payload": {...}}
|
||||
成功响应 202(立即确认),执行完成后由后台线程回传网关 /api/agent/result。
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import gateway_client
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.agents.run_config import RunConfig, StreamingMode
|
||||
from google.genai import types as genai_types
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sessions_db_path() -> str:
|
||||
"""返回会话数据库路径(与 chat.py 一致,位于项目 data 目录)。"""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
data_dir = os.path.join(here, "data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
return os.path.join(data_dir, "sessions.db")
|
||||
|
||||
|
||||
def _first_sentence(text: str) -> str:
|
||||
"""从文本中提取第一个完整句子(用于"Agent 接受任务回复"展示)。
|
||||
|
||||
ADK 流式事件会把回复拆成多个 text 片段,第一个片段常只有一两个字。这里把
|
||||
累积文本按句子结束符切分,返回第一句完整内容;若没有句子结束符则回退为
|
||||
完整文本。
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
for sep in ("。", "!", "?", "!", "?", "\n", ";"):
|
||||
idx = text.find(sep)
|
||||
if idx != -1:
|
||||
return text[: idx + 1].strip()
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _payload_to_prompt(payload: dict) -> str:
|
||||
"""将网关任务 payload 转换为 agent 的用户指令。"""
|
||||
if not payload:
|
||||
return "请执行任务并汇报结果。"
|
||||
if "prompt" in payload and payload["prompt"]:
|
||||
return str(payload["prompt"])
|
||||
if "cmd" in payload and payload["cmd"]:
|
||||
return f"请执行以下命令并汇报执行结果:\n{payload['cmd']}"
|
||||
# 兜底:序列化整个 payload
|
||||
return "请根据以下任务载荷执行并汇报结果:\n" + str(payload)
|
||||
|
||||
|
||||
def create_task_router(app):
|
||||
"""根据指定的 ADK App 创建网关任务接收 router。
|
||||
|
||||
Args:
|
||||
app: ADK App 容器(如 agents.my_agent.app.dev_app),需具备 .name 属性。
|
||||
"""
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
class _TaskCancelled(Exception):
|
||||
pass
|
||||
|
||||
async def _run_agent_once(prompt: str, request_id: str) -> tuple[str, str]:
|
||||
"""运行一次 agent,返回 (接受任务后的首条回复, 最终总结)。
|
||||
|
||||
必须使用 Runner + SqliteSessionService + streaming_mode=SSE(与 chat.py
|
||||
一致):InMemoryRunner 无法驱动带 compaction 配置的 App 容器,会导致
|
||||
LLM 不调用、回复为空("Root node was cancelled")。
|
||||
"""
|
||||
runner = Runner(
|
||||
app=app,
|
||||
session_service=SqliteSessionService(db_path=_sessions_db_path()),
|
||||
artifact_service=InMemoryArtifactService(),
|
||||
auto_create_session=True,
|
||||
)
|
||||
session_id = f"task-{request_id}"
|
||||
message = genai_types.Content(parts=[genai_types.Part(text=prompt)])
|
||||
texts: list[str] = []
|
||||
final_text = ""
|
||||
async for event in runner.run_async(
|
||||
user_id="gateway",
|
||||
session_id=session_id,
|
||||
new_message=message,
|
||||
run_config=RunConfig(streaming_mode=StreamingMode.SSE),
|
||||
):
|
||||
# 可中断:每收到一个事件检查一次停止标志
|
||||
if gateway_client.is_stop_requested(request_id):
|
||||
raise _TaskCancelled()
|
||||
# 只收集非思考(thought)的用户可见文本:过滤掉 thought 片段
|
||||
if event.content and event.content.parts:
|
||||
for part in event.content.parts:
|
||||
text = getattr(part, "text", None)
|
||||
is_thought = getattr(part, "thought", False)
|
||||
if not text or is_thought:
|
||||
continue
|
||||
if event.is_final_response():
|
||||
final_text += text
|
||||
else:
|
||||
texts.append(text)
|
||||
# 最终总结 = final response 文本;首条回复 = 累积中间文本直到完整句子
|
||||
summary = final_text.strip() or "".join(texts).strip() or "(无输出)"
|
||||
reply = _first_sentence("".join(texts)) or summary
|
||||
return reply, summary
|
||||
|
||||
async def _execute_and_report(request_id: str, payload: dict) -> None:
|
||||
"""后台执行:执行 agent,成功后回传 success,异常回传 failed,被取消时回传 cancelled。
|
||||
|
||||
此协程通过 asyncio.create_task 在主事件循环中调度,与 agent 的 MCP
|
||||
session / opentelemetry 上下文保持同一事件循环,避免跨线程/跨 loop 导致的
|
||||
"Root node was cancelled" / "Failed to detach context" 崩溃。
|
||||
|
||||
执行过程中每步都检查停止标志(gateway_client.is_stop_requested),一旦收到
|
||||
取消指令(task_stop)即中断并回传失败(cancelled),网关 on_result 终态保护
|
||||
会将其置回就绪。
|
||||
"""
|
||||
gateway_client.clear_stop_requested(request_id)
|
||||
try:
|
||||
prompt = _payload_to_prompt(payload)
|
||||
reply, summary = await _run_agent_once(prompt, request_id)
|
||||
if gateway_client.is_stop_requested(request_id):
|
||||
raise _TaskCancelled()
|
||||
gateway_client.report_result(
|
||||
request_id,
|
||||
agent_id=app.name,
|
||||
status="success",
|
||||
progress=100,
|
||||
result={"reply": reply, "output": summary},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("agent task cancelled (loop) request=%s", request_id)
|
||||
gateway_client.report_result(
|
||||
request_id,
|
||||
agent_id=app.name,
|
||||
status="failed",
|
||||
progress=100,
|
||||
error_info="cancelled by user",
|
||||
)
|
||||
except _TaskCancelled:
|
||||
logger.info("agent task cancelled request=%s", request_id)
|
||||
gateway_client.report_result(
|
||||
request_id,
|
||||
agent_id=app.name,
|
||||
status="failed",
|
||||
progress=100,
|
||||
error_info="cancelled by user",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("agent task failed request=%s", request_id)
|
||||
gateway_client.report_result(
|
||||
request_id,
|
||||
agent_id=app.name,
|
||||
status="failed",
|
||||
progress=100,
|
||||
error_info=str(e),
|
||||
)
|
||||
|
||||
@router.post("/tasks/{request_id}")
|
||||
async def receive_task(request_id: str, request: Request):
|
||||
"""接收网关推送的任务,立即 202 确认,后台执行。
|
||||
|
||||
body 中若携带 cli_session_id,则同时启动该会话的 SSE 停止指令订阅线程,
|
||||
用于接收网关取消任务时下发的 task_stop。
|
||||
|
||||
执行在 asyncio.create_task 中调度(与 MCP session 同事件循环),
|
||||
不再使用新线程 + asyncio.run,避免跨事件循环导致 agent 崩溃。
|
||||
"""
|
||||
body = await request.json()
|
||||
if body.get("auth") != gateway_client.GATEWAY_AUTH:
|
||||
raise HTTPException(status_code=401, detail="invalid auth")
|
||||
payload = body.get("payload") or {}
|
||||
cli_session_id = body.get("cli_session_id")
|
||||
if cli_session_id:
|
||||
gateway_client.start_stop_listener(cli_session_id)
|
||||
asyncio.create_task(
|
||||
_execute_and_report(request_id, payload),
|
||||
name=f"task-{request_id[:8]}",
|
||||
)
|
||||
logger.info("task received request=%s payload=%s", request_id, payload)
|
||||
return JSONResponse(status_code=202, content={"ok": True, "request_id": request_id, "status": "accepted"})
|
||||
|
||||
return router
|
||||
270
watch_session.py
Normal file
270
watch_session.py
Normal file
@ -0,0 +1,270 @@
|
||||
"""
|
||||
Session 实时监控脚本
|
||||
输入 session_id,实时打印该会话中的所有新事件(用户输入、模型回复、工具调用等)。
|
||||
|
||||
用法:
|
||||
python watch_session.py --session <session_id>
|
||||
python watch_session.py -s <session_id> --agent my_agent
|
||||
python watch_session.py -s test_001 --poll 2.0
|
||||
|
||||
支持的参数:
|
||||
--session / -s : 会话 ID(必填)
|
||||
--agent / -a : Agent 名称,默认 my_agent(可选:my_agent / luna_agent / qwen_agent)
|
||||
--user / -u : 用户 ID,默认 codebuddy
|
||||
--poll / -p : 轮询间隔(秒),默认 1.5
|
||||
--url : API Server 地址,默认根据 agent 自动选择
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
# Agent 对应的默认 API 地址
|
||||
AGENT_URLS = {
|
||||
"my_agent": "http://127.0.0.1:8001",
|
||||
"luna_agent": "http://127.0.0.1:8002",
|
||||
"qwen_agent": "http://127.0.0.1:8003",
|
||||
}
|
||||
|
||||
# Agent 名称别名
|
||||
AGENT_ALIASES = {
|
||||
"my": "my_agent",
|
||||
"default": "my_agent",
|
||||
"aq": "my_agent",
|
||||
"luna": "luna_agent",
|
||||
"gpt": "luna_agent",
|
||||
"qwen": "qwen_agent",
|
||||
"astron": "qwen_agent",
|
||||
}
|
||||
|
||||
|
||||
def resolve_agent(name: str) -> str:
|
||||
"""解析 agent 名称"""
|
||||
name = name.strip().lower()
|
||||
if name in AGENT_URLS:
|
||||
return name
|
||||
if name in AGENT_ALIASES:
|
||||
return AGENT_ALIASES[name]
|
||||
for full_name in AGENT_URLS:
|
||||
if name in full_name:
|
||||
return full_name
|
||||
raise ValueError(
|
||||
f"未知的 agent: {name}\n"
|
||||
f"可用: {list(AGENT_URLS.keys())}\n"
|
||||
f"别名: {list(AGENT_ALIASES.keys())}"
|
||||
)
|
||||
|
||||
|
||||
def get_api_url(agent_name: str, custom_url: str | None) -> str:
|
||||
"""获取 API 地址"""
|
||||
if custom_url:
|
||||
return custom_url.rstrip("/")
|
||||
return AGENT_URLS[agent_name]
|
||||
|
||||
|
||||
def fetch_session(api_url: str, app_name: str, user_id: str, session_id: str) -> dict | None:
|
||||
"""获取会话数据"""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{api_url}/apps/{app_name}/users/{user_id}/sessions/{session_id}",
|
||||
timeout=10.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
print(f"[警告] 获取会话失败 (HTTP {resp.status_code}): {resp.text[:200]}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[警告] 连接 API Server 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def format_event(event: dict, index: int) -> str:
|
||||
"""格式化单个事件为可读字符串"""
|
||||
content = event.get("content", {})
|
||||
role = content.get("role", "?")
|
||||
author = event.get("author", "")
|
||||
parts = content.get("parts", [])
|
||||
timestamp = event.get("timestamp", 0)
|
||||
|
||||
time_str = ""
|
||||
if timestamp:
|
||||
try:
|
||||
time_str = datetime.fromtimestamp(timestamp).strftime("%H:%M:%S")
|
||||
except Exception:
|
||||
time_str = str(timestamp)
|
||||
|
||||
role_label = {
|
||||
"user": "👤 用户",
|
||||
"model": "🤖 模型",
|
||||
"function": "🔧 工具",
|
||||
}.get(role, f"❓ {role}")
|
||||
|
||||
author_str = f" [{author}]" if author else ""
|
||||
header = f"\n{'─' * 60}\n[{time_str}] {role_label}{author_str} #{index}\n{'─' * 60}"
|
||||
|
||||
lines = [header]
|
||||
|
||||
for part in parts:
|
||||
if "text" in part:
|
||||
text = part["text"]
|
||||
# thoughts 单独标注
|
||||
if part.get("thought"):
|
||||
lines.append(f"💭 [思考中]\n{text}\n")
|
||||
else:
|
||||
lines.append(f"{text}\n")
|
||||
|
||||
elif "functionCall" in part:
|
||||
call = part["functionCall"]
|
||||
args_str = json.dumps(call.get("args", {}), ensure_ascii=False, indent=2)
|
||||
# 太长就截断
|
||||
if len(args_str) > 500:
|
||||
args_str = args_str[:500] + f"\n... (共 {len(args_str)} 字符,已截断)"
|
||||
lines.append(f"📞 调用工具: {call.get('name', '?')}\n{args_str}\n")
|
||||
|
||||
elif "functionResponse" in part:
|
||||
resp = part["functionResponse"]
|
||||
resp_name = resp.get("name", "?")
|
||||
resp_content = resp.get("content", [])
|
||||
# 提取文本内容
|
||||
text_parts = []
|
||||
for c in resp_content:
|
||||
if isinstance(c, dict) and c.get("type") == "text":
|
||||
text_parts.append(c.get("text", ""))
|
||||
elif isinstance(c, str):
|
||||
text_parts.append(c)
|
||||
result_text = "\n".join(text_parts) if text_parts else str(resp_content)
|
||||
# 太长就截断
|
||||
if len(result_text) > 800:
|
||||
result_text = result_text[:800] + f"\n... (共 {len(result_text)} 字符,已截断)"
|
||||
lines.append(f"✅ 工具返回: {resp_name}\n{result_text}\n")
|
||||
|
||||
elif "code" in part:
|
||||
code = part["code"]
|
||||
lines.append(f"📝 代码片段:\n```\n{code}\n```\n")
|
||||
|
||||
elif "executableCode" in part:
|
||||
ec = part["executableCode"]
|
||||
lines.append(f"💻 可执行代码 ({ec.get('language', '?')}):\n```\n{ec.get('code', '')[:500]}\n```\n")
|
||||
|
||||
else:
|
||||
part_types = list(part.keys())
|
||||
lines.append(f"[其他内容] 类型: {part_types}\n")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def extract_events(session_data: dict) -> list[dict]:
|
||||
"""从会话数据中提取事件列表"""
|
||||
return session_data.get("events", []) or []
|
||||
|
||||
|
||||
def watch_session(
|
||||
api_url: str,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
poll_interval: float,
|
||||
):
|
||||
"""实时监控会话"""
|
||||
print(f"🔍 开始监控会话")
|
||||
print(f" Agent: {app_name}")
|
||||
print(f" API: {api_url}")
|
||||
print(f" 用户: {user_id}")
|
||||
print(f" 会话ID: {session_id}")
|
||||
print(f" 轮询间隔: {poll_interval}s")
|
||||
print(f" 按 Ctrl+C 退出\n")
|
||||
|
||||
last_event_count = 0
|
||||
|
||||
# 首次获取,如果有历史事件,问要不要回放
|
||||
session = fetch_session(api_url, app_name, user_id, session_id)
|
||||
if session is None:
|
||||
print(f"会话 [{session_id}] 不存在,请检查 session_id 和 agent 是否正确。")
|
||||
print(f"提示: 确认 {app_name} 的 API Server 是否已启动({api_url})")
|
||||
return
|
||||
|
||||
events = extract_events(session)
|
||||
existing_count = len(events)
|
||||
|
||||
if existing_count > 0:
|
||||
print(f"📜 该会话已有 {existing_count} 条历史事件。")
|
||||
try:
|
||||
choice = input("是否打印历史事件?(y/n,默认 n): ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n已退出。")
|
||||
return
|
||||
|
||||
if choice in ("y", "yes"):
|
||||
for i, event in enumerate(events, 1):
|
||||
print(format_event(event, i))
|
||||
last_event_count = existing_count
|
||||
print(f"\n✅ 历史事件回放完毕,共 {existing_count} 条。")
|
||||
print(f" 现在开始监控新事件...\n")
|
||||
else:
|
||||
last_event_count = existing_count
|
||||
print(f" 跳过历史,从第 {existing_count + 1} 条开始监控新事件...\n")
|
||||
else:
|
||||
print("📭 该会话目前没有事件,等待新事件...\n")
|
||||
|
||||
# 开始轮询
|
||||
try:
|
||||
while True:
|
||||
time.sleep(poll_interval)
|
||||
session = fetch_session(api_url, app_name, user_id, session_id)
|
||||
if session is None:
|
||||
continue
|
||||
|
||||
events = extract_events(session)
|
||||
current_count = len(events)
|
||||
|
||||
if current_count > last_event_count:
|
||||
# 有新事件
|
||||
for i in range(last_event_count, current_count):
|
||||
print(format_event(events[i], i + 1))
|
||||
last_event_count = current_count
|
||||
|
||||
# 检测是否结束(最后一条是 model role 的 final 事件)
|
||||
# 这里不自动退出,继续轮询,因为可能有多轮对话
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n\n👋 已停止监控。共检测到 {last_event_count} 条事件。")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Session 实时监控工具")
|
||||
parser.add_argument("--session", "-s", required=True, help="会话 ID")
|
||||
parser.add_argument("--agent", "-a", default="my_agent",
|
||||
help="Agent 名称(默认 my_agent)")
|
||||
parser.add_argument("--user", "-u", default="codebuddy",
|
||||
help="用户 ID(默认 codebuddy)")
|
||||
parser.add_argument("--poll", "-p", type=float, default=1.5,
|
||||
help="轮询间隔秒数(默认 1.5)")
|
||||
parser.add_argument("--url", default=None,
|
||||
help="自定义 API Server 地址(覆盖默认)")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
agent_name = resolve_agent(args.agent)
|
||||
except ValueError as e:
|
||||
print(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
api_url = get_api_url(agent_name, args.url)
|
||||
|
||||
watch_session(
|
||||
api_url=api_url,
|
||||
app_name=agent_name,
|
||||
user_id=args.user,
|
||||
session_id=args.session,
|
||||
poll_interval=args.poll,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user