Compare commits

...

28 Commits

Author SHA1 Message Date
3a4876ab00 fix: 修复Python模块导入错误并优化Chat功能
- 修复 core/agents/api 模块导入问题
- 优化 ChatInput 组件交互体验
- 增强 agent_handler 和 agent_service 功能
- 调整 Chat 页面样式和布局

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:27:07 +08:00
52a9d02342 fix: 右侧边栏仅在有会话时显示 2026-03-15 21:48:39 +08:00
b8944813cf feat: Chat 页面新增群聊功能入口 2026-03-15 21:47:45 +08:00
d9484f16c7 refactor: 简化 Chat 页面移除推荐智能体模块
- 移除 selectAgentAndCreateSession 方法
- 移除推荐智能体卡片区域
- 精简页面代码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:44:20 +08:00
0e0f988264 feat: 增强 Agent 意图识别和上下文管理
- 新增 intent_router.py 意图路由模块
- 优化 context.py 上下文管理
- 增强 loop.py Agent 运行循环
- 更新 memory.py 记忆模块
- 修复 builtin.py 工具函数

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:44:00 +08:00
d72c6a3f25 feat: 优化 Chat 页面和聊天样式
- 新增 chat.css 聊天样式文件
- 优化 Chat.vue 页面交互
- 更新 chat.ts 聊天逻辑

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:43:37 +08:00
1e8e0533fd fix: 修复 AgentLoop 消息保存逻辑
- 同时保存 assistant 消息的 content 和 tool_calls
- 修复多轮工具调用场景下的消息丢失问题

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:52:10 +08:00
20f2ea8c38 refactor: 重构 Plan 页面代码结构
- 抽取 usePlan composable 逻辑
- 分离 plan.css 样式文件
- 简化 Plan.vue 组件代码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:51:29 +08:00
c9f19f43fb feat: 新增沙盒执行模块
- 新增 bwrap_sandbox.py bwrap 沙盒实现
- 新增 gvisor_sandbox.py gVisor 沙盒实现
- 新增 sandbox_execution.py 沙盒执行入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:38 +08:00
6b1258e9ca style: 更新前端基础样式
- 调整 reset.css 重置样式
- 更新 variables.css 变量定义

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:20 +08:00
1afa88e812 feat: 增强 core/agents 工具和 API
- 新增 loop.py Agent 运行循环
- 优化 memory.py 记忆模块
- 扩展 api/routes.py 接口
- 更新 tools 模块:builtin.py, manager.py, __init__.py
- 新增 .env.example 配置示例
- 更新 requirements.txt 依赖

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:49:40 +08:00
31f0feafb5 feat: 增强会话管理和 Agent 服务
- 优化 session_handler 会话处理逻辑
- 增强 agent_service Agent 服务功能
- 新增 chat_repository 仓储方法
- 更新 agent_handler 和 chat_group_handler
- 更新数据模型 agent 和 chat_session

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:49:27 +08:00
bce8b9240b feat: 优化 Chat 页面和 Agents 页面
- 优化 Chat 页面交互和消息显示
- 增强 Agents 页面功能
- 改进 ChatAgentSelector 组件
- 优化 ChatMessage 和 ChatSidebar 组件
- 更新聊天逻辑 useAgents 和 chat 模块

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:48:42 +08:00
a35a88effc chore: 添加项目启动脚本
新增 start-all.bat 和 start-all.sh 用于一键启动所有服务

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:29:35 +08:00
903b772a06 chore: 更新配置文件和环境变量示例
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:29:22 +08:00
249e7e577a feat: 新增 core/agents 模块和 nanobot
- 新增 agents 模块,包含 agent、api、skills 等子模块
- 新增 nanobot 项目,支持多渠道集成
- 添加启动脚本 start-all.bat 和 start-all.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:29:12 +08:00
ecb6be6463 feat: 更新 Web 前端页面
- 更新 Agents、Chat、Settings 等页面
- 新增 ModelAPIs 页面
- 更新各个模块的 composables
- 更新 vite 配置和依赖版本

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:29:01 +08:00
71e8cc59d5 feat: 更新 Server 后端服务
- 更新 agent handler 和 service 层
- 新增 chat_group handler 和 service
- 删除废弃的 chat_handler
- 更新 tool 相关处理
- 更新 API 文档和依赖

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:26:27 +08:00
237ab9f6d7 chore: 清理旧版 teams 需求文档
移除已归档的 teams 目录下的需求分析文档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:24:42 +08:00
194fe22e26 chore: 删除旧版 agent/app 模块
移除已废弃的旧版 agent 实现,使用新的 core/agents 模块替代

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:24:20 +08:00
7b5d4b20a5 feat: 增强Chat记忆模块功能
- 新增记忆搜索API
- 集成向量检索能力
- 引入智能摘要和预压缩机制

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:34:38 +08:00
e5ea4ff359 feat: 更新数据库和后端服务
- 新增chat_sessions和chat_groups数据库表
- 更新skill_handler和model相关接口
- 修改main.go注册新路由

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:33:54 +08:00
e19a0ba673 feat: 更新Chat前端页面
- 优化Chat组件交互体验
- 修复智能体选择逻辑
- 新增chat页面样式和脚本

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:33:25 +08:00
77f5b4872e feat: 新增Chat会话和群聊API
- 新增chat_session相关模型、仓库和服务
- 新增chat_group相关模型、仓库和服务
- 新增session_handler和chat_group_handler
- 实现会话管理和群聊功能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:31:42 +08:00
4045dad903 refactor: Agents页面重构为独立模块
- 新增 web/src/views/agents/useAgents.ts: Agents页面的组合式函数
- 新增 web/src/views/agents/agents.css: Agents页面样式文件
- 精简 web/src/views/Agents.vue: 保留主入口,引用新的模块

将Agents页面的逻辑拆分为独立的TS文件和CSS文件,提升代码可维护性

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:32:08 +08:00
2a9326ef5f refactor: Skill状态字段从int改为string类型
- model/skill.go: Status字段从 int 改为 string,支持 "active"/"inactive"
- handler/skill_handler.go: 适配Status字段的类型变化,处理"1"/"0"和"active"/"inactive"两种格式
- repository/skill_repo.go: 更新Status字段的空值判断逻辑
- service/skill_service.go: 同步更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:31:57 +08:00
a07cc4498d feat: 更新前端页面
- Agents.vue: 大幅更新agent管理界面
- App.vue: 更新应用布局
- 各页面: 更新Account、Database、Knowledge、Memory、Script、Skill、Tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:19:04 +08:00
5dc2e403e9 feat: 更新后端服务
- agent_handler.go: 新增agent管理接口
- agent_service.go: 扩展agent服务逻辑
- skill_handler.go: 更新skill接口
- chat_service.go: 更新chat服务
- model相关: 新增model仓库和服务
- main.go: 更新路由配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:18:46 +08:00
318 changed files with 44611 additions and 11187 deletions

View File

@@ -4,7 +4,261 @@
"Bash(npm install)", "Bash(npm install)",
"Bash(npm run dev)", "Bash(npm run dev)",
"Bash(npm run build)", "Bash(npm run build)",
"Bash(npm install echarts)" "Bash(npm install echarts)",
"mcp__web-search-prime__webSearchPrime",
"Bash(git add web/src/style.css web/src/views/Agents.vue web/src/views/MCP.vue web/src/views/ModelAPIs.vue)",
"Bash(git commit:*)",
"Bash(ls -la *.yml *.yaml)",
"Bash(python3 -c \"import yaml; yaml.safe_load\\(open\\(''docker-compose.yml''\\)\\)\")",
"Bash(python -c \"import yaml; yaml.safe_load\\(open\\(''docker-compose.yml''\\)\\)\")",
"Bash(docker compose version)",
"Bash(docker compose convert)",
"Bash(test-compose.yml:*)",
"Bash(docker compose -f test-compose.yml config)",
"Bash(test-compose2.yml:*)",
"Bash(docker compose -f test-compose2.yml config)",
"Bash(docker compose up -d)",
"Bash(docker context ls)",
"Bash(docker compose -f compose.yml config)",
"Bash(docker compose -f compose.yml config --quiet)",
"Bash(docker-compose --version)",
"Bash(docker compose -f D:/Code/Project/X-Agents/docker-compose.yml config)",
"Bash(docker compose -f \"D:\\\\Code\\\\Project\\\\X-Agents\\\\docker-compose.yml\" config)",
"Bash(printf 'version: \"\"3.8\"\"\\\\n\\\\nnetworks:\\\\n x-agents-network:\\\\n driver: bridge\\\\n\\\\nvolumes:\\\\n db-data:\\\\n redis-data:\\\\n qdrant-data:\\\\n agent-data:\\\\n\\\\nservices:\\\\n server:\\\\n build:\\\\n context: ./server\\\\n dockerfile: Dockerfile\\\\n container_name: x-agents-server\\\\n ports:\\\\n - \"\"8080:8080\"\"\\\\n environment:\\\\n - PORT=8080\\\\n - JWT_SECRET=${JWT_SECRET:-your-secret-key-change-in-production}\\\\n - DATABASE_URL=postgres://postgres:postgres@db:5432/x_agents?sslmode=disable\\\\n - PYTHON_SERVICE_URL=http://agent:8081\\\\n depends_on:\\\\n db:\\\\n condition: service_healthy\\\\n agent:\\\\n condition: service_started\\\\n restart: unless-stopped\\\\n networks:\\\\n - x-agents-network\\\\n\\\\n agent:\\\\n build:\\\\n context: ./agent\\\\n dockerfile: Dockerfile\\\\n container_name: x-agents-agent\\\\n ports:\\\\n - \"\"8081:8081\"\"\\\\n environment:\\\\n - PYTHON_SERVICE_PORT=8081\\\\n - LLM_PROVIDER=${LLM_PROVIDER:-openai}\\\\n - OPENAI_API_KEY=${OPENAI_API_KEY:-}\\\\n - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}\\\\n volumes:\\\\n - ./agent/app:/app/app\\\\n - agent-data:/app/data\\\\n restart: unless-stopped\\\\n networks:\\\\n - x-agents-network\\\\n\\\\n db:\\\\n image: postgres:15-alpine\\\\n container_name: x-agents-db\\\\n environment:\\\\n POSTGRES_USER: postgres\\\\n POSTGRES_PASSWORD: postgres\\\\n POSTGRES_DB: x_agents\\\\n volumes:\\\\n - db-data:/var/lib/postgresql/data\\\\n ports:\\\\n - \"\"5432:5432\"\"\\\\n healthcheck:\\\\n test: [\"\"CMD-SHELL\"\", \"\"pg_isready -U postgres\"\"]\\\\n interval: 10s\\\\n timeout: 5s\\\\n retries: 5\\\\n restart: unless-stopped\\\\n networks:\\\\n - x-agents-network\\\\n\\\\n redis:\\\\n image: redis:7-alpine\\\\n container_name: x-agents-redis\\\\n ports:\\\\n - \"\"6379:6379\"\"\\\\n volumes:\\\\n - redis-data:/data\\\\n restart: unless-stopped\\\\n networks:\\\\n - x-agents-network\\\\n\\\\n qdrant:\\\\n image: qdrant/qdrant:v1.7.0\\\\n container_name: x-agents-qdrant\\\\n ports:\\\\n - \"\"6333:6333\"\"\\\\n - \"\"6334:6334\"\"\\\\n volumes:\\\\n - qdrant-data:/qdrant/storage\\\\n restart: unless-stopped\\\\n networks:\\\\n - x-agents-network\\\\n')",
"Bash(powershell.exe -Command \"Remove-Item docker-compose.yml -ErrorAction SilentlyContinue; Write-Host ''removed''\")",
"Bash(powershell.exe -NoProfile -Command '@\"\":*)",
"Bash(DEBUG=*)",
"Bash(docker compose config -p x-agents)",
"Bash(docker info)",
"Bash(docker compose ls)",
"Bash(go mod tidy)",
"Bash(docker run --rm -v D:/Code/Project/X-Agents/server:/app -w /app golang:1.21 go mod tidy)",
"Bash(where go)",
"Bash(npx vue-tsc --noEmit)",
"Bash(go env -w GOPROXY=https://goproxy.cn,direct)",
"Bash(curl -X POST http://localhost:8082/database/add -H \"Content-Type: application/json\" -d '{\"\"name\"\":\"\"test\"\",\"\"db_type\"\":\"\"mysql\"\",\"\"host\"\":\"\"localhost\"\",\"\"port\"\":6036,\"\"username\"\":\"\"root\"\",\"\"password\"\":\"\"root\"\",\"\"database\"\":\"\"x_agents\"\"}')",
"Bash(go build -o api.exe ./cmd/api)",
"Bash(curl -s -X POST http://localhost:8082/database/add -H \"Content-Type: application/json\" -d '{\"\"name\"\":\"\"测试数据库\"\",\"\"description\"\":\"\"测试\"\",\"\"db_type\"\":\"\"MySQL\"\",\"\"host\"\":\"\"localhost\"\",\"\"port\"\":3306,\"\"username\"\":\"\"root\"\",\"\"password\"\":\"\"123123\"\",\"\"database\"\":\"\"testdb\"\"}')",
"Bash(taskkill //F //IM api.exe)",
"Bash(go run temp_add_data.go)",
"Bash(ping -n 1 10.10.10.189)",
"Bash(nc -zv 10.10.10.189 3306)",
"Bash(powershell.exe -Command \"Test-NetConnection -ComputerName 10.10.10.189 -Port 3306\")",
"Bash(go run temp_grant.go)",
"Bash(go run temp_fix.go)",
"Bash(go run temp_add_data2.go)",
"Bash(go run temp_regrant.go)",
"Bash(go run temp_newuser.go)",
"Bash(go run temp_check.go)",
"Bash(go run temp_reset.go)",
"Bash(go run temp_native.go)",
"Bash(go get github.com/shirou/gopsutil/v3/mem)",
"Bash(curl -s -X POST http://localhost:8080/api/database/check -H \"Content-Type: application/json\" -d '{\"\"db_type\"\":\"\"mysql\"\",\"\"host\"\":\"\"localhost\"\",\"\"port\"\":3306,\"\"username\"\":\"\"root\"\",\"\"password\"\":\"\"root\"\",\"\"database\"\":\"\"test\"\",\"\"charset\"\":\"\"utf8mb4\"\"}')",
"Bash(docker ps --format \"table {{.Names}}\\\\t{{.Ports}}\")",
"Bash(curl -s -X POST http://localhost:8082/api/database/check -H \"Content-Type: application/json\" -d '{\"\"db_type\"\":\"\"mysql\"\",\"\"host\"\":\"\"localhost\"\",\"\"port\"\":6036,\"\"username\"\":\"\"root\"\",\"\"password\"\":\"\"root\"\",\"\"database\"\":\"\"x_agents\"\",\"\"charset\"\":\"\"utf8mb4\"\"}')",
"Bash(netstat -ano)",
"Bash(findstr \"8082\")",
"Bash(curl -s -X POST http://localhost:8082/database/check -H \"Content-Type: application/json\" -d '{\"\"db_type\"\":\"\"mysql\"\",\"\"host\"\":\"\"localhost\"\",\"\"port\"\":6036,\"\"username\"\":\"\"root\"\",\"\"password\"\":\"\"root\"\",\"\"database\"\":\"\"x_agents\"\",\"\"charset\"\":\"\"utf8mb4\"\"}')",
"Bash(taskkill //F //FI \"IMAGENAME eq api.exe\")",
"Bash(taskkill //F //FI \"IMAGENAME eq main.exe\")",
"Bash(findstr \":8082\")",
"Bash(findstr \"LISTENING\")",
"Bash(taskkill //F //PID 70176)",
"Bash(taskkill //F //PID 71260)",
"Bash(taskkill //F //PID 63192)",
"Bash(curl -s -X POST http://localhost:8082/database/check -H \"Content-Type: application/json\" -d '{\"\"db_type\"\":\"\"mysql\"\",\"\"host\"\":\"\"localhost\"\",\"\"port\"\":6036,\"\"username\"\":\"\"root\"\",\"\"password\"\":\"\"root\"\",\"\"database\"\":\"\"x_agents\"\",\"\"database_id\"\":\"\"test-id\"\"}')",
"Bash(taskkill //F //PID 43848)",
"Bash(taskkill //F //PID 35324)",
"Bash(taskkill //F //PID 74868)",
"Bash(go build ./cmd/api/main.go)",
"Bash(curl -s -X POST http://localhost:8082/database/add -H \"Content-Type: application/json\" -d '{:*)",
"Bash(taskkill //F //PID 49692)",
"Bash(curl -s -X POST http://localhost:8082/database/check -H \"Content-Type: application/json\" -d '{:*)",
"Bash(taskkill //F //PID 40216)",
"Bash(curl -s http://localhost:8082/sub-table/database/68b6fb60-eae2-495b-b248-9c46c8d8d6cb)",
"Bash(taskkill //F //PID 59688)",
"Bash(taskkill //F //PID 55352)",
"Bash(taskkill //F //PID 71716)",
"Bash(git add .gitignore)",
"Bash(git add agent/ server/ docs/ web/src/ .env.example docker-compose.yml docker-compose.dev.yml start-local.ps1 team-require/)",
"Bash(git add web/agents.html web/dashboard.html web/graph.html)",
"Bash(go get github.com/neo4j/neo4j-driver-go/v5@latest)",
"Bash(go build -o /dev/null ./cmd/api/main.go)",
"mcp__web-search-prime__web_search_prime",
"Bash(curl -X POST http://localhost:8080/neo4j/check -H \"Content-Type: application/json\" -d '{\"\"host\"\":\"\"10.10.10.189\"\",\"\"port\"\":7687,\"\"username\"\":\"\"neo4j\"\",\"\"password\"\":\"\"neo4j\"\"}')",
"Bash(curl -X POST http://localhost:8082/neo4j/check -H \"Content-Type: application/json\" -d '{\"\"host\"\":\"\"10.10.10.189\"\",\"\"port\"\":7687,\"\"username\"\":\"\"neo4j\"\",\"\"password\"\":\"\"neo4j\"\"}')",
"Bash(go build -o server.exe ./cmd/api/main.go)",
"Bash(curl -X POST http://localhost:8082/neo4j/check -H \"Content-Type: application/json\" -d '{\"\"host\"\":\"\"10.10.10.189\"\",\"\"port\"\":7687,\"\"username\"\":\"\"password\"\":\"\"neo4neo4j\"\",\"\"j\"\"}')",
"Bash(curl -X POST \"http://localhost:8082/neo4j/check\" -H \"Content-Type: application/json\" -d \"{\"\"host\"\":\"\"10.10.10.189\"\",\"\"port\"\":7687,\"\"username\"\":\"\"neo4j\"\",\"\"password\"\":\"\"neo4j\"\"}\")",
"Bash(curl -s http://localhost:8082/system/info)",
"Bash(curl -s -X POST \"http://localhost:8082/neo4j/check\" -H \"Content-Type: application/json\" -d \"{\"\"host\"\":\"\"10.10.10.189\"\",\"\"port\"\":7687,\"\"username\"\":\"\"neo4j\"\",\"\"password\"\":\"\"neo4j\"\"}\")",
"Bash(curl -v -X POST \"http://localhost:8082/neo4j/check\" -H \"Content-Type: application/json\" -d \"{\"\"host\"\":\"\"10.10.10.189\"\",\"\"port\"\":7687,\"\"username\"\":\"\"neo4j\"\",\"\"password\"\":\"\"neo4j\"\"}\")",
"Bash(findstr :8082)",
"Bash(taskkill /F /PID 68728)",
"Bash(powershell -Command \"Stop-Process -Id 68728 -Force\")",
"Bash(cmd //c \"taskkill /F /PID 68728\")",
"Bash(curl -s -X POST \"http://localhost:8082/database/check\" -H \"Content-Type: application/json\" -d \"{\"\"db_type\"\":\"\"neo4j\"\",\"\"uri\"\":\"\"bolt://10.10.10.189:7687\"\",\"\"username\"\":\"\"neo4j\"\",\"\"password\"\":\"\"neo4j\"\",\"\"database\"\":\"\"neo4j\"\"}\")",
"Bash(curl -s -X POST \"http://localhost:8082/database/check\" -H \"Content-Type: application/json\" -d \"{\"\"db_type\"\":\"\"neo4j\"\",\"\"host\"\":\"\"10.10.10.189\"\",\"\"port\"\":7687,\"\"uri\"\":\"\"bolt://10.10.10.189:7687\"\",\"\"username\"\":\"\"neo4j\"\",\"\"password\"\":\"\"neo4j\"\",\"\"database\"\":\"\"neo4j\"\"}\")",
"Bash(findstr LISTENING)",
"Bash(cmd //c \"taskkill //F //PID 80208\")",
"Bash(powershell -NoProfile -Command \"Stop-Process -Id 80208 -Force -ErrorAction SilentlyContinue\")",
"Bash(npx vite build)",
"Bash(ls d:/Code/Project/X-Agents/web/*.md)",
"Bash(go build -o server.exe ./cmd/api)",
"Bash(ls -la /d/Code/Project/X-Agents/server/*.go)",
"Bash(npm run type-check)",
"Bash(go build ./...)",
"Bash(grep -i \"ensureNeo4j\\\\|Check.*确保\\\\|Check.*database\" \"d:/Code/Project/X-Agents/server/logs/2026-03-06/\"*.log)",
"Bash(ls -la /d/Code/Project/X-Agents/web/src/*.css)",
"Bash(git add server/ web/src/ team-require/)",
"Bash(python \"C:/Users/caoxiaozhu/.claude/skills/skill-creator/scripts/init_skill.py\" write-requirement --path \"C:/Users/caoxiaozhu/.claude/skills\")",
"WebFetch(domain:github.com)",
"Bash(gh repo view Tencent/WeKnora --json name,description,readme,url)",
"mcp__web-reader__webReader",
"WebFetch(domain:minimax-algeng-chat-tts.oss-cn-wulanchabu.aliyuncs.com)",
"Bash(npx vue-tsc --noEmit src/views/Settings.vue)",
"Bash(curl -s http://localhost:5173)",
"Bash(curl -s http://localhost:8082/model/test -X POST -H \"Content-Type: application/json\" -d '{}')",
"Bash(curl -s http://localhost:8082/model/test -X POST -H \"Content-Type: application/json\" -d '{\"\"provider\"\":\"\"OpenAI\"\",\"\"model\"\":\"\"gpt-4\"\",\"\"api_key\"\":\"\"test\"\",\"\"base_url\"\":\"\"https://api.openai.com/v1\"\"}')",
"Bash(go build -o api.exe ./cmd/api/)",
"Bash(go get github.com/minio/minio-go/v7)",
"Bash(curl -s --connect-timeout 5 http://localhost:5173)",
"Bash(npx vue-tsc --noEmit src/views/MCP.vue)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8082/api/knowledge/list)",
"Bash(curl -s http://localhost:8082/api/knowledge/list)",
"Bash(python -m venv venv)",
"Bash(powershell -Command \"Move-Item -Path ''algorithm'' -Destination ''ai-core'' -Force\")",
"Bash(python -c \"import sys; sys.path.insert\\(0, ''proto''\\); import docparser_pb2; print\\(''OK''\\)\")",
"Bash(python -c \"import document_parser_pb2; print\\(dir\\(document_parser_pb2\\)\\)\")",
"Bash(python -c \"import google.protobuf; print\\(google.protobuf.__version__\\)\")",
"Bash(python generate_grpc.py)",
"Bash(pip install grpcio-tools)",
"Bash(timeout 5 python main.py)",
"Bash(pip install grpcio-reflection)",
"Bash(pip install -r requirements.txt)",
"Bash(where python)",
"Bash(./venv/Scripts/pip.exe install -r requirements.txt)",
"Bash(./venv/Scripts/python.exe generate_grpc.py)",
"Bash(timeout 3 ./start.bat)",
"Bash(timeout 3 bash start.sh)",
"Bash(source venv/Scripts/activate)",
"Bash(curl -s http://localhost:50051/health)",
"Bash(timeout 10 python main.py)",
"Bash(findstr 50051)",
"Bash(findstr \"50051\\\\|50052\")",
"Bash(findstr \":50051\\\\|:50052\")",
"Bash(findstr \":50051\")",
"Bash(cd:*)",
"Read(//c/Users/caoxiaozhu/.claude/skills/ui-ux-pro-max/**)",
"Bash(python scripts/search.py \"signup registration form dark theme SaaS\" --design-system -p \"X-Agents Signup\")",
"Bash(cd D:/Code/Project/X-Agents/server && go build ./cmd/api/...)",
"Bash(git add:*)",
"Bash(cd D:/Code/Project/X-Agents/server && go get -u github.com/swaggo/swag/cmd/swag)",
"Bash(cd D:/Code/Project/X-Agents/server && go get -u github.com/swaggo/gin-swagger && go get -u github.com/swaggo/files)",
"Bash(cd D:/Code/Project/X-Agents/server && npx swag init -g cmd/api/main.go -o docs --parseDependency --parseInternal)",
"Bash(cd D:/Code/Project/X-Agents/server && go run github.com/swaggo/swag/cmd/swag@latest init -g cmd/api/main.go -o docs --parseDependency --parseInternal)",
"Bash(cd \"D:\\\\Code\\\\Project\\\\X-Agents\\\\server\\\\docs\" && cat swagger.json | python -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('\\\\n'.join\\(d['paths'].keys\\(\\)\\)\\)\")",
"Bash(sleep 3 && curl -X POST http://localhost:8082/auth/register -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"admin\\\\\",\\\\\"password\\\\\":\\\\\"admin\\\\\",\\\\\"email\\\\\":\\\\\"admin@example.com\\\\\"}\")",
"Bash(cd D:/Code/Project/X-Agents/server && go run cmd/api/main.go 2>&1 | head -30)",
"Bash(sleep 5 && curl -X POST http://localhost:8082/auth/register -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"admin\\\\\",\\\\\"password\\\\\":\\\\\"admin\\\\\",\\\\\"email\\\\\":\\\\\"admin@example.com\\\\\"}\")",
"Bash(mysql -h localhost -P 6036 -u root -proot -e \"USE x_agents; SHOW TABLES;\")",
"Bash(curl -X POST http://localhost:8082/auth/register -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"admin\\\\\",\\\\\"password\\\\\":\\\\\"admin\\\\\",\\\\\"email\\\\\":\\\\\"admin@example.com\\\\\"}\")",
"Bash(sleep 8 && curl -X POST http://localhost:8082/auth/register -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"admin\\\\\",\\\\\"password\\\\\":\\\\\"admin\\\\\",\\\\\"email\\\\\":\\\\\"admin@example.com\\\\\"}\")",
"Bash(cd D:/Code/Project/X-Agents/server && timeout 10 go run cmd/api/main.go 2>&1 || true)",
"Bash(taskkill /F /IM server.exe 2>/dev/null; sleep 2)",
"Bash(cd D:/Code/Project/X-Agents/server && go run cmd/api/main.go 2>&1 | head -20)",
"Bash(taskkill /F /IM server.exe 2>/dev/null; taskkill /F /IM go.exe 2>/dev/null; sleep 3)",
"Bash(cd D:/Code/Project/X-Agents/server && timeout 20 go run cmd/api/main.go 2>&1 || true)",
"Bash(sleep 3 && curl -X POST http://localhost:8082/auth/login -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"admin\\\\\",\\\\\"password\\\\\":\\\\\"admin\\\\\"}\")",
"Bash(sleep 5 && curl -X POST http://localhost:8082/auth/register -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"testuser\\\\\",\\\\\"password\\\\\":\\\\\"123456\\\\\",\\\\\"email\\\\\":\\\\\"test@example.com\\\\\"}\")",
"Bash(sleep 3 && curl -X POST http://localhost:8082/auth/register -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"user2\\\\\",\\\\\"password\\\\\":\\\\\"123456\\\\\",\\\\\"email\\\\\":\\\\\"user2@example.com\\\\\"}\")",
"Bash(cd D:/Code/Project/X-Agents/server && rm -f server.exe && go build -o server.exe ./cmd/api/... && ls -la server.exe)",
"Bash(sleep 4 && curl -X POST http://localhost:8082/auth/register -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"user3\\\\\",\\\\\"password\\\\\":\\\\\"123456\\\\\",\\\\\"email\\\\\":\\\\\"user3@example.com\\\\\"}\")",
"Bash(sleep 4 && curl -X POST http://localhost:8082/auth/register -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"user4\\\\\",\\\\\"password\\\\\":\\\\\"123456\\\\\",\\\\\"email\\\\\":\\\\\"user4@example.com\\\\\"}\")",
"Bash(curl -s http://localhost:8082/auth/login -X POST -H \"Content-Type: application/json\" -d '{\"username\":\"admin\",\"password\":\"admin\"}')",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NzM4MDQ3NzcsImV4cGlyZXNfYXQiOiIyMDI2LTAzLTE4VDExOjMyOjU3KzA4OjAwIiwiaWF0IjoxNzczMTk5OTc3LCJyb2xlIjoidXNlciIsInN1YiI6Ijg3NDgxMjlkLWM1NTYtNDM4NS04OGE5LWY5MTRjNzU4NDg3ZCIsInVzZXJuYW1lIjoiYWRtaW4ifQ.VILfFUxl8nYbwfsYHeGvIwTaxgxWPb43mihI-pNNxWk\" && curl -s http://localhost:8082/user/list -H \"Authorization: Bearer $TOKEN\")",
"Bash(sleep 4 && curl -s http://localhost:8082/auth/login -X POST -H \"Content-Type: application/json\" -d '{\"username\":\"admin\",\"password\":\"admin\"}' | head -c 200)",
"Bash(cd D:/Code/Project/X-Agents/server && go build -o server.exe ./cmd/api/... 2>&1)",
"Bash(tasklist | grep -i server)",
"Bash(curl -s http://localhost:8082/swagger/index.html | head -20)",
"Bash(curl -s http://localhost:8082/swagger.json | grep -o '\"/user[^\"]*\"' | head -10)",
"Bash(curl -s \"http://localhost:8082/database/list\")",
"Bash(taskkill /F /IM server.exe 2>/dev/null; sleep 1)",
"Bash(taskkill /PID 48088 /F)",
"Bash(taskkill.exe //PID 48088 //F)",
"Bash(cd \"D:/Code/Project/X-Agents/web\" && npm install lucide-vue-next)",
"Bash(mkdir -p \"D:/Code/Project/X-Agents/agent/app/core/tools/impl\" && mkdir -p \"D:/Code/Project/X-Agents/agent/app/core/tools/sandbox\")",
"Bash(cd D:/Code/Project/X-Agents/server && go build -o server.exe ./cmd/api/)",
"Bash(cd \"D:\\\\Code\\\\Project\\\\X-Agents\\\\web\" && npm install monaco-editor)",
"Bash(curl -s http://localhost:8082/tools)",
"Bash(cd \"D:\\\\Code\\\\Project\\\\X-Agents\\\\web\" && npm install -D vite-plugin-monaco-editor)",
"Bash(mysql -h localhost -P 6036 -u root -proot x_agents -e \"CREATE TABLE IF NOT EXISTS tools \\(id VARCHAR\\(191\\) PRIMARY KEY, name VARCHAR\\(100\\) UNIQUE NOT NULL, description TEXT, category VARCHAR\\(50\\) NOT NULL, provider VARCHAR\\(100\\), status VARCHAR\\(20\\) DEFAULT 'active', created_at DATETIME\\(3\\), updated_at DATETIME\\(3\\), INDEX idx_tools_category \\(category\\), INDEX idx_tools_name \\(name\\)\\);\")",
"Bash(mysql -h localhost -P 6036 -u root -proot x_agents -e \"\nINSERT INTO tools \\(id, name, description, category, provider, status, created_at, updated_at\\) VALUES\n\\(UUID\\(\\), 'read_file', '读取文件', '文件操作', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'write_file', '写入文件', '文件操作', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'list_dir', '列出目录', '文件操作', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'delete_file', '删除文件', '文件操作', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'search_files', '搜索文件', '文件操作', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'execute_python', '执行Python', '代码执行', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'execute_javascript', '执行JavaScript', '代码执行', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'execute_bash', '执行Bash命令', '代码执行', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'web_fetch', '获取网页', '网页', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'web_search', '搜索网页', '网页', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'http_request', 'HTTP请求', '通信', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'send_notification', '发送通知', '通信', 'system', 'active', NOW\\(\\), NOW\\(\\)\\),\n\\(UUID\\(\\), 'get_current_time', '获取时间', '工具', 'system', 'active', NOW\\(\\), NOW\\(\\)\\)\nON DUPLICATE KEY UPDATE description=VALUES\\(description\\), category=VALUES\\(category\\);\n\")",
"Bash(curl -s http://localhost:8080/tool/list 2>/dev/null || curl -s http://localhost:3000/tool/list 2>/dev/null || echo \"Server not running on common ports\")",
"Bash(curl -s http://localhost:8082/tool/list)",
"Bash(git push:*)",
"Bash(git remote:*)",
"Bash(git reset:*)",
"Bash(cd \"D:/Code/Project/X-Agents/account/admin/\" && mv projects sandbox)",
"Read(//d/Code/Project/**)",
"Bash(mv projects:*)",
"Bash(mkdir-Agents/account/le -p skills scripts)",
"Bash(cd \"D:/Code/Project/X-Agents/server\" && swag init -g cmd/api/main.go -o docs --parseDependency --parseInternal)",
"Bash(cd \"D:/Code/Project/X-Agents/server\" && go install github.com/swaggo/swag/cmd/swag@latest)",
"Bash(find \"D:/Code/Project/X-Agents\" -name \"python_*.log\" 2>/dev/null | head -10)",
"Bash(cd \"D:\\\\Code\\\\Project\\\\X-Agents\\\\server\" && go run ./cmd/api)",
"Bash(taskkill /PID 49852 /F)",
"Bash(taskkill //PID 49852 //F)",
"Bash(cd \"D:\\\\Code\\\\Project\\\\X-Agents\\\\server\" && go build ./cmd/api 2>&1 | head -20)",
"Bash(cd \"D:\\\\Code\\\\Project\\\\X-Agents\\\\server\" && go build ./cmd/api 2>&1)",
"Bash(cd \"D:\\\\Code\\\\Project\\\\X-Agents\\\\server\" && go run ./cmd/api 2>&1 | head -30)",
"Bash(curl -N -X POST http://localhost:8081/agent/chat/stream -H \"Content-Type: application/json\" -d \"{\\\\\"agent_id\\\\\":1,\\\\\"message\\\\\":\\\\\"你好\\\\\"}\" 2>&1 | head -20)",
"Bash(curl -N -X POST http://localhost:8081/agent/chat/stream -H \"Content-Type: application/json\" -d \"{\\\\\"agent_id\\\\\":1,\\\\\"message\\\\\":\\\\\"你好\\\\\",\\\\\"user_id\\\\\":1}\" 2>&1 | head -30)",
"Bash(curl -N -X POST http://localhost:8082/api/agent/chat/stream -H \"Content-Type: application/json\" -d \"{\\\\\"agent_id\\\\\":1,\\\\\"message\\\\\":\\\\\"hello\\\\\"}\" 2>&1 | head -50)",
"Bash(curl -N -X POST http://localhost:5173/api/agent/chat/stream -H \"Content-Type: application/json\" -d \"{\\\\\"agent_id\\\\\":1,\\\\\"message\\\\\":\\\\\"hello\\\\\"}\" 2>&1 | head -30)",
"Bash(curl -s http://localhost:8082/api/model/list 2>&1)",
"Bash(curl -s http://localhost:8082/model/list 2>&1)",
"Bash(pkill -f \"go run cmd/api/main.go\" 2>/dev/null || taskkill //F //IM api.exe 2>/dev/null || true)",
"Bash(curl -N -X POST http://localhost:5173/api/agent/chat/stream -H \"Content-Type: application/json\" -d \"{\\\\\"agent_id\\\\\":1,\\\\\"message\\\\\":\\\\\"hello\\\\\",\\\\\"model_id\\\\\":\\\\\"44c82db8-5321-44a4-8caa-0829afa2c0d9\\\\\"}\" 2>&1 | head -20)",
"Bash(taskkill //F //IM node.exe 2>/dev/null || true)",
"Bash(taskkill //F //PID 52048)",
"Bash(cd \"C:\\\\Users\\\\caoxiaozhu\\\\.claude\\\\skills\\\\ui-ux-pro-max\" && python scripts/search.py \"chat message bubble design\" --design-system -p \"Chat UI\")",
"Bash(git -C \"D:/Code/Project/X-Agents\" diff web/src/views/Agents.vue | head -100)",
"Bash(git -C \"D:/Code/Project/X-Agents\" checkout -- web/src/views/Agents.vue)",
"Bash(cd D:/Code/Project/X-Agents && curl -s -X POST http://localhost:8082/skill/add -F \"skill_name=test\" -F \"skill_desc=test desc\" -F \"skill_type=user\" 2>&1)",
"Bash(cd D:/Code/Project/X-Agents/server && go run cmd/api/main.go 2>&1 | head -100)",
"Bash(sleep 3 && curl -s -X POST http://localhost:8082/skill/add -F \"skill_name=test\" -F \"skill_desc=test desc\" -F \"skill_type=user\" 2>&1)",
"Bash(sleep 3 && curl -s -X POST http://localhost:8082/skill/add -F \"skill_name=test123\" -F \"skill_desc=test desc\" -F \"skill_type=user\" 2>&1)",
"Bash(cd D:/Code/Project/X-Agents/server && timeout 5 go run cmd/api/main.go 2>&1 || true)",
"Bash(taskkill /F /IM \"main.exe\" 2>/dev/null || true)",
"Bash(cd D:/Code/Project/X-Agents/web && npx vue-tsc --noEmit src/views/skill/useSkills.ts src/views/Skill.vue 2>&1 | head -30)",
"Bash(curl -s http://localhost:8082/skill/6974b449-c1c6-4ab2-921a-f244d035cba7/content 2>&1)",
"Bash(cd D:/Code/Project/X-Agents/server && swag init -g cmd/api/main.go -o docs 2>&1)",
"Bash(cd D:/Code/Project/X-Agents/server && go build -o /dev/null ./internal/handler/...)",
"Bash(cd D:/Code/Project/X-Agents/server && go vet ./internal/handler/skill_handler.go 2>&1 || true)",
"Bash(curl -s http://localhost:8081/agent/list 2>&1)",
"Bash(netstat -ano | findstr \"8081\" 2>&1 | head -5)",
"Bash(curl -s http://localhost:8081/agent/list 2>&1 || echo \"Python service not running\")",
"Bash(cd D:/Code/Project/X-Agents/server && timeout 5 ./server.exe 2>&1 || true)",
"Bash(curl -s http://localhost:8082/api/agent/list 2>&1)",
"Bash(curl -s \"http://localhost:8082/database/a89dfc3e-5089-4a9e-8f6b-991d5bebd85d\" 2>&1)",
"Bash(curl -s -X POST http://localhost:8082/api/agent/create -H \"Content-Type: application/json\" -d '{\"name\":\"test-agent\",\"description\":\"test\",\"avatar\":\"🤖\",\"skillsMode\":\"all\",\"skills\":[],\"knowledge\":\"none\",\"prompt\":\"test prompt\"}' 2>&1)",
"Bash(curl -s http://localhost:8082/skill/list 2>&1 | head -20)",
"Bash(taskkill /F /PID 19976)",
"Bash(powershell -Command \"Stop-Process -Id 19976 -Force\")",
"Bash(cmd //c \"taskkill /F /PID 19976\")",
"Bash(curl -s http://localhost:8082/skill/list 2>&1 | head -100)",
"Bash(cd D:/Code/Project/X-Agents/web && npm install jszip)",
"Bash(curl -s http://localhost:8082/model/list | head -200)",
"Bash(curl -s \"http://localhost:8082/model/list\" | python -m json.tool 2>/dev/null || curl -s \"http://localhost:8082/model/list\")",
"Bash(curl -s \"http://localhost:5173/model/list\" 2>&1 | head -50)",
"Bash(sleep 5 && curl -s \"http://localhost:5173/model/list\" 2>&1 | head -100)",
"Bash(curl -s \"http://localhost:5173/src/views/chat/chat.ts\" 2>&1 | head -10)",
"Bash(curl -s \"http://localhost:5173/src/views/chat/chat.ts\" 2>&1 | grep -A5 \"fetchModels\")",
"Bash(cd \"D:/Code/Project/X-Agents/agent\" && pip install -r requirements.txt -q)",
"Bash(curl -s \"http://localhost:5173/src/views/chat/chat.ts\" 2>&1 | grep -A15 \"const fetchModels\")",
"Bash(curl -s \"http://localhost:5173/api/model/list\" 2>&1 | head -50)",
"Bash(cd \"D:\\\\Code\\\\Project\\\\X-Agents\\\\web\" && npx vue-tsc --noEmit src/views/Agents.vue 2>&1 | head -30)"
] ]
} }
} }

View File

@@ -1,11 +1,30 @@
# JWT 配置 # ========================================
JWT_SECRET=your-secret-key-change-in-production # X-Agents 全局配置文件
# ========================================
# 将此文件复制为 .env 后修改配置
# LLM 提供商 (openai / anthropic) # ========================================
LLM_PROVIDER=openai # Go 后端配置
# ========================================
GO_PORT=8082
GO_DATABASE_TYPE=mysql # 可选值: mysql, sqlite
GO_DATABASE_HOST=localhost
GO_DATABASE_PORT=6036
GO_DATABASE_NAME=x_agents
GO_DATABASE_USER=root
GO_DATABASE_PASSWORD=
GO_SQLITE_PATH=./data/x_agents.db # SQLite 数据库文件路径
# OpenAI API Key # ========================================
OPENAI_API_KEY=your-openai-api-key # Python Agent 配置
# ========================================
PYTHON_PORT=8001
PYTHON_WORKSPACE=./workspace
PYTHON_LLM_PROVIDER=openai
PYTHON_LLM_API_KEY=
PYTHON_LLM_MODEL=gpt-4o
# Anthropic API Key # ========================================
ANTHROPIC_API_KEY=your-anthropic-api-key # Web 前端配置
# ========================================
WEB_PORT=5173

View File

@@ -1 +0,0 @@
# X-Agents Python Agent Engine

View File

@@ -1,3 +0,0 @@
# Agent Core
from app.agent.core.agent import AgentCore, AgentConfig, AgentResponse
from app.agent.core.supervisor import Supervisor

View File

@@ -1,170 +0,0 @@
"""
Agent Core - 单智能体核心
"""
import logging
from typing import Optional, List, Dict, Any
from pydantic import BaseModel
from app.agent.memory.manager import MemoryManager
from app.agent.skills.router import SkillRouter
from app.agent.skills.executor import SkillExecutor
from app.agent.llm.factory import LLMFactory
logger = logging.getLogger("agent.core")
class AgentConfig(BaseModel):
"""智能体配置"""
id: int
name: str
role_description: str
model_provider: str = "openai"
model_name: str = "gpt-4"
api_key: Optional[str] = None # API Key可选用于覆盖默认配置
base_url: Optional[str] = None # Base URL可选用于覆盖默认配置
skills: List[int] = [] # 技能 ID 列表
knowledge_base_ids: List[int] = []
timeout: int = 60
memory_limit: int = 134217728 # 128MB
class AgentResponse(BaseModel):
"""智能体响应"""
content: str
tool_calls: List[Dict[str, Any]] = []
tokens_used: int = 0
duration_ms: int = 0
session_id: Optional[str] = None
class AgentCore:
"""单智能体核心类"""
def __init__(self, config: AgentConfig):
self.config = config
# 记录 LLM 初始化信息
api_key_info = f"{config.api_key[:10]}..." if config.api_key else "None"
logger.info(f"初始化 AgentCore: name={config.name}, provider={config.model_provider}, model={config.model_name}, api_key={api_key_info}, base_url={config.base_url}")
self.llm = LLMFactory.create(config.model_provider, config.model_name, config.api_key, config.base_url)
self.memory = MemoryManager(config.id)
self.skill_router = SkillRouter(config.skills)
self.skill_executor = SkillExecutor()
async def run(self, user_input: str, user_id: int, session_id: str) -> AgentResponse:
"""
执行智能体对话
Args:
user_input: 用户输入
user_id: 用户 ID
session_id: 会话 ID
Returns:
AgentResponse: 智能体响应
"""
import time
start_time = time.time()
try:
# 1. 加载记忆
context = await self.memory.load_context(user_input, user_id, session_id)
# 2. 构建 Prompt
prompt = self._build_prompt(user_input, context)
# 3. LLM 决策
decision = await self.llm.decide(prompt)
# 4. 执行技能(如需)
if decision.get('needs_skill'):
skill_results = await self._execute_skills(decision.get('tool_calls', []))
# 5. 基于结果生成回复
final_response = await self.llm.generate(prompt, skill_results)
else:
final_response = decision.get('response', '')
# 6. 保存记忆
await self.memory.save(user_input, final_response, user_id, session_id)
duration_ms = int((time.time() - start_time) * 1000)
return AgentResponse(
content=final_response,
tool_calls=decision.get('tool_calls', []),
duration_ms=duration_ms,
session_id=session_id
)
except Exception as e:
duration_ms = int((time.time() - start_time) * 1000)
return AgentResponse(
content=f"处理请求时发生错误: {str(e)}",
duration_ms=duration_ms,
session_id=session_id
)
def _build_prompt(self, user_input: str, context: dict) -> str:
"""构建 Prompt"""
system_prompt = f"""你是 {self.config.name}
{self.config.role_description}
相关记忆:
{context.get('summary', '')}
知识库信息:
{context.get('knowledge', '')}
请根据以上上下文回答用户问题,并使用 Markdown 格式输出。"""
return f"{system_prompt}\n\n用户: {user_input}"
async def _execute_skills(self, skill_decisions: List[Dict]) -> List[Dict]:
"""执行技能"""
if not skill_decisions:
return []
results = []
for decision in skill_decisions:
result = await self.skill_executor.execute(
skill_id=decision.get('skill_id'),
params=decision.get('params', {})
)
results.append(result)
return results
async def run_stream(self, user_input: str, user_id: int, session_id: str):
"""
执行智能体对话(流式输出)
优化:对于简单对话,直接流式生成,跳过 decide 步骤(省一次 LLM 调用)
只有当需要工具时才先判断
Args:
user_input: 用户输入
user_id: 用户 ID
session_id: 会话 ID
Yields:
str: 流式回复片段
"""
import time
start_time = time.time()
try:
# 1. 加载记忆
context = await self.memory.load_context(user_input, user_id, session_id)
# 2. 构建 Prompt
prompt = self._build_prompt(user_input, context)
# 3. 直接流式生成回复(跳过 decide省一次 LLM 调用)
# 如果将来需要工具能力,可以在这里添加判断逻辑
async for chunk in self.llm.generate_stream(prompt, []):
yield chunk
# 4. 保存记忆(完成后)
final_response = ""
await self.memory.save(user_input, final_response, user_id, session_id)
except Exception as e:
yield f"处理请求时发生错误: {str(e)}"

View File

@@ -1,156 +0,0 @@
"""
Supervisor - 多智能体调度器
"""
import asyncio
from typing import List, Dict, Any
from app.agent.core.agent import AgentCore
class Supervisor:
"""多智能体调度器"""
def __init__(self, supervisor_agent: AgentCore, members: List[AgentCore], strategy: str = "parallel"):
"""
初始化调度器
Args:
supervisor_agent: 主智能体
members: 子智能体列表
strategy: 调度策略 (parallel/sequential)
"""
self.supervisor = supervisor_agent
self.members = members
self.strategy = strategy
async def run(self, task: str, user_id: int, session_id: str) -> Dict[str, Any]:
"""
执行多智能体协作
Args:
task: 用户任务
user_id: 用户 ID
session_id: 会话 ID
Returns:
Dict: 包含主响应和子任务结果
"""
# 1. 任务分解
subtasks = await self._decompose_task(task)
# 2. 分配任务
if self.strategy == "parallel":
results = await self._dispatch_parallel(subtasks, user_id, session_id)
else:
results = await self._dispatch_sequential(subtasks, user_id, session_id)
# 3. 汇总结果
final_result = await self._aggregate(results)
return {
"main_response": final_result,
"subtask_results": results,
"strategy": self.strategy
}
async def _decompose_task(self, task: str) -> List[Dict[str, str]]:
"""任务分解"""
# 调用 LLM 分解任务
prompt = f"""分解以下任务为子任务,返回 JSON 数组格式:
任务: {task}
返回格式示例:
[{{"task": "子任务1描述", "agent_type": "适合的智能体类型"}}, {{"task": "子任务2描述", "agent_type": "适合的智能体类型"}}]
请直接返回 JSON 数组,不要其他内容。"""
response = await self.supervisor.llm.generate(prompt, [])
try:
import json
# 尝试解析 JSON
subtasks = json.loads(response)
return subtasks
except:
# 解析失败,创建默认子任务
return [{"task": task, "agent_type": "general"}]
async def _dispatch_parallel(self, subtasks: List[Dict], user_id: int, session_id: str) -> List[Dict]:
"""并行分发任务"""
tasks = []
for i, subtask in enumerate(subtasks):
if i < len(self.members):
member = self.members[i]
else:
# 如果子任务多于成员,使用轮询
member = self.members[i % len(self.members)]
tasks.append(member.run(subtask['task'], user_id, session_id))
results = await asyncio.gather(*tasks, return_exceptions=True)
# 处理结果
formatted_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
formatted_results.append({
"task": subtasks[i]['task'],
"success": False,
"error": str(result)
})
else:
formatted_results.append({
"task": subtasks[i]['task'],
"success": True,
"content": result.content,
"tool_calls": result.tool_calls
})
return formatted_results
async def _dispatch_sequential(self, subtasks: List[Dict], user_id: int, session_id: str) -> List[Dict]:
"""顺序分发任务"""
results = []
context = ""
for i, subtask in enumerate(subtasks):
# 选择子智能体
if i < len(self.members):
member = self.members[i]
else:
member = self.members[i % len(self.members)]
# 传递前一个结果作为上下文
enhanced_task = f"{context}\n\n当前任务: {subtask['task']}" if context else subtask['task']
result = await member.run(enhanced_task, user_id, session_id)
results.append({
"task": subtask['task'],
"success": True,
"content": result.content,
"tool_calls": result.tool_calls
})
# 累加上下文
context += f"\n\n=== 任务: {subtask['task']} ===\n{result.content}"
return results
async def _aggregate(self, results: List[Dict]) -> str:
"""汇总结果"""
# 过滤成功的结果
success_results = [r for r in results if r.get('success')]
if not success_results:
return "所有子任务执行失败"
if len(success_results) == 1:
return success_results[0].get('content', '')
# 调用 LLM 汇总
summary_prompt = "请汇总以下所有任务的结果,生成一个完整的回复:\n\n"
for i, result in enumerate(success_results, 1):
summary_prompt += f"=== 任务 {i}: {result.get('task', '')} ===\n{result.get('content', '')}\n\n"
final_response = await self.supervisor.llm.generate(summary_prompt, [])
return final_response

View File

@@ -1,4 +0,0 @@
# LLM
from app.agent.llm.factory import LLMFactory
from app.agent.llm.openai import OpenAILLM
from app.agent.llm.anthropic import AnthropicLLM

View File

@@ -1,136 +0,0 @@
"""
Anthropic LLM 实现
"""
import os
from typing import Dict, Any, List, Optional
from anthropic import AsyncAnthropic
class AnthropicLLM:
"""Anthropic Claude LLM"""
def __init__(self, model_name: str = "claude-3-sonnet-20240229", api_key: Optional[str] = None, base_url: Optional[str] = None):
self.model_name = model_name
# 支持自定义 base_url如 OpenRouter
if base_url:
self.client = AsyncAnthropic(
api_key=api_key or os.getenv("ANTHROPIC_API_KEY", ""),
base_url=base_url
)
else:
self.client = AsyncAnthropic(
api_key=api_key or os.getenv("ANTHROPIC_API_KEY", "")
)
async def decide(self, prompt: str) -> Dict[str, Any]:
"""
LLM 决策 - 判断是否需要调用技能
Args:
prompt: 完整的 Prompt
Returns:
Dict: 包含 needs_skill, tool_calls, response 等
"""
system_prompt = """你是一个智能助手。请分析用户请求,判断是否需要调用工具来回答问题。
如果需要调用工具,请按以下格式返回 JSON
{"needs_skill": true, "tool_calls": [{"skill_id": "工具名称", "parameters": {"参数": ""}, "reason": "调用原因"}]}
如果不需要调用工具,请返回:
{"needs_skill": false, "response": "直接回答用户的内容"}
请只返回 JSON不要其他内容。"""
try:
response = await self.client.messages.create(
model=self.model_name,
max_tokens=2000,
system=system_prompt,
messages=[{"role": "user", "content": prompt}]
)
content = response.content[0].text
# 尝试解析 JSON
import json
try:
result = json.loads(content)
return result
except json.JSONDecodeError:
return {
"needs_skill": False,
"response": content
}
except Exception as e:
return {
"needs_skill": False,
"response": f"LLM 调用失败: {str(e)}"
}
async def generate(self, prompt: str, tool_results: List[Dict]) -> str:
"""
生成回复
Args:
prompt: 完整的 Prompt
tool_results: 工具调用结果
Returns:
str: 生成的回复
"""
user_message = prompt
# 添加工具结果作为上下文
if tool_results:
tool_context = "\n\n工具返回结果:\n"
for result in tool_results:
if result.get("success"):
tool_context += f"- {result.get('skill_id')}: {result.get('result')}\n"
user_message += tool_context
try:
response = await self.client.messages.create(
model=self.model_name,
max_tokens=4000,
messages=[{"role": "user", "content": user_message}]
)
return response.content[0].text
except Exception as e:
return f"生成回复失败: {str(e)}"
async def generate_stream(self, prompt: str, tool_results: List[Dict]):
"""
流式生成回复
Args:
prompt: 完整的 Prompt
tool_results: 工具调用结果
Yields:
str: 生成的回复片段
"""
user_message = prompt
# 添加工具结果作为上下文
if tool_results:
tool_context = "\n\n工具返回结果:\n"
for result in tool_results:
if result.get("success"):
tool_context += f"- {result.get('skill_id')}: {result.get('result')}\n"
user_message += tool_context
try:
async with self.client.messages.stream(
model=self.model_name,
max_tokens=4000,
messages=[{"role": "user", "content": user_message}]
) as stream:
async for text in stream.text_stream:
yield text
except Exception as e:
yield f"生成回复失败: {str(e)}"

View File

@@ -1,32 +0,0 @@
"""
LLM Factory - LLM 工厂类
"""
from typing import Optional
from app.agent.llm.openai import OpenAILLM
from app.agent.llm.anthropic import AnthropicLLM
class LLMFactory:
"""LLM 工厂类"""
@staticmethod
def create(provider: str, model_name: str, api_key: Optional[str] = None, base_url: Optional[str] = None):
"""
创建 LLM 实例
Args:
provider: 模型提供商 (openai/anthropic)
model_name: 模型名称
api_key: API Key可选
base_url: Base URL可选
Returns:
LLM 实例
"""
if provider.lower() == "openai":
return OpenAILLM(model_name, api_key, base_url)
elif provider.lower() == "anthropic":
return AnthropicLLM(model_name, api_key, base_url)
else:
# 默认使用 OpenAI
return OpenAILLM(model_name, api_key, base_url)

View File

@@ -1,167 +0,0 @@
"""
OpenAI LLM 实现
"""
import os
import logging
from typing import Dict, Any, List, Optional
from openai import AsyncOpenAI
from openai._client import AsyncOpenAI
logger = logging.getLogger("llm.openai")
class OpenAILLM:
"""OpenAI LLM"""
def __init__(self, model_name: str = "gpt-4", api_key: Optional[str] = None, base_url: Optional[str] = None):
self.model_name = model_name
# 优先使用传入的参数,否则使用环境变量
self.api_key = api_key or os.getenv("OPENAI_API_KEY", "")
self.base_url = base_url or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
api_key_info = f"{self.api_key[:10]}..." if self.api_key else "None"
logger.info(f"初始化 OpenAI LLM: model={model_name}, api_key={api_key_info}, base_url={self.base_url}")
if not self.api_key:
logger.warning("⚠️ WARNING: No API key provided!")
# 配置超时
self.client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
timeout=60.0, # 60秒超时
max_retries=1 # 减少重试次数
)
async def decide(self, prompt: str) -> Dict[str, Any]:
"""
LLM 决策 - 判断是否需要调用技能
Args:
prompt: 完整的 Prompt
Returns:
Dict: 包含 needs_skill, tool_calls, response 等
"""
# 构建决策用的系统提示
system_prompt = """你是一个智能助手。请分析用户请求,判断是否需要调用工具来回答问题。
如果需要调用工具,请按以下格式返回 JSON
{
"needs_skill": true,
"tool_calls": [
{"skill_id": "工具名称", "parameters": {"参数": ""}, "reason": "调用原因"}
]
}
如果不需要调用工具,请返回:
{
"needs_skill": false,
"response": "直接回答用户的内容"
}
请只返回 JSON不要其他内容。"""
try:
response = await self.client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
temperature=0.7,
max_tokens=2000
)
content = response.choices[0].message.content
# 尝试解析 JSON
import json
try:
result = json.loads(content)
return result
except json.JSONDecodeError:
# 解析失败,作为普通回复处理
return {
"needs_skill": False,
"response": content
}
except Exception as e:
return {
"needs_skill": False,
"response": f"LLM 调用失败: {str(e)}"
}
async def generate(self, prompt: str, tool_results: List[Dict]) -> str:
"""
生成回复
Args:
prompt: 完整的 Prompt
tool_results: 工具调用结果
Returns:
str: 生成的回复
"""
messages = [{"role": "user", "content": prompt}]
# 添加工具结果作为上下文
if tool_results:
for result in tool_results:
if result.get("success"):
messages.append({
"role": "assistant",
"content": f"工具 {result.get('skill_id')} 返回: {result.get('result')}"
})
try:
response = await self.client.chat.completions.create(
model=self.model_name,
messages=messages,
temperature=0.7,
max_tokens=4000
)
return response.choices[0].message.content
except Exception as e:
return f"生成回复失败: {str(e)}"
async def generate_stream(self, prompt: str, tool_results: List[Dict]):
"""
流式生成回复
Args:
prompt: 完整的 Prompt
tool_results: 工具调用结果
Yields:
str: 生成的回复片段
"""
messages = [{"role": "user", "content": prompt}]
# 添加工具结果作为上下文
if tool_results:
for result in tool_results:
if result.get("success"):
messages.append({
"role": "assistant",
"content": f"工具 {result.get('skill_id')} 返回: {result.get('result')}"
})
try:
response = await self.client.chat.completions.create(
model=self.model_name,
messages=messages,
temperature=0.7,
max_tokens=4000,
stream=True
)
async for chunk in response:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
except Exception as e:
yield f"生成回复失败: {str(e)}"

View File

@@ -1,5 +0,0 @@
# Memory
from app.agent.memory.manager import MemoryManager
from app.agent.memory.working import WorkingMemory
from app.agent.memory.session import SessionMemory
from app.agent.memory.persistent import PersistentMemory

View File

@@ -1,99 +0,0 @@
"""
Memory Manager - 记忆管理器
"""
from typing import Dict, List, Optional
from app.agent.memory.working import WorkingMemory
from app.agent.memory.session import SessionMemory
from app.agent.memory.persistent import PersistentMemory
class MemoryManager:
"""记忆管理器 - 统一接口"""
def __init__(self, agent_id: int):
self.agent_id = agent_id
self.working = WorkingMemory()
self.session = SessionMemory(agent_id)
self.persistent = PersistentMemory(agent_id)
async def load_context(self, query: str, user_id: int, session_id: str) -> Dict[str, str]:
"""
加载上下文记忆
优化:跳过耗时的向量搜索,提升响应速度
生产环境可以加回来
Args:
query: 查询内容
user_id: 用户 ID
session_id: 会话 ID
Returns:
Dict: 包含 summary, knowledge 等
"""
# 1. Working Memory (内存,最快)
working_context = self.working.get()
# 2. Session Memory (Redis) - 暂时跳过,减少延迟
# session_context = await self.session.get_summary(user_id, session_id)
session_context = ""
# 3. Persistent Memory (向量库) - 暂时跳过,减少延迟
# persistent_context = await self.persistent.search(query, user_id, top_k=3)
persistent_context = []
return {
'working': working_context.get('recent_messages', []),
'session': session_context,
'persistent': persistent_context,
'summary': "", # 简化
'knowledge': ""
}
async def save(self, user_input: str, response: str, user_id: int, session_id: str):
"""
保存记忆
Args:
user_input: 用户输入
response: 智能体回复
user_id: 用户 ID
session_id: 会话 ID
"""
# 1. 写入 Working
self.working.add(user_input, response)
# 2. 写入 Session (定期摘要)
await self.session.add(user_input, response, user_id, session_id)
# 3. 提取关键信息写入 Persistent (定期)
if self.working.size() >= 5:
await self._extract_and_persist(user_input, response, user_id)
def _build_summary(self, session_context: str, persistent_context: List[str]) -> str:
"""构建记忆摘要"""
parts = []
if session_context:
parts.append(f"会话记忆: {session_context}")
if persistent_context:
parts.append(f"长期记忆: {'; '.join(persistent_context[:3])}")
return "\n".join(parts) if parts else "无相关记忆"
async def _extract_and_persist(self, user_input: str, response: str, user_id: int):
"""提取并持久化关键信息"""
# 提取关键信息简化版取前100字符作为摘要
key_points = []
# 简化:直接保存重要交互
if len(response) > 50: # 只保存有意义的回复
summary = response[:100] + "..."
key_points.append(summary)
for point in key_points:
await self.persistent.add(point, user_id, memory_type="conversation")
# 重置 Working Memory
self.working.clear()

View File

@@ -1,109 +0,0 @@
"""
Persistent Memory - 长期记忆(向量存储)
"""
from typing import List, Optional
class PersistentMemory:
"""长期记忆,向量存储"""
def __init__(self, agent_id: int, vector_store=None):
"""
初始化长期记忆
Args:
agent_id: 智能体 ID
vector_store: 向量存储客户端(可选)
"""
self.agent_id = agent_id
self.vector_store = vector_store
self.use_vector = vector_store is not None
async def add(self, content: str, user_id: int, memory_type: str = "experience"):
"""
添加长期记忆
Args:
content: 记忆内容
user_id: 用户 ID
memory_type: 记忆类型 (experience/preference/conversation)
"""
if not self.use_vector:
return self._add_memory(content, user_id, memory_type)
# 生成向量并存储
embedding = await self._get_embedding(content)
# TODO: 调用向量存储 API
async def search(self, query: str, user_id: int, top_k: int = 3) -> List[str]:
"""
搜索相关记忆
Args:
query: 查询内容
user_id: 用户 ID
top_k: 返回数量
Returns:
List[str]: 相关的记忆列表
"""
if not self.use_vector:
return self._search_memory(query, user_id, top_k)
# 生成查询向量
query_embedding = await self._get_embedding(query)
# TODO: 调用向量搜索 API
# results = await self.vector_store.search(
# agent_id=self.agent_id,
# user_id=user_id,
# embedding=query_embedding,
# top_k=top_k
# )
return []
async def _get_embedding(self, text: str) -> List[float]:
"""
获取文本向量
Args:
text: 文本
Returns:
List[float]: 向量
"""
# TODO: 实现向量生成
# 可以使用 OpenAI Embedding API 或本地模型
import hashlib
# 简化:使用文本哈希模拟
h = hashlib.md5(text.encode()).digest()
return [float(b) / 255.0 for b in h[:16]] + [0.0] * 16
# === 内存模拟(无向量存储时使用)===
_memory_store = {}
def _add_memory(self, content: str, user_id: int, memory_type: str):
"""内存模拟 - 添加"""
key = f"{self.agent_id}:{user_id}"
if key not in self._memory_store:
self._memory_store[key] = []
self._memory_store[key].append({
"content": content,
"type": memory_type
})
def _search_memory(self, query: str, user_id: int, top_k: int) -> List[str]:
"""内存模拟 - 搜索(简化版:关键词匹配)"""
key = f"{self.agent_id}:{user_id}"
if key not in self._memory_store:
return []
# 简化:包含查询词的记忆
results = []
for mem in self._memory_store[key]:
if query.lower() in mem["content"].lower():
results.append(mem["content"])
return results[:top_k]

View File

@@ -1,125 +0,0 @@
"""
Session Memory - 会话级记忆Redis 存储)
"""
import json
from typing import Optional
class SessionMemory:
"""会话级记忆Redis 存储"""
def __init__(self, agent_id: int, redis_client=None):
"""
初始化会话记忆
Args:
agent_id: 智能体 ID
redis_client: Redis 客户端(可选)
"""
self.agent_id = agent_id
self.redis = redis_client
self.ttl = 3600 * 24 # 24 小时
self.summary_threshold = 10 # 多少条消息后生成摘要
def _key(self, user_id: int, session_id: str) -> str:
"""生成 Redis Key"""
return f"agent:memory:session:{self.agent_id}:{user_id}:{session_id}"
async def add(self, user_input: str, response: str, user_id: int, session_id: str):
"""
添加对话到会话记忆
Args:
user_input: 用户输入
response: 智能体回复
user_id: 用户 ID
session_id: 会话 ID
"""
if not self.redis:
# 如果没有 Redis使用内存模拟
return self._add_memory(user_input, response, user_id, session_id)
key = self._key(user_id, session_id)
# 获取现有数据
data = await self.redis.get(key)
messages = json.loads(data) if data else {"messages": [], "summary": ""}
# 添加新消息
messages["messages"].append({"role": "user", "content": user_input})
messages["messages"].append({"role": "assistant", "content": response})
# 定期生成摘要
if len(messages["messages"]) >= self.summary_threshold:
messages["summary"] = await self._generate_summary(messages["messages"])
# 保持消息数量
if len(messages["messages"]) > 50:
messages["messages"] = messages["messages"][-50:]
await self.redis.setex(key, self.ttl, json.dumps(messages))
async def get_summary(self, user_id: int, session_id: str) -> str:
"""
获取会话摘要
Args:
user_id: 用户 ID
session_id: 会话 ID
Returns:
str: 会话摘要
"""
if not self.redis:
return self._get_memory_summary(user_id, session_id)
key = self._key(user_id, session_id)
data = await self.redis.get(key)
if data:
messages = json.loads(data)
return messages.get("summary", "")
return ""
async def _generate_summary(self, messages: list) -> str:
"""
生成摘要(简化版)
Args:
messages: 消息列表
Returns:
str: 摘要
"""
# 简化:取最后几条消息的要点
if not messages:
return ""
recent = messages[-6:] # 最近 3 轮
summary = f"最近对话包含 {len(messages)//2} 轮交互"
# TODO: 后续可以使用 LLM 生成更好的摘要
return summary
# === 内存模拟(无 Redis 时使用)===
_memory_store = {}
def _add_memory(self, user_input: str, response: str, user_id: int, session_id: str):
"""内存模拟 - 添加"""
key = f"{self.agent_id}:{user_id}:{session_id}"
if key not in self._memory_store:
self._memory_store[key] = {"messages": [], "summary": ""}
messages = self._memory_store[key]["messages"]
messages.append({"role": "user", "content": user_input})
messages.append({"role": "assistant", "content": response})
if len(messages) >= self.summary_threshold:
self._memory_store[key]["summary"] = self._generate_summary(messages)
def _get_memory_summary(self, user_id: int, session_id: str) -> str:
"""内存模拟 - 获取摘要"""
key = f"{self.agent_id}:{user_id}:{session_id}"
if key in self._memory_store:
return self._memory_store[key].get("summary", "")
return ""

View File

@@ -1,47 +0,0 @@
"""
Working Memory - 当前任务上下文(内存级存储)
"""
class WorkingMemory:
"""当前任务上下文,内存级存储"""
def __init__(self):
self.current_task = None
self.recent_messages = []
self.max_size = 10 # 最大保留 10 轮对话
def get(self) -> dict:
"""获取当前记忆"""
return {
'current_task': self.current_task,
'recent_messages': self.recent_messages[-self.max_size:]
}
def add(self, user_input: str, response: str):
"""添加对话到记忆"""
self.recent_messages.append({
'role': 'user',
'content': user_input
})
self.recent_messages.append({
'role': 'assistant',
'content': response
})
# 保持固定大小
if len(self.recent_messages) > self.max_size * 2:
self.recent_messages = self.recent_messages[-self.max_size * 2:]
def set_current_task(self, task: str):
"""设置当前任务"""
self.current_task = task
def clear(self):
"""清空记忆"""
self.recent_messages = []
self.current_task = None
def size(self) -> int:
"""获取对话轮数"""
return len(self.recent_messages) // 2

View File

@@ -1,3 +0,0 @@
# Skills
from app.agent.skills.router import SkillRouter
from app.agent.skills.executor import SkillExecutor

View File

@@ -1,129 +0,0 @@
"""
Skill Executor - 技能执行器
"""
import asyncio
from typing import List, Dict, Any
class SkillExecutor:
"""技能执行器,支持并发/串行执行"""
def __init__(self, skill_registry=None):
"""
初始化技能执行器
Args:
skill_registry: 技能注册表(可选)
"""
self.skill_registry = skill_registry
self._skill_handlers = self._init_default_handlers()
def _init_default_handlers(self) -> Dict[str, callable]:
"""初始化默认技能处理器"""
return {
"query_database": self._handle_query_database,
"data_analysis": self._handle_data_analysis,
"search_knowledge": self._handle_search_knowledge,
"web_search": self._handle_web_search,
}
async def execute(self, skill_id: str, params: dict) -> Dict[str, Any]:
"""
执行单个技能
Args:
skill_id: 技能 ID
params: 技能参数
Returns:
Dict: 执行结果
"""
handler = self._skill_handlers.get(skill_id)
if not handler:
return {
"success": False,
"error": f"Skill {skill_id} not found"
}
try:
result = await handler(params)
return {
"success": True,
"skill_id": skill_id,
"result": result
}
except Exception as e:
return {
"success": False,
"skill_id": skill_id,
"error": str(e)
}
async def execute_multiple(self, skills: List[Dict], strategy: str = "parallel") -> List[Dict]:
"""
批量执行技能
Args:
skills: 技能列表
strategy: 执行策略 (parallel/sequential)
Returns:
List[Dict]: 执行结果列表
"""
if strategy == "parallel":
tasks = [self.execute(s['skill_id'], s['params']) for s in skills]
results = await asyncio.gather(*tasks, return_exceptions=True)
# 处理异常结果
formatted_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
formatted_results.append({
"success": False,
"skill_id": skills[i]['skill_id'],
"error": str(result)
})
else:
formatted_results.append(result)
return formatted_results
else:
results = []
for s in skills:
result = await self.execute(s['skill_id'], s['params'])
results.append(result)
return results
# === 默认技能处理器 ===
async def _handle_query_database(self, params: dict) -> Dict[str, Any]:
"""处理数据库查询"""
# TODO: 调用现有的数据库查询功能
return {
"message": "数据库查询功能待实现",
"sql": params.get("sql", "")
}
async def _handle_data_analysis(self, params: dict) -> Dict[str, Any]:
"""处理数据分析"""
# TODO: 调用现有的数据分析功能
return {
"message": "数据分析功能待实现",
"data": params.get("data", {})
}
async def _handle_search_knowledge(self, params: dict) -> Dict[str, Any]:
"""处理知识库搜索"""
# TODO: 调用现有的知识库搜索功能
return {
"message": "知识库搜索功能待实现",
"query": params.get("query", "")
}
async def _handle_web_search(self, params: dict) -> Dict[str, Any]:
"""处理网页搜索"""
# TODO: 实现网页搜索
return {
"message": "网页搜索功能待实现",
"query": params.get("query", "")
}

View File

@@ -1,46 +0,0 @@
"""
Skill Router - 技能路由器
"""
from typing import List, Dict
class SkillRouter:
"""根据 LLM 决策选择要调用的技能"""
def __init__(self, available_skills: List[int]):
"""
初始化技能路由器
Args:
available_skills: 可用技能 ID 列表
"""
self.available_skills = available_skills
async def route(self, llm_decision: dict) -> List[dict]:
"""
解析 LLM 的技能调用决策
Args:
llm_decision: LLM 决策结果
Returns:
List[dict]: 要执行的技能列表
"""
if not llm_decision.get('tool_calls'):
return []
routes = []
for tool_call in llm_decision['tool_calls']:
skill_id = tool_call.get('skill_id') or tool_call.get('tool_name')
# 检查技能是否可用
if self.available_skills and skill_id not in self.available_skills:
continue
routes.append({
'skill_id': skill_id,
'params': tool_call.get('parameters', {}),
'reason': tool_call.get('reason', '')
})
return routes

View File

@@ -1,549 +0,0 @@
"""
FastAPI Agent Engine Server
"""
import os
import sys
import time
import logging
from datetime import datetime
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from fastapi.middleware.cors import CORSMiddleware
import asyncio
from app.agent.core import AgentConfig
from app.xbot import XBotAgent
# 日志目录 - 放在 server/logs 下
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "server", "logs", datetime.now().strftime("%Y-%m-%d"))
os.makedirs(LOG_DIR, exist_ok=True)
# 成功/失败日志文件
today = datetime.now().strftime("%Y-%m-%d")
success_log_file = os.path.join(LOG_DIR, f"python_success.log")
failure_log_file = os.path.join(LOG_DIR, f"python_failure.log")
def setup_logging():
"""配置日志系统"""
log_level = os.getenv("LOG_LEVEL", "INFO")
# 创建格式化器
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
# 成功日志文件处理器
success_handler = logging.FileHandler(success_log_file, encoding='utf-8')
success_handler.setFormatter(formatter)
success_handler.setLevel(logging.INFO)
# 失败日志文件处理器
failure_handler = logging.FileHandler(failure_log_file, encoding='utf-8')
failure_handler.setFormatter(formatter)
failure_handler.setLevel(logging.WARNING)
# 根日志配置
root_logger = logging.getLogger()
root_logger.setLevel(getattr(logging, log_level))
root_logger.addHandler(console_handler)
root_logger.addHandler(success_handler)
root_logger.addHandler(failure_handler)
return root_logger
# 成功日志记录器(只记录 INFO 级别到成功日志)
class SuccessLogger:
"""成功日志记录器"""
@staticmethod
def log(message: str):
"""记录成功日志"""
logger = logging.getLogger("success")
logger.setLevel(logging.INFO)
handler = logging.FileHandler(success_log_file, encoding='utf-8')
handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S'))
logger.addHandler(handler)
logger.info(message)
# 同时输出到控制台
print(f"{message}")
# 失败日志记录器
class FailureLogger:
"""失败日志记录器"""
@staticmethod
def log(message: str, error: str = ""):
"""记录失败日志"""
logger = logging.getLogger("failure")
logger.setLevel(logging.WARNING)
handler = logging.FileHandler(failure_log_file, encoding='utf-8')
handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s - %(error)s', datefmt='%Y-%m-%d %H:%M:%S'))
logger.addHandler(handler)
full_message = f"{message} - Error: {error}" if error else message
logger.warning(full_message)
# 同时输出到控制台
print(f"{full_message}")
logger = setup_logging()
app = FastAPI(title="X-Agents Python Engine", version="1.0.0")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# === 请求/响应模型 ===
class ChatRequest(BaseModel):
"""对话请求"""
agent_id: int
message: str
user_id: int = 1
session_id: Optional[str] = None
# 模型参数(可选,如果传了就使用,否则用智能体配置的默认模型)
model_id: Optional[str] = None
model_name: Optional[str] = None
model_provider: Optional[str] = None
api_key: Optional[str] = None
base_url: Optional[str] = None
# Embedding 模型(可选)
embedding_model: Optional[str] = None
embedding_base_url: Optional[str] = None
class TeamChatRequest(BaseModel):
"""多智能体群聊请求"""
supervisor_agent_id: int
member_agent_ids: list[int]
message: str
user_id: int = 1
session_id: Optional[str] = None
strategy: str = "parallel"
class CreateAgentRequest(BaseModel):
"""创建智能体请求"""
name: str
description: Optional[str] = None
avatar: str = "🤖"
# 技能配置
skills_mode: str = "all" # all / include / exclude
skills: list[str] = [] # 技能ID列表
# 知识库
knowledge: str = "general" # general / codebase / docs / api
# 自定义提示词
prompt: Optional[str] = None
# 模型配置
model_provider: Optional[str] = None
model_name: Optional[str] = None
user_id: int = 1
class CreateAgentResponse(BaseModel):
"""创建智能体响应"""
agent_id: int
name: str
message: str = "Agent created successfully"
class ChatResponse(BaseModel):
"""对话响应"""
agent_id: int
response: str
tool_calls: list = []
tokens_used: int = 0
duration_ms: int = 0
session_id: Optional[str] = None
# === 模拟数据存储 ===
# TODO: 后续替换为从数据库加载
_mock_agents = {
1: {
"id": 1,
"name": "数据分析助手",
"role_description": "你是一个专业的数据分析助手,擅长分析数据、生成报告。",
"model_provider": "openai",
"model_name": "gpt-4",
"skills": [1, 2]
},
2: {
"id": 2,
"name": "代码审查助手",
"role_description": "你是一个专业的代码审查助手擅长审查代码、发现bug。",
"model_provider": "openai",
"model_name": "gpt-4",
"skills": [3]
}
}
def get_agent_config(agent_id: int, api_key: str = None, base_url: str = None) -> AgentConfig:
"""获取智能体配置"""
agent_data = _mock_agents.get(agent_id)
if not agent_data:
raise HTTPException(status_code=404, detail="Agent not found")
return AgentConfig(
id=agent_data["id"],
name=agent_data["name"],
role_description=agent_data["role_description"],
model_provider=agent_data["model_provider"],
model_name=agent_data["model_name"],
api_key=api_key,
base_url=base_url,
skills=agent_data.get("skills", [])
)
# === API 路由 ===
@app.get("/")
async def root():
return {"message": "X-Agents Python Engine", "version": "1.0.0"}
@app.get("/health")
async def health():
return {"status": "healthy"}
@app.post("/agent/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
"""
单智能体对话
"""
chat_logger = logging.getLogger("agent.chat")
# 打印请求参数(隐藏 api_key 敏感信息)
api_key_preview = f"{request.api_key[:10]}..." if request.api_key else "None"
chat_logger.info(f"========== 收到聊天请求 ==========")
chat_logger.info(f"agent_id: {request.agent_id}")
chat_logger.info(f"model_id: {request.model_id}")
chat_logger.info(f"model_provider: {request.model_provider}")
chat_logger.info(f"model_name: {request.model_name}")
chat_logger.info(f"api_key: {api_key_preview}")
chat_logger.info(f"base_url: {request.base_url}")
chat_logger.info(f"message: {request.message[:50]}...")
start_time = time.time()
# 获取智能体配置
try:
config = get_agent_config(request.agent_id, request.api_key, request.base_url)
chat_logger.info(f"Agent config loaded: provider={config.model_provider}, model={config.model_name}")
except HTTPException as e:
FailureLogger.log(f"Agent not found: agent_id={request.agent_id}", str(e))
chat_logger.error(f"Agent not found: {e}")
raise
except Exception as e:
FailureLogger.log(f"Error loading config: agent_id={request.agent_id}", str(e))
chat_logger.error(f"Error loading config: {e}")
raise HTTPException(status_code=400, detail=str(e))
# 如果请求中指定了模型,覆盖智能体的默认配置
if request.model_provider:
config.model_provider = request.model_provider
if request.model_name:
config.model_name = request.model_name
chat_logger.info(f"Final LLM config: provider={config.model_provider}, model={config.model_name}, api_key={config.api_key[:10] if config.api_key else 'None'}..., base_url={config.base_url}")
# 生成 session_id
session_id = request.session_id or f"session_{int(time.time())}"
# 执行对话 - 默认使用 XBot Agent (nanobot 核心)
try:
xbot = XBotAgent(
name=config.name,
role_description=config.role_description,
provider=config.model_provider,
model=config.model_name,
api_key=request.api_key or config.api_key,
base_url=request.base_url or config.base_url,
embedding_model=request.embedding_model,
embedding_base_url=request.embedding_base_url,
)
result = await xbot.run(request.message, session_id)
response_content = result["content"]
tool_calls = [{"name": tc} for tc in result.get("tool_calls", [])] if result.get("tool_calls") else []
except Exception as e:
FailureLogger.log(f"Agent execution failed: agent_id={request.agent_id}, message={request.message[:30]}", str(e))
chat_logger.error(f"Agent execution error: {e}")
raise HTTPException(status_code=500, detail=str(e))
duration_ms = int((time.time() - start_time) * 1000)
# 记录成功日志
SuccessLogger.log(f"Chat success: agent_id={request.agent_id}, duration={duration_ms}ms, message={request.message[:30]}...")
return ChatResponse(
agent_id=request.agent_id,
response=response_content,
tool_calls=tool_calls,
tokens_used=0,
duration_ms=duration_ms,
session_id=session_id
)
@app.post("/agent/chat/stream")
async def chat_stream(request: ChatRequest):
"""
单智能体对话(流式输出)
"""
chat_logger = logging.getLogger("agent.chat.stream")
# 打印请求参数
api_key_preview = f"{request.api_key[:10]}..." if request.api_key else "None"
base_url_preview = request.base_url if request.base_url else "None"
chat_logger.info(f"========== 收到流式聊天请求 ==========")
chat_logger.info(f"agent_id: {request.agent_id}")
chat_logger.info(f"model_provider: {request.model_provider}")
chat_logger.info(f"model_name: {request.model_name}")
chat_logger.info(f"api_key: {api_key_preview}")
chat_logger.info(f"base_url: {base_url_preview}")
# 获取智能体配置
try:
config = get_agent_config(request.agent_id, request.api_key, request.base_url)
except HTTPException as e:
chat_logger.error(f"Agent not found: {e}")
raise
except Exception as e:
chat_logger.error(f"Error loading config: {e}")
raise HTTPException(status_code=400, detail=str(e))
# 如果请求中指定了模型,覆盖智能体的默认配置
if request.model_provider:
config.model_provider = request.model_provider
if request.model_name:
config.model_name = request.model_name
chat_logger.info(f"最终配置 - provider: {config.model_provider}, model: {config.model_name}, base_url: {config.base_url}")
# 生成 session_id
session_id = request.session_id or f"session_{int(time.time())}"
# Mock 模式测试流式
if request.message.startswith("/mock "):
mock_text = request.message[6:] # 去掉 "/mock " 前缀
async def mock_stream():
for char in mock_text:
yield f"data: {char}\n\n"
await asyncio.sleep(0.05) # 50ms 延迟模拟流式
yield f"data: [DONE]\n\n"
return StreamingResponse(mock_stream(), media_type="text/event-stream")
# 使用 XBot Agent (nanobot 核心)
xbot = XBotAgent(
name=config.name,
role_description=config.role_description,
provider=config.model_provider,
model=config.model_name,
api_key=request.api_key or config.api_key,
base_url=request.base_url or config.base_url,
embedding_model=request.embedding_model,
embedding_base_url=request.embedding_base_url,
)
async def event_generator():
"""SSE 事件生成器"""
try:
# 执行流式对话
async for chunk in xbot.run_stream(request.message, session_id):
# 发送 SSE 格式的数据
yield f"data: {chunk}\n\n"
# 发送结束信号
yield f"data: [DONE]\n\n"
except Exception as e:
chat_logger.error(f"Stream error: {e}")
yield f"data: {{\"error\": \"{str(e)}\"}}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@app.post("/agent/team/chat")
async def team_chat(request: TeamChatRequest):
"""
多智能体群聊
"""
start_time = time.time()
# 创建主智能体
try:
supervisor_config = get_agent_config(request.supervisor_agent_id)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# 使用 XBot 作为主智能体
supervisor_agent = XBotAgent(
name=supervisor_config.name,
role_description=supervisor_config.role_description,
provider=supervisor_config.model_provider,
model=supervisor_config.model_name,
api_key=supervisor_config.api_key,
base_url=supervisor_config.base_url,
)
# 创建子智能体
members = []
for member_id in request.member_agent_ids:
try:
member_config = get_agent_config(member_id)
members.append(XBotAgent(
name=member_config.name,
role_description=member_config.role_description,
provider=member_config.model_provider,
model=member_config.model_name,
api_key=member_config.api_key,
base_url=member_config.base_url,
))
except:
continue
if not members:
raise HTTPException(status_code=400, detail="No valid member agents")
# TODO: 群聊调度逻辑 - 目前简化为串行执行
# 生成 session_id
session_id = request.session_id or f"team_session_{int(time.time())}"
# 串行执行每个智能体
subtask_results = []
main_response = ""
try:
# 主智能体先处理
result = await supervisor_agent.run(request.message, session_id)
main_response = result["content"]
subtask_results.append({
"agent_id": request.supervisor_agent_id,
"response": main_response,
})
# 子智能体并行处理
# import asyncio
# results = await asyncio.gather(*[m.run(request.message, session_id) for m in members])
# for m, r in zip(members, results):
# subtask_results.append({"agent_id": m.name, "response": r["content"]})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
duration_ms = int((time.time() - start_time) * 1000)
return {
"supervisor_agent_id": request.supervisor_agent_id,
"response": main_response,
"subtask_results": subtask_results,
"strategy": request.strategy or "parallel",
"duration_ms": duration_ms,
"session_id": session_id
}
@app.post("/agent/create", response_model=CreateAgentResponse)
async def create_agent(request: CreateAgentRequest):
"""
创建新的智能体
"""
import json
import uuid
# 生成唯一的 agent_id
agent_id = int(datetime.now().timestamp() * 1000) % 100000
# 构建 Agent 配置
agent_config = {
"id": agent_id,
"name": request.name,
"description": request.description or "",
"avatar": request.avatar,
"skills_mode": request.skills_mode,
"skills": request.skills,
"knowledge": request.knowledge,
"role_description": request.prompt or f"You are {request.name}. {request.description or ''}",
"model_provider": request.model_provider or "anthropic",
"model_name": request.model_name or "claude-sonnet-4-20250514",
}
# 保存到 agents 目录
agents_dir = os.path.join(os.path.dirname(__file__), "agents")
os.makedirs(agents_dir, exist_ok=True)
config_file = os.path.join(agents_dir, f"agent_{agent_id}.json")
with open(config_file, "w", encoding="utf-8") as f:
json.dump(agent_config, f, ensure_ascii=False, indent=2)
logger.info(f"Agent created: {request.name} (ID: {agent_id})")
return CreateAgentResponse(
agent_id=agent_id,
name=request.name,
message="Agent created successfully"
)
@app.get("/agent/list")
async def list_agents():
"""
获取智能体列表
"""
import json
agents_dir = os.path.join(os.path.dirname(__file__), "agents")
if not os.path.exists(agents_dir):
return {"agents": []}
agents = []
for file in os.listdir(agents_dir):
if file.endswith(".json"):
config_file = os.path.join(agents_dir, file)
try:
with open(config_file, "r", encoding="utf-8") as f:
agent = json.load(f)
agents.append(agent)
except:
continue
return {"agents": agents}
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("AGENT_PORT", "8081"))
uvicorn.run(
app,
host="0.0.0.0",
port=port,
loop="asyncio",
http="h11",
access_log=False,
timeout_keep_alive=5,
)

View File

@@ -1,17 +0,0 @@
"""XBot - 轻量级 Agent 框架(基于 nanobot 核心)"""
from .loop import AgentLoop
from .memory import MemoryConsolidator, MemoryStore
from .session import Session, SessionManager
from .adapter import XBotLLMAdapter
from .agent import XBotAgent
__all__ = [
"AgentLoop",
"MemoryConsolidator",
"MemoryStore",
"Session",
"SessionManager",
"XBotLLMAdapter",
"XBotAgent",
]

View File

@@ -1,186 +0,0 @@
"""LLM Adapter - 将现有 LLM 适配到 XBot 接口"""
import json
from dataclasses import dataclass, field
from typing import Any, Optional
from app.agent.llm.factory import LLMFactory
@dataclass
class ToolCallRequest:
"""A tool call request from the LLM."""
id: str
name: str
arguments: dict[str, Any]
def to_openai_tool_call(self) -> dict[str, Any]:
return {
"id": self.id,
"type": "function",
"function": {
"name": self.name,
"arguments": json.dumps(self.arguments, ensure_ascii=False),
},
}
@dataclass
class LLMResponse:
"""Response from an LLM provider."""
content: str | None
tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict)
reasoning_content: str | None = None
@property
def has_tool_calls(self) -> bool:
return len(self.tool_calls) > 0
class XBotLLMAdapter:
"""
适配器:将现有 LLM 适配到 XBot 的 LLMProvider 接口
封装 LLMFactory 创建的 LLM使其符合 nanobot 风格的接口:
- chat_with_retry(messages, tools, model) -> LLMResponse
"""
def __init__(
self,
provider: str,
model_name: str,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 4096,
):
self.provider_name = provider
self.model = model_name
self.temperature = temperature
self.max_tokens = max_tokens
# 创建底层 LLM
self._llm = LLMFactory.create(provider, model_name, api_key, base_url)
# 检查是否支持 tool calling
self._supports_tools = self._check_tool_support()
def _check_tool_support(self) -> bool:
"""检查模型是否支持 tool calling"""
# GPT-4, Claude 支持 tool calling
# 简单的判断逻辑
model_lower = self.model.lower()
if "gpt-4" in model_lower or "claude" in model_lower:
return True
return True # 默认支持
async def chat_with_retry(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
) -> LLMResponse:
"""
发送聊天请求(支持 tool calling
Args:
messages: 消息列表
tools: 工具定义列表
model: 模型名称(可选)
max_tokens: 最大 tokens可选
temperature: 温度(可选)
Returns:
LLMResponse: 包含内容和/或工具调用
"""
model = model or self.model
max_tokens = max_tokens or self.max_tokens
temperature = temperature or self.temperature
try:
# 使用流式调用来获取完整响应
response = await self._llm.client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
temperature=temperature,
max_tokens=max_tokens,
)
message = response.choices[0].message
# 检查是否有 tool calls
if message.tool_calls and tools:
tool_calls = []
for tc in message.tool_calls:
tool_calls.append(ToolCallRequest(
id=tc.id,
name=tc.function.name,
arguments=json.loads(tc.function.arguments) if isinstance(tc.function.arguments, str) else tc.function.arguments,
))
return LLMResponse(
content=message.content,
tool_calls=tool_calls,
finish_reason="tool_calls",
)
else:
return LLMResponse(
content=message.content or "",
finish_reason="stop",
)
except Exception as e:
return LLMResponse(
content=f"Error calling LLM: {str(e)}",
finish_reason="error",
)
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> LLMResponse:
"""简化的 chat 方法"""
return await self.chat_with_retry(
messages=messages,
tools=tools,
model=model,
max_tokens=max_tokens,
temperature=temperature,
)
async def chat_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
):
"""流式聊天"""
model = model or self.model
try:
response = await self._llm.client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
async for chunk in response:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
except Exception as e:
yield f"Error: {str(e)}"

View File

@@ -1,309 +0,0 @@
"""XBot Agent - 封装 nanobot 核心能力的 Agent"""
import os
from pathlib import Path
from typing import Any, Optional
from datetime import datetime
from .loop import AgentLoop
from .session import SessionManager
from .adapter import XBotLLMAdapter, LLMResponse
from . import config
# 尝试导入 simplemem
try:
from simplemem import SimpleMemSystem
HAS_SIMPLEMEM = True
except ImportError:
HAS_SIMPLEMEM = False
class SimpleToolRegistry:
"""简单的工具注册表"""
def __init__(self):
self._tools: dict[str, Any] = {}
def register(self, name: str, func: Any, description: str = "") -> None:
"""注册一个工具"""
self._tools[name] = {
"function": func,
"description": description,
}
def get_definitions(self) -> list[dict]:
"""获取工具定义列表"""
tools = []
for name, tool in self._tools.items():
tools.append({
"type": "function",
"function": {
"name": name,
"description": tool.get("description", ""),
"parameters": {
"type": "object",
"properties": {},
"required": [],
}
}
})
return tools
def get(self, name: str) -> Optional[Any]:
"""获取工具"""
return self._tools.get(name)
async def execute(self, name: str, arguments: dict) -> Any:
"""执行工具"""
tool = self._tools.get(name)
if not tool:
return f"Tool {name} not found"
func = tool.get("function")
if not func:
return f"Tool {name} has no function"
try:
if callable(func):
return await func(**arguments) if hasattr(func, '__await__') else func(**arguments)
return "Tool function is not callable"
except Exception as e:
return f"Tool execution error: {str(e)}"
class XBotAgent:
"""
XBot Agent - 基于 nanobot 核心的 Agent 实现
特性:
- 多轮 tool-calling 对话
- 自动内存压缩
- 会话历史持久化
"""
def __init__(
self,
name: str,
role_description: str,
provider: str = "openai",
model: str = "gpt-4",
api_key: Optional[str] = None,
base_url: Optional[str] = None,
workspace: Optional[Path] = None,
context_window_tokens: int = 200000,
embedding_model: Optional[str] = None,
embedding_base_url: Optional[str] = None,
):
"""
初始化 XBot Agent
Args:
name: Agent 名称
role_description: Agent 角色描述
provider: LLM 提供商
model: 模型名称
api_key: API Key
base_url: Base URL
workspace: 工作目录(用于存储会话和记忆)
context_window_tokens: 上下文窗口大小
"""
self.name = name
self.role_description = role_description
# 使用配置文件的默认值
if api_key is None:
api_key = config.API_KEY
if base_url is None:
base_url = config.BASE_URL
if workspace is None:
workspace = Path(config.WORKSPACE)
# 创建工作目录
self.workspace = workspace
self.workspace.mkdir(parents=True, exist_ok=True)
# 创建 LLM 适配器
self.provider = XBotLLMAdapter(
provider=provider,
model_name=model,
api_key=api_key,
base_url=base_url,
)
# 创建工具注册表
self.tools = SimpleToolRegistry()
self._register_default_tools()
# 创建 Agent Loop
self.agent_loop = AgentLoop(
provider=self.provider,
model=model,
tools=self.tools,
max_iterations=50,
)
# 创建会话管理器
self.sessions = SessionManager(self.workspace)
# 创建 SimpleMem 记忆系统
if HAS_SIMPLEMEM and api_key and config.ENABLE_SIMPLEMEM:
# 使用配置文件的 embedding 设置
emb_model = embedding_model or config.EMBEDDING_MODEL
emb_base = embedding_base_url or config.EMBEDDING_BASE_URL or base_url
self.memory = SimpleMemSystem(
api_key=api_key,
base_url=emb_base,
model=model,
embedding_model=emb_model,
db_path=str(self.workspace / "memory_db"),
clear_db=False,
# 并行处理配置
enable_parallel_processing=config.ENABLE_PARALLEL_PROCESSING,
max_parallel_workers=config.MAX_PARALLEL_WORKERS,
enable_parallel_retrieval=config.ENABLE_PARALLEL_RETRIEVAL,
max_retrieval_workers=config.MAX_RETRIEVAL_WORKERS,
enable_planning=config.ENABLE_PLANNING,
enable_reflection=config.ENABLE_REFLECTION,
max_reflection_rounds=config.MAX_REFLECTION_ROUNDS,
)
self._use_simplemem = True
print(f"SimpleMem initialized with embedding: {emb_model}, base_url: {emb_base}")
else:
self.memory = None
self._use_simplemem = False
if not api_key:
print("Warning: No API key provided, SimpleMem will be disabled")
def _register_default_tools(self) -> None:
"""注册默认工具"""
# 可以在这里添加默认工具
pass
def register_tool(
self,
name: str,
func: Any,
description: str = "",
parameters: Optional[dict] = None,
) -> None:
"""注册自定义工具"""
tool_def = {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters or {
"type": "object",
"properties": {},
"required": [],
}
}
}
# 存储在 tools 中
self.tools.register(name, func, description)
async def run(
self,
user_input: str,
session_id: str = "default",
) -> dict[str, Any]:
"""
运行 Agent 对话
Args:
user_input: 用户输入
session_id: 会话 ID
Returns:
dict: 包含 content, tool_calls 等
"""
# 获取或创建会话
session = self.sessions.get_or_create(session_id)
# 构建系统提示
system_prompt = f"""你是 {self.name}
{self.role_description}
请根据用户的问题回答,并使用 Markdown 格式输出。"""
# 如果使用 SimpleMem检索相关记忆
memory_context = ""
if self._use_simplemem and self.memory:
try:
memory_context = self.memory.ask(user_input)
except Exception as e:
print(f"Memory retrieval error: {e}")
if memory_context:
system_prompt += f"\n\n相关记忆:\n{memory_context}"
# 获取历史消息
history = session.get_history(max_messages=50)
# 构建初始消息
initial_messages = history + [
{"role": "user", "content": user_input}
]
# 运行 agent loop
final_content, tools_used, all_messages = await self.agent_loop.run_loop(
initial_messages=initial_messages,
system_prompt=system_prompt,
)
# 保存到会话
for m in all_messages[len(history):]:
session.messages.append(m)
self.sessions.save(session)
# 保存到 SimpleMem 记忆
if self._use_simplemem and self.memory and final_content:
try:
self.memory.add_dialogue("User", user_input, datetime.now().isoformat())
self.memory.add_dialogue(self.name, final_content, datetime.now().isoformat())
self.memory.finalize()
except Exception as e:
print(f"Memory save error: {e}")
return {
"content": final_content or "No response",
"tool_calls": tools_used,
"session_id": session_id,
}
async def run_stream(
self,
user_input: str,
session_id: str = "default",
):
"""
运行 Agent 对话(流式输出)
先完整执行 agent loop最后流式输出结果
Args:
user_input: 用户输入
session_id: 会话 ID
Yields:
str: 流式回复片段
"""
# 先完整执行 agent loop包含 tool-calling
result = await self.run(user_input, session_id)
content = result["content"]
# 流式输出结果
for char in content:
yield char
def clear_session(self, session_id: str) -> None:
"""清除会话"""
session = self.sessions.get_or_create(session_id)
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session_id)
def list_sessions(self) -> list[dict]:
"""列出所有会话"""
return self.sessions.list_sessions()

View File

@@ -1,74 +0,0 @@
"""
XBot 配置文件
"""
# ==================== LLM 配置 ====================
# 默认 LLM 提供商
DEFAULT_PROVIDER = "openai"
# 默认模型
DEFAULT_MODEL = "gpt-4"
# API Key建议使用环境变量
import os
API_KEY = os.getenv("OPENAI_API_KEY", "")
# Base URL
BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
# ==================== SimpleMem 记忆配置 ====================
# 是否启用 SimpleMem
ENABLE_SIMPLEMEM = True
# Embedding 模型
# 推荐: text-embedding-3-small, text-embedding-3-large, text-embedding-ada-002
# 或使用 Qwen: Qwen/Qwen3-Embedding-0.6B
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
# Embedding 服务的 Base URL可选默认使用 BASE_URL
EMBEDDING_BASE_URL = os.getenv("EMBEDDING_BASE_URL", "")
# ==================== 并行处理配置 ====================
# 是否启用并行处理
ENABLE_PARALLEL_PROCESSING = True
MAX_PARALLEL_WORKERS = 8
# 是否启用并行检索
ENABLE_PARALLEL_RETRIEVAL = True
MAX_RETRIEVAL_WORKERS = 4
# 是否启用规划
ENABLE_PLANNING = True
# 是否启用反思
ENABLE_REFLECTION = True
MAX_REFLECTION_ROUNDS = 2
# ==================== 工作目录 ====================
# 工作目录(用于存储会话和记忆)
WORKSPACE = os.getenv("XAGENT_WORKSPACE", "./xbot_workspace")
# 上下文窗口大小
CONTEXT_WINDOW_TOKENS = 200000
# ==================== Agent 配置 ====================
# 默认 Agent 配置
DEFAULT_AGENTS = {
1: {
"name": "数据分析助手",
"role_description": "你是一个专业的数据分析助手,擅长分析数据、生成报告。",
},
2: {
"name": "代码审查助手",
"role_description": "你是一个专业的代码审查助手擅长审查代码、发现bug。",
},
}

View File

@@ -1,190 +0,0 @@
"""Agent loop for tool-calling conversation."""
import asyncio
import json
import re
from typing import Any, Callable, Optional
from loguru import logger
class AgentLoop:
"""
Agent loop with tool-calling capability.
This is the core of the nanobot agent - it handles:
- Multi-turn conversation with the LLM
- Tool execution when the model requests it
- Progress callbacks for streaming responses
"""
_TOOL_RESULT_MAX_CHARS = 50000
def __init__(
self,
provider: Any,
model: str,
tools: Any,
max_iterations: int = 50,
):
"""
Initialize the agent loop.
Args:
provider: LLM provider (must implement chat_with_retry)
model: Model name
tools: Tool registry (must have get_definitions() and execute())
max_iterations: Maximum tool call iterations
"""
self.provider = provider
self.model = model
self.tools = tools
self.max_iterations = max_iterations
@staticmethod
def _strip_think(text: Optional[str]) -> Optional[str]:
"""Strip model thinking blocks from content."""
if not text:
return None
# Strip <thinking> tags commonly used by models like DeepSeek
pattern = r"<thinking>[\s\S]*?</thinking>"
text = re.sub(pattern, "", text)
return text.strip() or None
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hint."""
def _fmt(tc):
args = tc.arguments or {}
val = next(iter(args.values()), None) if isinstance(args, dict) else None
if not isinstance(val, str):
return tc.name
return f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls)
async def run_loop(
self,
initial_messages: list[dict],
system_prompt: str = "",
on_progress: Optional[Callable[..., Any]] = None,
) -> tuple[Optional[str], list[str], list[dict]]:
"""
Run the agent iteration loop.
Args:
initial_messages: Starting message list
system_prompt: System prompt to prepend
on_progress: Optional callback for progress updates
Returns:
Tuple of (final_content, tools_used, all_messages)
"""
# Prepend system prompt if provided
if system_prompt:
messages = [{"role": "system", "content": system_prompt}] + initial_messages
else:
messages = initial_messages
iteration = 0
final_content = None
tools_used: list[str] = []
while iteration < self.max_iterations:
iteration += 1
tool_defs = self.tools.get_definitions() if self.tools else []
response = await self.provider.chat_with_retry(
messages=messages,
tools=tool_defs,
model=self.model,
)
if response.has_tool_calls:
# Send progress update
if on_progress:
thought = self._strip_think(response.content)
if thought:
await on_progress(thought)
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
# Add assistant message with tool calls
tool_call_dicts = [
tc.to_openai_tool_call() if hasattr(tc, 'to_openai_tool_call') else tc
for tc in response.tool_calls
]
messages = self._add_assistant_message(
messages, response.content, tool_call_dicts,
reasoning_content=getattr(response, 'reasoning_content', None),
)
# Execute tools
for tool_call in response.tool_calls:
tools_used.append(tool_call.name)
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tool_call.name, args_str[:200])
result = await self.tools.execute(tool_call.name, tool_call.arguments)
messages = self._add_tool_result(messages, tool_call.id, tool_call.name, result)
else:
clean = self._strip_think(response.content)
# Handle error responses
if response.finish_reason == "error":
logger.error("LLM returned error: {}", (clean or "")[:200])
final_content = clean or "Sorry, I encountered an error calling the AI model."
break
messages = self._add_assistant_message(
messages, clean,
reasoning_content=getattr(response, 'reasoning_content', None),
)
final_content = clean
break
if final_content is None and iteration >= self.max_iterations:
logger.warning("Max iterations ({}) reached", self.max_iterations)
final_content = (
f"I reached the maximum number of tool call iterations ({self.max_iterations}) "
"without completing the task."
)
return final_content, tools_used, messages
def _add_assistant_message(
self,
messages: list[dict],
content: Optional[str],
tool_calls: Optional[list[dict]] = None,
reasoning_content: Optional[str] = None,
) -> list[dict]:
"""Add an assistant message to the message list."""
msg: dict[str, Any] = {"role": "assistant", "content": content}
if tool_calls:
msg["tool_calls"] = tool_calls
if reasoning_content is not None:
msg["reasoning_content"] = reasoning_content
messages.append(msg)
return messages
def _add_tool_result(
self,
messages: list[dict],
tool_call_id: str,
tool_name: str,
result: Any,
) -> list[dict]:
"""Add a tool result message to the message list."""
# Truncate large results
content = str(result)
if len(content) > self._TOOL_RESULT_MAX_CHARS:
content = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": tool_name,
"content": content,
})
return messages

View File

@@ -1,240 +0,0 @@
"""Memory system for persistent agent memory."""
import json
import asyncio
import weakref
from pathlib import Path
from typing import Any, Callable, Optional
try:
import tiktoken
HAS_TIKTOKEN = True
except ImportError:
HAS_TIKTOKEN = False
_SAVE_MEMORY_TOOL = [
{
"type": "function",
"function": {
"name": "save_memory",
"description": "Save the memory consolidation result to persistent storage.",
"parameters": {
"type": "object",
"properties": {
"history_entry": {
"type": "string",
"description": "A paragraph summarizing key events/decisions/topics.",
},
"memory_update": {
"type": "string",
"description": "Full updated long-term memory as markdown. Include all existing facts plus new ones.",
},
},
"required": ["history_entry", "memory_update"],
},
},
}
]
class MemoryStore:
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
def __init__(self, workspace: Path):
self.memory_dir = workspace / "memory"
self.memory_dir.mkdir(parents=True, exist_ok=True)
self.memory_file = self.memory_dir / "MEMORY.md"
self.history_file = self.memory_dir / "HISTORY.md"
def read_long_term(self) -> str:
if self.memory_file.exists():
return self.memory_file.read_text(encoding="utf-8")
return ""
def write_long_term(self, content: str) -> None:
self.memory_file.write_text(content, encoding="utf-8")
def append_history(self, entry: str) -> None:
with open(self.history_file, "a", encoding="utf-8") as f:
f.write(entry.rstrip() + "\n\n")
def get_memory_context(self) -> str:
long_term = self.read_long_term()
return f"## Long-term Memory\n{long_term}" if long_term else ""
def _estimate_tokens(text: str) -> int:
"""Estimate token count."""
if HAS_TIKTOKEN:
try:
enc = tiktoken.get_encoding("cl100k_base")
return len(enc.encode(text))
except Exception:
pass
return max(1, len(text) // 4)
def _estimate_message_tokens(message: dict[str, Any]) -> int:
"""Estimate prompt tokens for a message."""
content = message.get("content")
parts = []
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
text = part.get("text", "")
if text:
parts.append(text)
else:
parts.append(json.dumps(part, ensure_ascii=False))
elif content is not None:
parts.append(json.dumps(content, ensure_ascii=False))
for key in ("name", "tool_call_id"):
value = message.get(key)
if isinstance(value, str) and value:
parts.append(value)
if message.get("tool_calls"):
parts.append(json.dumps(message["tool_calls"], ensure_ascii=False))
payload = "\n".join(parts)
return max(1, _estimate_tokens(payload)) if payload else 1
class MemoryConsolidator:
"""Owns consolidation policy, locking, and session offset updates."""
def __init__(
self,
workspace: Path,
provider: Any,
model: str,
sessions: Any,
context_window_tokens: int = 200000,
):
self.store = MemoryStore(workspace)
self.provider = provider
self.model = model
self.sessions = sessions
self.context_window_tokens = context_window_tokens
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
def get_lock(self, session_key: str) -> asyncio.Lock:
"""Return the shared consolidation lock for one session."""
return self._locks.setdefault(session_key, asyncio.Lock())
async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
"""Archive a selected message chunk into persistent memory."""
if not messages:
return True
current_memory = self.store.read_long_term()
prompt = f"""Process this conversation and call the save_memory tool.
## Current Long-term Memory
{current_memory or "(empty)"}
## Conversation to Process
{self._format_messages(messages)}"""
try:
response = await self.provider.chat_with_retry(
messages=[
{"role": "system", "content": "You are a memory consolidation agent."},
{"role": "user", "content": prompt},
],
tools=_SAVE_MEMORY_TOOL,
model=self.model,
)
if not response.has_tool_calls:
return False
args = response.tool_calls[0].arguments
if isinstance(args, str):
args = json.loads(args)
if isinstance(args, list):
args = args[0] if args else {}
if entry := args.get("history_entry"):
self.store.append_history(str(entry))
if update := args.get("memory_update"):
update = str(update)
if update != current_memory:
self.store.write_long_term(update)
return True
except Exception:
return False
def _format_messages(self, messages: list[dict]) -> str:
lines = []
for message in messages:
if not message.get("content"):
continue
lines.append(
f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}: {message['content']}"
)
return "\n".join(lines)
def pick_consolidation_boundary(
self,
session: Any,
tokens_to_remove: int,
) -> Optional[tuple[int, int]]:
"""Pick a user-turn boundary that removes enough old prompt tokens."""
start = session.last_consolidated
if start >= len(session.messages) or tokens_to_remove <= 0:
return None
removed_tokens = 0
last_boundary: Optional[tuple[int, int]] = None
for idx in range(start, len(session.messages)):
message = session.messages[idx]
if idx > start and message.get("role") == "user":
last_boundary = (idx, removed_tokens)
if removed_tokens >= tokens_to_remove:
return last_boundary
removed_tokens += _estimate_message_tokens(message)
return last_boundary
async def archive_unconsolidated(self, session: Any) -> bool:
"""Archive the full unconsolidated tail for /new-style session rollover."""
lock = self.get_lock(session.key)
async with lock:
snapshot = session.messages[session.last_consolidated:]
if not snapshot:
return True
return await self.consolidate_messages(snapshot)
async def maybe_consolidate_by_tokens(self, session: Any) -> None:
"""Loop: archive old messages until prompt fits within half the context window."""
if not session.messages or self.context_window_tokens <= 0:
return
lock = self.get_lock(session.key)
async with lock:
target = self.context_window_tokens // 2
# Simple estimation without full prompt build
estimated = sum(_estimate_message_tokens(m) for m in session.messages[session.last_consolidated:])
if estimated < self.context_window_tokens:
return
# Find boundary and consolidate
boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
if boundary is None:
return
end_idx = boundary[0]
chunk = session.messages[session.last_consolidated:end_idx]
if not chunk:
return
if await self.consolidate_messages(chunk):
session.last_consolidated = end_idx
self.sessions.save(session)

View File

@@ -1,11 +0,0 @@
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
pydantic>=2.0.0
openai>=1.0.0
anthropic>=0.18.0
python-dotenv>=1.0.0
aiohttp>=3.8.0
redis>=5.0.0
loguru>=0.7.0
tiktoken>=0.12.0
simplemem>=0.1.0

View File

@@ -0,0 +1,158 @@
{
"permissions": {
"allow": [
"Bash(netstat -ano | findstr :8082)",
"Bash(taskkill /PID 17380 /F)",
"Bash(cmd /c \"taskkill /PID 17380 /F\")",
"Bash(powershell -Command \"Stop-Process -Id 17380 -Force\")",
"Bash(taskkill //PID 17380 //F)",
"Bash(netstat -ano | findstr :8082 | head -2)",
"WebSearch",
"mcp__web-search-prime__web_search_prime",
"mcp__web-reader__webReader",
"Bash(curl -s -X POST http://localhost:8082/model/test -H \"Content-Type: application/json\" -d '{\"provider\":\"openai\",\"model\":\"gpt-4\",\"model_type\":\"chat\",\"api_key\":\"test\",\"base_url\":\"https://api.openai.com\"}' 2>&1 || echo \"Failed to connect\")",
"Bash(curl -s http://localhost:8082/model/list 2>&1 | head -100)",
"Bash(cd D:\\\\Code\\\\Project\\\\X-Agents\\\\server && go run ./cmd/api 2>&1 | head -20)",
"Bash(cd /d/Code/Project/X-Agents/server && go build ./cmd/api 2>&1 | head -20)",
"Bash(cd /d/Code/Project/X-Agents/server && go build ./cmd/api 2>&1)",
"Bash(curl -s \"http://localhost:8082/api/chat/sessions?user_id=default-user&limit=50\" 2>&1)",
"Bash(curl -s \"http://localhost:8082/api/agent/list\" 2>&1)",
"Bash(mysql -h localhost -u root -proot x_agents -e \"CREATE TABLE IF NOT EXISTS chat_sessions \\(id VARCHAR\\(36\\) PRIMARY KEY, user_id VARCHAR\\(36\\) NOT NULL, agent_id VARCHAR\\(36\\), title VARCHAR\\(255\\), model_id VARCHAR\\(36\\), status VARCHAR\\(20\\) DEFAULT 'active', created_at DATETIME\\(3\\), updated_at DATETIME\\(3\\), INDEX idx_chat_sessions_user \\(user_id\\), INDEX idx_chat_sessions_agent \\(agent_id\\), INDEX idx_chat_sessions_updated \\(updated_at DESC\\)\\);\" 2>&1)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8080/api/chat/sessions?user_id=test 2>/dev/null || echo \"Server not running\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5173 2>/dev/null || echo \"Frontend not running\")",
"Bash(curl -s \"http://localhost:8082/api/agent/list\" 2>&1 | head -50)",
"Bash(netstat -ano 2>/dev/null | grep -E \"8080|3000\" | head -5 || echo \"Port check failed\")",
"Bash(ls -la /d/Code/Project/X-Agents/server/*.exe 2>/dev/null || ls -la /d/Code/Project/X-Agents/server/server.exe 2>/dev/null || ls -la /d/Code/Project/X-Agents/server/api.exe 2>/dev/null)",
"Bash(tasklist 2>/dev/null | grep -i \"api\\\\|server\" || echo \"No process found\")",
"Bash(taskkill //F //PID 14560 2>&1 || echo \"Process already dead\")",
"Bash(curl -s http://localhost:8080/api/chat/sessions?user_id=test 2>&1)",
"Bash(cd /d/Code/Project/X-Agents/server/cmd/api && go build -o ../api.exe . 2>&1)",
"Bash(sleep 3 && curl -s \"http://localhost:8082/api/chat/sessions?user_id=default-user&limit=50\" 2>&1)",
"Bash(netstat -ano 2>/dev/null | grep 8082 | head -5)",
"Bash(curl -s http://localhost:8082/api/chat/sessions?user_id=test 2>&1)",
"Bash(tasklist 2>/dev/null | grep -i \"api\")",
"Bash(taskkill //F //IM api.exe 2>&1 || echo \"Process killed\")",
"Bash(which mysql:*)",
"Bash(cd /d/Code/Project/X-Agents/server/cmd/api && go build -o ../api_new.exe . 2>&1)",
"Bash(docker ps:*)",
"Bash(docker exec:*)",
"Bash(ls -la /d/Code/Project/X-Agents/server/*.exe 2>/dev/null)",
"Bash(curl -s http://localhost:8082/api/chat/sessions?user_id=test-user-123 2>&1)",
"Bash(cd /d/Code/Project/X-Agents/server/cmd/api && go build -o ../api.exe . 2>&1 && echo \"Build success\")",
"Bash(netstat -ano 2>/dev/null | grep 8082 | head -3)",
"Bash(tasklist 2>/dev/null | grep -i \"go\\\\|api\\\\|server\" | head -10)",
"Bash(curl -s \"http://localhost:8082/api/chat/groups?user_id=default-user\" 2>&1)",
"Bash(sleep 3 && curl -s http://localhost:8082/api/chat/sessions?user_id=test-user-123 2>&1)",
"Bash(curl -s -X POST \"http://localhost:8082/api/chat/sessions\" -H \"Content-Type: application/json\" -d '{\"user_id\":\"default-user\",\"agent_id\":\"test-agent\",\"title\":\"Test Session\"}' 2>&1)",
"Bash(curl -s -X POST \"http://localhost:8082/api/agent/chat\" -H \"Content-Type: application/json\" -d '{\"agent_id\":\"1\",\"message\":\"hello\"}' 2>&1)",
"Bash(curl -s -X POST \"http://localhost:8082/api/agent/chat/stream\" -H \"Content-Type: application/json\" -d '{\"agent_id\":\"1\",\"message\":\"hello\"}' 2>&1 | head -5)",
"Bash(taskkill //F //IM api.exe 2>&1\ncd /d/Code/Project/X-Agents/server/cmd/api && go clean -cache && go build -o ../api.exe . 2>&1)",
"Bash(ls -la /d/Code/Project/X-Agents/server/*.exe)",
"Bash(cd /d/Code/Project/X-Agents/server/cmd/api && go build -o ../api.exe . 2>&1 && ls -la ../api.exe)",
"Bash(taskkill //F //IM api.exe 2>&1 || true\ncd /d/Code/Project/X-Agents/server/cmd/api && go build -o ../api.exe . 2>&1 && echo \"Build success\")",
"Bash(curl -s -X POST \"http://localhost:8082/api/agent/chat/stream\" -H \"Content-Type: application/json\" -d '{\"agent_id\":1,\"message\":\"hello\"}' 2>&1)",
"Bash(curl -s -X POST \"http://localhost:8082/api/agent/chat/stream\" -H \"Content-Type: application/json\" -d '{\"agent_id\":\"1\",\"message\":\"hello\"}' 2>&1)",
"Bash(taskkill //F //IM api.exe 2>&1 || true\ncd /d/Code/Project/X-Agents/server/cmd/api && go build -o ../api.exe . 2>&1\nls -la /d/Code/Project/X-Agents/server/api.exe)",
"Bash(go build:*)",
"Read(//tmp/**)",
"Bash(netstat -ano | grep 8082)",
"Bash(taskkill //F //PID 66476)",
"Bash(sleep 3 && curl -s -X POST http://localhost:8082/api/agent/chat/stream -H \"Content-Type: application/json\" -d '{\"agent_id\": \"1\", \"message\": \"hello\"}' 2>&1 | head -20)",
"Bash(netstat -ano | grep -E \"8081|8001\")",
"Bash(sleep 3 && curl -s http://localhost:8081/docs 2>&1 | head -5)",
"Bash(netstat -ano | grep 8081)",
"Bash(sleep 4 && netstat -ano | grep 8081)",
"Bash(netstat -ano | grep 8001)",
"Bash(taskkill /F /IM api.exe 2>/dev/null; taskkill /F /IM python.exe 2>/dev/null; echo \"Done\")",
"Bash(netstat -ano | findstr 8001)",
"Bash(chmod +x \"D:\\\\Code\\\\Project\\\\X-Agents\\\\start-all.sh\")",
"Bash(sed -i '260,264d' /d/Code/Project/X-Agents/core/agents/agent/loop.py && sed -n '255,270p' /d/Code/Project/X-Agents/core/agents/agent/loop.py)",
"Bash(sed -i '260,261d' /d/Code/Project/X-Agents/core/agents/agent/loop.py && sed -n '255,270p' /d/Code/Project/X-Agents/core/agents/agent/loop.py)",
"Bash(sed -i '259d' /d/Code/Project/X-Agents/core/agents/agent/loop.py && sed -n '255,270p' /d/Code/Project/X-Agents/core/agents/agent/loop.py)",
"Bash(cd /d/Code/Project/X-Agents/core && python -c \"import agents.agent.loop\" 2>&1 | head -20)",
"Bash(PYTHONPATH=/d/Code/Project/X-Agents/core python -c \"from agents.agent.loop import AgentLoop; print\\('OK'\\)\" 2>&1)",
"Bash(PYTHONPATH=/d/Code/Project/X-Agents/core python -c \"from core.agents.agent.loop import AgentLoop; print\\('OK'\\)\" 2>&1)",
"Bash(PYTHONPATH=. python -c \"from core.agents.agent.loop import AgentLoop; print\\('OK'\\)\" 2>&1)",
"Bash(python -c \"import sys; sys.path.insert\\(0, '.'\\); from core.agents.agent.loop import AgentLoop; print\\('OK'\\)\" 2>&1)",
"Bash(cd /d/Code/Project/X-Agents && PYTHONPATH=core python -c \"from core.agents.agent.loop import AgentLoop; print\\('OK'\\)\" 2>&1)",
"Bash(cd /d/Code/Project/X-Agents && PYTHONPATH=\"core;nanobot\" python -c \"from core.agents.agent.loop import AgentLoop; print\\('OK'\\)\" 2>&1 | head -10)",
"Bash(cd /d/Code/Project/X-Agents/core && PYTHONPATH=. python -c \"from core.agents.agent.loop import AgentLoop; print\\('OK'\\)\" 2>&1 | head -10)",
"Bash(PYTHONPATH=. python agents/main.py 2>&1 | head -20)",
"Bash(python agents/main.py 2>&1 | head -20)",
"Bash(python agents/main.py 2>&1 | head -30)",
"Bash(/d/Code/Project/X-Agents/core/agents/venv/Scripts/pip.exe install:*)",
"Bash(/d/Code/Project/X-Agents/core/agents/venv/Scripts/python.exe agents/main.py 2>&1 | head -30)",
"Bash(/d/Code/Project/X-Agents/core/agents/venv/Scripts/python.exe agents/main.py 2>&1 | head -40)",
"Bash(/d/Code/Project/X-Agents/core/agents/venv/Scripts/python.exe agents/main.py 2>&1 | head -50)",
"Bash(cd D:/Code/Project/X-Agents/core && python -c \"from agents.agent.team_agent import TeamAgent; print\\('TeamAgent import OK'\\)\")",
"Bash(cd D:/Code/Project/X-Agents && PYTHONPATH=core python -c \"from agents.agent.team_agent import TeamAgent; print\\('TeamAgent import OK'\\)\")",
"Bash(/d/Code/Project/X-Agents/core/agents/venv/Scripts/python.exe -c \"from agents.main import create_app; print\\('Import successful!'\\)\" 2>&1)",
"Bash(PYTHONPATH=/d/Code/Project/X-Agents/core /d/Code/Project/X-Agents/core/agents/venv/Scripts/python.exe -c \"from agents.main import create_app; print\\('Import successful!'\\)\" 2>&1)",
"Bash(PYTHONPATH=/d/Code/Project/X-Agents/core /d/Code/Project/X-Agents/core/agents/venv/Scripts/python.exe -m agents.main --help 2>&1 | head -20)",
"Bash(pip install:*)",
"Bash(netstat -ano 2>&1 | findstr 8001)",
"Bash(netstat -ano 2>&1 | findstr \"8001\")",
"Bash(taskkill //F //IM python.exe 2>&1 || true)",
"Bash(netstat -ano 2>&1 | findstr 8082)",
"Bash(taskkill //F //PID 25804)",
"Bash(taskkill //F //PID 73424)",
"Bash(taskkill //F //PID 73364)",
"Bash(pip search:*)",
"Bash(taskkill //F //PID 74128)",
"Bash(sleep 5 && curl -s -X POST http://localhost:8082/api/agent/chat/stream -H \"Content-Type: application/json\" -d '{\"agent_id\": \"1\", \"message\": \"hello\"}' 2>&1 | head -10)",
"Bash(taskkill //F //PID 72320)",
"Bash(curl -s -X POST http://localhost:8082/api/agent/team/chat -H \"Content-Type: application/json\" -d '{\"supervisor_agent_id\": 1, \"member_agent_ids\": [1,2,3], \"message\": \"hello team\"}' 2>&1)",
"Bash(netstat -ano 2>&1 | findstr \"8082\")",
"Bash(cd /d/Code/Project/X-Agents/server && timeout 10 go run ./cmd/api 2>&1 || true)",
"Bash(curl -s -X POST http://localhost:8082/api/chat/messages -H \"Content-Type: application/json\" -d '{\"session_id\":\"test-session\",\"role\":\"user\",\"content\":\"hello\"}' 2>&1)",
"Bash(curl -s -X POST http://localhost:8082/api/chat/sessions -H \"Content-Type: application/json\" -d '{\"user_id\":\"test-user\",\"agent_id\":\"test-agent\",\"title\":\"Test Chat\"}' 2>&1)",
"Bash(curl -s -X POST http://localhost:8082/api/chat/messages -H \"Content-Type: application/json\" -d '{\"session_id\":\"8d9e9f73-5b6c-4d3d-ace9-d677dfdc63c3\",\"role\":\"user\",\"content\":\"hello\"}' 2>&1)",
"Bash(curl -s -X POST http://localhost:8082/api/chat/groups -H \"Content-Type: application/json\" -d '{\"user_id\":\"test-user\",\"name\":\"Test Group\",\"description\":\"Test Group Description\",\"agent_ids\":\"[\\\\\"agent1\\\\\",\\\\\"agent2\\\\\"]\"}' 2>&1)",
"Bash(curl -s -X POST \"http://localhost:8082/api/chat/groups/040e742e-aa6c-4d04-b246-d71953294cde/chat\" -H \"Content-Type: application/json\" -d '{\"message\":\"Hello group\",\"user_id\":\"test-user\"}' 2>&1)",
"Bash(curl -s http://localhost:8082/api/agent/list 2>&1 | head -500)",
"Bash(curl -s -X POST http://localhost:8082/api/chat/groups -H \"Content-Type: application/json\" -d '{\"user_id\":\"test-user\",\"name\":\"Test Group Real\",\"description\":\"Test Group with real agents\",\"agent_ids\":\"[\\\\\"64ac115c-df75-4907-9028-a101fd82395e\\\\\",\\\\\"cb150dd3-e745-434d-b62d-341a603c0351\\\\\"]\"}' 2>&1)",
"Bash(curl -s -X POST \"http://localhost:8082/api/chat/groups/7c968861-8d5d-46f0-8c01-b6db31eb263f/chat\" -H \"Content-Type: application/json\" -d '{\"message\":\"Hello agents\",\"user_id\":\"test-user\"}' 2>&1)",
"Bash(cd /d \"D:\\\\Code\\\\Project\\\\X-Agents\\\\server\" && go build -o api.exe ./cmd/api/)",
"Bash(taskkill //F //IM api.exe 2>&1 || true)",
"Bash(cd /d/Code/Project/X-Agents/server && timeout 8 go run ./cmd/api 2>&1 || true)",
"Bash(curl -s http://localhost:8082/api/chat/groups?user_id=1 2>/dev/null || echo \"Go server not running\")",
"Bash(curl -s -X POST http://localhost:8082/api/chat/groups \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"user_id\":\"1\",\"name\":\"测试群聊\",\"agent_ids\":\"[1,2]\"}')",
"Bash(curl -s -X POST http://localhost:8082/api/chat/groups/e118af0b-cd5b-4587-b316-f7bf2831e800/chat \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"message\":\"你好\",\"agent_ids\":\"[1,2]\"}')",
"Bash(curl -s http://localhost:8082/api/agent/list)",
"Bash(curl -s -X POST http://localhost:8082/api/chat/groups \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"user_id\":\"1\",\"name\":\"测试群聊2\",\"agent_ids\":\"[\\\\\"64ac115c-df75-4907-9028-a101fd82395e\\\\\",\\\\\"cb150dd3-e745-434d-b62d-341a603c0351\\\\\"]\"}')",
"Bash(curl -s -X POST http://localhost:8082/api/chat/groups/b51773ab-767d-4226-840c-5960e3ff6a12/chat \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"message\":\"你好,请介绍一下你自己\"}')",
"Bash(curl -s -X POST http://localhost:8082/api/agent/chat/stream \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"agent_id\":\"64ac115c-df75-4907-9028-a101fd82395e\",\"message\":\"你好\"}')",
"Bash(curl -s -X POST http://localhost:8001/api/v1/agent/team/chat \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"supervisor_agent_id\":0,\"member_agent_ids\":[1,2],\"message\":\"你好\",\"user_id\":1,\"strategy\":\"parallel\"}')",
"Bash(sleep 3 && curl -s -X POST http://localhost:8082/api/chat/groups/b51773ab-767d-4226-840c-5960e3ff6a12/chat \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"message\":\"你好测试\"}')",
"Bash(curl -s -X POST http://localhost:8001/api/v1/agent/team/chat \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"supervisor_agent_id\":0,\"member_agent_ids\":[1,2],\"message\":\"hello\",\"user_id\":1,\"strategy\":\"parallel\"}')",
"Bash(netstat -ano | grep 8082 | head -1)",
"Bash(curl -s http://localhost:8001/api/v1/health)",
"Bash(cd D:/Code/Project/X-Agents/server && go clean -cache && go build -o api.exe ./cmd/api/ 2>&1)",
"Bash(taskkill /F /PID 72912 2>/dev/null\nsleep 2\nnetstat -ano | grep 8082)",
"Bash(wmic process:*)",
"Bash(taskkill //F //PID 72912)",
"Bash(cd \"D:\\\\Code\\\\Project\\\\X-Agents\" && ./start-all.bat)",
"Bash(netstat -ano | grep -E \"8080|8081|5173\")",
"Bash(taskkill //F //PID 31372 && taskkill //F //PID 52956 && taskkill //F //PID 35560)",
"Bash(sleep 3 && netstat -ano | grep -E \"8080|8081|5173\" | head -10)",
"Bash(netstat -ano | grep LISTENING | grep -E \"8080|8081|5173\")",
"Bash(netstat -ano | grep -E \"8082|8081|5173\")",
"Bash(sleep 3 && netstat -ano | grep -E \"8081|5173\")",
"Bash(sleep 2 && netstat -ano | grep LISTENING | grep -E \"8000|8001|8081\")",
"Bash(sleep 5 && netstat -ano | grep LISTENING | grep 5173)",
"Bash(netstat -ano)",
"Bash(xargs -I {} taskkill //F //PID {})",
"Bash(cd D:/Code/Project/X-Agents/server && go mod download gorm.io/driver/sqlite3)",
"Bash(cd D:/Code/Project/X-Agents/server && go mod tidy)",
"Bash(cd D:/Code/Project/X-Agents && cmd /c \"start-all.bat\")",
"Bash(timeout /t 10 /nobreak >nul && netstat -ano | findstr \"LISTENING\" | findstr \"8082\")",
"Bash(taskkill //F //IM api.exe 2>/dev/null; taskkill //F //IM node.exe 2>/dev/null; echo \"Ports cleaned\")",
"Bash(taskkill /PID 8604 /F)",
"Bash(taskkill //PID 8604 //F)",
"Bash(cd D:/Code/Project/X-Agents/core/agents && python -m py_compile agent/loop.py)",
"Bash(cd D:/Code/Project/X-Agents/core/agents && python -m py_compile agent/loop.py && echo \"Syntax OK\")",
"Bash(cd D:/Code/Project/X-Agents/core/agents && python -m py_compile agent/loop.py 2>&1)",
"Bash(cd D:/Code/Project/X-Agents/core/agents && python -m py_compile api/routes.py && echo \"OK\")"
]
}
}

34
core/agents/.env.example Normal file
View File

@@ -0,0 +1,34 @@
# X-Agents Python Agent Environment Configuration
# API Settings
API_HOST=0.0.0.0
API_PORT=8001
# Go Backend URL (for tool sync)
GO_BACKEND_URL=http://localhost:8080
# LLM Provider (openai/anthropic)
LLM_PROVIDER=openai
# LLM API Key (required for actual LLM calls)
LLM_API_KEY=your-api-key-here
# LLM Model
LLM_MODEL=gpt-4o
# Optional: Custom LLM Base URL (for proxy/alternative endpoints)
# LLM_BASE_URL=https://api.openai.com/v1
# Workspace for agent files
WORKSPACE=./workspace
# Agent settings
MAX_ITERATIONS=10
TEMPERATURE=0.7
# Sandbox Configuration (optional)
# Enable sandbox mode for secure code execution (bwrap/gvisor)
# SANDBOX_TYPE=bwrap # Options: bwrap, gvisor, none
# SANDBOX_TIMEOUT=60 # Default timeout in seconds
# GVISCOR_RUNSC_PATH=runsc # Path to gVisor runsc binary
# BWRAP_PATH=bwrap # Path to bwrap binary

7
core/agents/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""X-Agents Agent Core Package."""
# 注意:不要在这里使用顶层导入,会导致循环依赖问题
# 如需使用,请在使用时导入:
# from core.agents.agent.loop import AgentLoop
__all__ = []

View File

@@ -0,0 +1,7 @@
"""X-Agents Agent Module."""
from agents.agent.loop import AgentLoop
from agents.agent.context import ContextBuilder
from agents.agent.memory import AgentMemory, SessionMemory, RemoteMemoryClient
__all__ = ["AgentLoop", "ContextBuilder", "AgentMemory", "SessionMemory", "RemoteMemoryClient"]

View File

@@ -0,0 +1,127 @@
"""Context builder for assembling agent prompts."""
import platform
from pathlib import Path
from typing import Any
class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
def __init__(self, workspace: Path):
"""Initialize the context builder.
Args:
workspace: Workspace directory
"""
self.workspace = workspace
def build_system_prompt(self) -> str:
"""Build the system prompt with identity and runtime info."""
workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system()
runtime = f"{system} {platform.machine()}"
return f"""# X-Agents Assistant
You are an AI assistant built on the X-Agents platform.
## Runtime
{runtime}
## Workspace
Your workspace is at: {workspace_path}
## Guidelines
- Be helpful and concise
- Think step by step when needed
- Ask for clarification when the request is ambiguous
## Tool Usage Guidelines
**IMPORTANT**: Only use tools when explicitly requested by the user:
**Use tools for**:
- Searching the web for current information
- Executing code or commands
- Reading or writing files
- Performing calculations
**DO NOT use tools for**:
- Simple questions and greetings (e.g., "介绍一下武汉", "你好", "什么是AI")
- General knowledge that you already know
- Conversational responses
For simple informational questions, respond directly from your knowledge without calling any tools.
"""
def build_messages(
self,
history: list[dict[str, Any]],
current_message: str,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call.
Args:
history: Conversation history
current_message: Current user message
Returns:
List of messages for LLM
"""
return [
{"role": "system", "content": self.build_system_prompt()},
*history,
{"role": "user", "content": current_message},
]
def add_assistant_message(
self,
messages: list[dict[str, Any]],
content: str | None,
tool_calls: list[dict[str, Any]] | None = None,
reasoning_content: str | None = None,
) -> list[dict[str, Any]]:
"""Add an assistant message to the message list.
Args:
messages: Current message list
content: Assistant message content
tool_calls: Optional tool calls
reasoning_content: Optional reasoning from model
Returns:
Updated message list
"""
msg = {"role": "assistant", "content": content or ""}
if tool_calls:
msg["tool_calls"] = tool_calls
if reasoning_content:
msg["reasoning_content"] = reasoning_content
messages.append(msg)
return messages
def add_tool_result(
self,
messages: list[dict[str, Any]],
tool_call_id: str,
tool_name: str,
result: str,
) -> list[dict[str, Any]]:
"""Add a tool result to the message list.
Args:
messages: Current message list
tool_call_id: ID of the tool call
tool_name: Name of the tool
result: Tool execution result
Returns:
Updated message list
"""
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": tool_name,
"content": result,
})
return messages

View File

@@ -0,0 +1,521 @@
"""Intelligent memory summarization and compression system."""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Any
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class SummarizationConfig:
"""Configuration for memory summarization."""
# Token thresholds
context_window: int = 200000 # Model's context window
reserve_tokens: int = 20000 # Reserved tokens for system prompt
soft_threshold: int = 4000 # Trigger summarization before hitting limit
# Summary settings
keep_recent_tokens: int = 20000 # Keep recent N tokens
summary_prompt: str = (
"Please summarize the following conversation, preserving key information, "
"decisions, and important details. Focus on:\n"
"- User preferences and requirements\n"
"- Important decisions made\n"
"- Technical details and configurations\n"
"- Any follow-up tasks or action items\n\n"
"Conversation:\n{content}\n\n"
"Provide a concise summary:"
)
# Evergreen settings
evergreen_importance_threshold: int = 8 # Auto-mark high importance as evergreen
# Decay settings
decay_days_no_activity: int = 30 # Days without activity before decay starts
decay_factor: float = 0.9 # Importance decay factor per period
class MemorySummarizer:
"""LLM-based memory summarizer."""
def __init__(self, llm_provider=None, config: SummarizationConfig | None = None):
"""Initialize memory summarizer.
Args:
llm_provider: LLM provider for generating summaries
config: Summarization configuration
"""
self.llm_provider = llm_provider
self.config = config or SummarizationConfig()
async def summarize_conversation(
self,
messages: list[dict[str, Any]],
) -> str | None:
"""Summarize a conversation.
Args:
messages: List of conversation messages
Returns:
Summary string or None if failed
"""
if not self.llm_provider:
logger.warning("No LLM provider configured for summarization")
return None
if not messages:
return None
# Format messages for summarization
content = self._format_messages(messages)
# Generate summary using LLM
try:
prompt = self.config.summary_prompt.format(content=content)
response = await self.llm_provider.chat(
messages=[{"role": "user", "content": prompt}],
max_tokens=1024,
temperature=0.5,
)
if response and response.content:
return response.content.strip()
except Exception as e:
logger.error(f"Summarization failed: {e}")
return None
def _format_messages(self, messages: list[dict[str, Any]]) -> str:
"""Format messages for summarization prompt."""
lines = []
for msg in messages:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if content:
lines.append(f"{role}: {content[:500]}") # Truncate long messages
return "\n".join(lines)
def estimate_tokens(self, text: str) -> int:
"""Estimate token count (rough approximation).
Args:
text: Text to estimate
Returns:
Estimated token count
"""
# Rough estimate: ~4 characters per token
return len(text) // 4
class ContextCompressor:
"""Context compression manager for agent memory."""
def __init__(
self,
summarizer: MemorySummarizer,
config: SummarizationConfig | None = None,
):
"""Initialize context compressor.
Args:
summarizer: Memory summarizer
config: Summarization configuration
"""
self.summarizer = summarizer
self.config = config or SummarizationConfig()
self._compaction_count = 0
@property
def flush_trigger_tokens(self) -> int:
"""Calculate token threshold for triggering memory flush."""
return (
self.config.context_window
- self.config.reserve_tokens
- self.config.soft_threshold
)
def should_flush(self, current_tokens: int) -> bool:
"""Check if memory flush should be triggered.
Args:
current_tokens: Current token count
Returns:
True if flush should be triggered
"""
return current_tokens >= self.flush_trigger_tokens
async def compress_context(
self,
messages: list[dict[str, Any]],
current_tokens: int,
) -> tuple[list[dict[str, Any]], str | None]:
"""Compress context when approaching token limit.
Args:
messages: Current conversation messages
current_tokens: Current token count
Returns:
Tuple of (compressed messages, summary)
"""
if not self.should_flush(current_tokens):
return messages, None
self._compaction_count += 1
logger.info(f"Triggering context compression (count: {self._compaction_count})")
# Keep recent messages
recent_messages = self._keep_recent_messages(
messages,
self.config.keep_recent_tokens,
)
# Summarize older messages
older_messages = self._get_older_messages(
messages,
self.config.keep_recent_tokens,
)
if not older_messages:
return recent_messages, None
summary = await self.summarizer.summarize_conversation(older_messages)
# Create compressed context
compressed = recent_messages.copy()
if summary:
# Add summary as a system message
compressed.insert(0, {
"role": "system",
"content": f"[Previous conversation summary]\n{summary}",
})
logger.info(f"Context compressed: {len(older_messages)} messages summarized")
return compressed, summary
def _keep_recent_messages(
self,
messages: list[dict[str, Any]],
max_tokens: int,
) -> list[dict[str, Any]]:
"""Keep recent messages within token limit."""
result = []
total_tokens = 0
# Process from newest to oldest
for msg in reversed(messages):
content = msg.get("content", "")
tokens = self.summarizer.estimate_tokens(content)
if total_tokens + tokens > max_tokens:
break
result.insert(0, msg)
total_tokens += tokens
return result
def _get_older_messages(
self,
messages: list[dict[str, Any]],
keep_tokens: int,
) -> list[dict[str, Any]]:
"""Get older messages that should be summarized."""
result = []
total_tokens = 0
# Process from oldest to newest
for msg in messages:
content = msg.get("content", "")
tokens = self.summarizer.estimate_tokens(content)
if total_tokens + tokens > keep_tokens:
result.append(msg)
total_tokens += tokens
return result
def get_compaction_count(self) -> int:
"""Get number of compactions performed."""
return self._compaction_count
class MemoryDecayManager:
"""Memory importance decay manager."""
def __init__(self, config: SummarizationConfig | None = None):
"""Initialize decay manager.
Args:
config: Summarization configuration
"""
self.config = config or SummarizationConfig()
def calculate_decay(
self,
importance: int,
last_accessed: datetime,
is_evergreen: bool = False,
) -> int:
"""Calculate decayed importance.
Args:
importance: Original importance (1-10)
last_accessed: Last access timestamp
is_evergreen: Whether memory is marked as evergreen
Returns:
Decayed importance
"""
if is_evergreen:
return importance
# Calculate days since last access
days_since = (datetime.now() - last_accessed).days
if days_since < self.config.decay_days_no_activity:
return importance
# Calculate decay periods
decay_periods = (
days_since - self.config.decay_days_no_activity
) // self.config.decay_days_no_activity
# Apply decay
decay_factor = self.config.decay_factor ** decay_periods
decayed = int(importance * decay_factor)
# Ensure minimum importance of 1
return max(1, decayed)
def should_archive(self, importance: int, last_accessed: datetime) -> bool:
"""Check if memory should be archived.
Args:
importance: Current importance
last_accessed: Last access timestamp
Returns:
True if should be archived
"""
# Archive if importance has decayed to 1 and no recent access
decayed = self.calculate_decay(importance, last_accessed)
days_since = (datetime.now() - last_accessed).days
return decayed == 1 and days_since > self.config.decay_days_no_activity * 3
class EvergreenManager:
"""Evergreen (persistent) memory manager."""
def __init__(self, config: SummarizationConfig | None = None):
"""Initialize evergreen manager.
Args:
config: Summarization configuration
"""
self.config = config or SummarizationConfig()
def should_mark_evergreen(
self,
importance: int,
memory_type: str,
content: str,
) -> bool:
"""Determine if memory should be marked as evergreen.
Args:
importance: Importance score
memory_type: Type of memory
content: Memory content
Returns:
True if should be evergreen
"""
# High importance memories are evergreen
if importance >= self.config.evergreen_importance_threshold:
return True
# Certain memory types are typically evergreen
evergreen_types = {"preference", "identity", "configuration"}
if memory_type in evergreen_types:
return True
# Check for evergreen keywords in content
evergreen_keywords = [
"always", "never", "permanent", "fixed",
"my name is", "i am", "preference",
]
content_lower = content.lower()
if any(kw in content_lower for kw in evergreen_keywords):
return True
return False
def format_evergreen_prompt(self, memories: list[dict[str, Any]]) -> str:
"""Format evergreen memories for system prompt.
Args:
memories: List of evergreen memories
Returns:
Formatted prompt
"""
if not memories:
return ""
lines = ["[Evergreen Memories]"]
for mem in memories:
content = mem.get("content", "")
memory_type = mem.get("memory_type", "general")
lines.append(f"- [{memory_type}] {content}")
return "\n".join(lines)
class IntelligentMemorySystem:
"""Complete intelligent memory management system."""
def __init__(
self,
llm_provider=None,
config: SummarizationConfig | None = None,
):
"""Initialize intelligent memory system.
Args:
llm_provider: LLM provider for summarization
config: System configuration
"""
self.config = config or SummarizationConfig()
# Initialize components
self.summarizer = MemorySummarizer(llm_provider, self.config)
self.compressor = ContextCompressor(self.summarizer, self.config)
self.decay_manager = MemoryDecayManager(self.config)
self.evergreen_manager = EvergreenManager(self.config)
async def process_message(
self,
messages: list[dict[str, Any]],
current_tokens: int,
agent_id: str,
user_id: str = "default",
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
"""Process incoming message with intelligent memory management.
Args:
messages: Current conversation messages
current_tokens: Current token count
agent_id: Agent ID
user_id: User ID
Returns:
Tuple of (processed messages, memory to save)
"""
# Check if compression needed
processed_messages, summary = await self.compressor.compress_context(
messages,
current_tokens,
)
memory_to_save = None
if summary:
memory_to_save = {
"content": f"[Conversation Summary]\n{summary}",
"agent_id": agent_id,
"user_id": user_id,
"memory_type": "summary",
"importance": 5,
}
return processed_messages, memory_to_save
def get_evergreen_context(
self,
memories: list[dict[str, Any]],
) -> str:
"""Get evergreen memories formatted for context.
Args:
memories: List of all memories
Returns:
Formatted evergreen context
"""
evergreen = [
m for m in memories
if m.get("is_evergreen", False)
or self.evergreen_manager.should_mark_evergreen(
m.get("importance", 5),
m.get("memory_type", ""),
m.get("content", ""),
)
]
return self.evergreen_manager.format_evergreen_prompt(evergreen)
def apply_decay(
self,
memories: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Apply decay to memories.
Args:
memories: List of memories
Returns:
Memories with updated importance
"""
updated = []
for mem in memories:
last_accessed = mem.get("last_accessed_at")
if isinstance(last_accessed, str):
last_accessed = datetime.fromisoformat(last_accessed)
elif not last_accessed:
last_accessed = datetime.now()
is_evergreen = mem.get("is_evergreen", False)
new_importance = self.decay_manager.calculate_decay(
mem.get("importance", 5),
last_accessed,
is_evergreen,
)
mem["importance"] = new_importance
mem["should_archive"] = self.decay_manager.should_archive(
new_importance,
last_accessed,
)
updated.append(mem)
return updated
def create_intelligent_memory_system(
llm_provider=None,
context_window: int = 200000,
reserve_tokens: int = 20000,
) -> IntelligentMemorySystem:
"""Create intelligent memory system with configuration.
Args:
llm_provider: LLM provider
context_window: Model context window size
reserve_tokens: Reserved tokens
Returns:
Configured IntelligentMemorySystem
"""
config = SummarizationConfig(
context_window=context_window,
reserve_tokens=reserve_tokens,
)
return IntelligentMemorySystem(llm_provider=llm_provider, config=config)

View File

@@ -0,0 +1,278 @@
"""Intent recognition system for routing user requests."""
import json
import logging
from enum import Enum
from typing import Any
logger = logging.getLogger(__name__)
class IntentType(Enum):
"""Types of user intents."""
SIMPLE = "simple" # Simple Q&A, no tools needed
TOOL = "tool" # Needs tools (search, code, files, etc.)
SKILL = "skill" # Needs specific domain skill
TEAM = "team" # Needs multi-agent collaboration
UNKNOWN = "unknown" # Cannot determine
# Intent recognition prompt template
INTENT_PROMPT = """Analyze the user's message and classify their intent.
Intent Types:
- simple: General knowledge questions, greetings, casual conversation, simple Q&A
Examples: "你好", "介绍一下武汉", "什么是AI", "今天天气怎么样"
- tool: Requires external tools - web search, code execution, file operations, calculations
Examples: "搜索最新的AI新闻", "帮我运行这段代码", "读取文件内容", "计算这个表达式"
- skill: Requires specific domain skill (coding, design, analysis, etc.)
Examples: "用Python写一个排序算法", "分析这段代码的性能", "创建一个网页"
- team: Requires multiple agents working together
Examples: "让设计agent和开发agent一起完成这个任务", "创建一个团队来完成这个项目"
Guidelines:
- For greetings and simple questions, prefer "simple"
- Only use "tool" when user explicitly asks for search, execution, or file operations
- "introduce Wuhan" in Chinese is general knowledge - prefer "simple" unless user specifically asks for latest/current information
- If ambiguous, prefer "simple" to avoid unnecessary tool calls
User message: {message}
Respond with only the intent type (simple/tool/skill/team), no explanation:"""
class IntentRecognizer:
"""Recognizes user intent to route requests appropriately."""
def __init__(self, llm_provider=None):
"""Initialize intent recognizer.
Args:
llm_provider: LLM provider for intent recognition
"""
self._llm_provider = llm_provider
self._cache = {} # Simple cache for recent intents
def recognize(
self,
message: str,
available_tools: list[str] | None = None,
available_skills: list[str] | None = None,
) -> IntentType:
"""Recognize user intent.
Args:
message: User message
available_tools: List of available tool names
available_skills: List of available skill names
Returns:
Recognized intent type
"""
# Simple heuristics for common cases (fast path)
intent = self._heuristic_recognition(message)
if intent != IntentType.UNKNOWN:
logger.info(f"Intent recognized (heuristic): {intent.value} for message: {message[:50]}...")
return intent
# Use LLM for complex cases
if self._llm_provider:
return self._llm_recognition(message)
# Default to simple if no LLM
return IntentType.SIMPLE
def _heuristic_recognition(self, message: str) -> IntentType:
"""Fast heuristic-based intent recognition.
Args:
message: User message
Returns:
Recognized intent or UNKNOWN
"""
if not message:
return IntentType.UNKNOWN
message_lower = message.lower().strip()
# Greetings
greetings = ["你好", "hello", "hi", "", "您好", "hey"]
if any(g in message_lower for g in greetings) and len(message_lower) < 20:
return IntentType.SIMPLE
# Simple questions patterns
simple_patterns = [
"什么是", "什么叫", "什么是",
"介绍一下", "请介绍",
"解释一下", "解释",
"怎么样", "好不好",
"是什么意思",
"who are", "what is", "what's",
"tell me about",
]
# Check for simple patterns that don't require tools
for pattern in simple_patterns:
if pattern in message_lower:
# But exclude if explicitly asking for current/latest/real-time
if any(kw in message_lower for kw in ["最新", "现在", "current", "latest", "实时"]):
return IntentType.UNKNOWN # Might need web search
return IntentType.SIMPLE
# Explicit tool request patterns
tool_patterns = [
"搜索", "查找", "search",
"执行", "运行", "run",
"计算", "calculate",
"帮我写代码", "write code",
"读取", "读取", "read file",
"创建文件", "write file",
]
for pattern in tool_patterns:
if pattern in message_lower:
return IntentType.TOOL
# Skill patterns
skill_patterns = [
"用python", "用java", "用js",
"写一个算法", "实现",
"创建一个", "开发",
"分析", "优化",
]
for pattern in skill_patterns:
if pattern in message_lower:
return IntentType.SKILL
# Team patterns
team_patterns = [
"团队", "协作", "多个agent",
"team", "collaborate", "一起",
]
for pattern in team_patterns:
if pattern in message_lower:
return IntentType.TEAM
return IntentType.UNKNOWN
def _llm_recognition(self, message: str) -> IntentType:
"""LLM-based intent recognition.
Args:
message: User message
Returns:
Recognized intent type
"""
try:
prompt = INTENT_PROMPT.format(message=message)
# Use the LLM to classify intent
response = self._llm_provider.chat(
messages=[{"role": "user", "content": prompt}],
max_tokens=50,
)
content = response.content.strip().lower()
# Parse the response
if "simple" in content:
return IntentType.SIMPLE
elif "tool" in content:
return IntentType.TOOL
elif "skill" in content:
return IntentType.SKILL
elif "team" in content:
return IntentType.TEAM
else:
logger.warning(f"Unexpected intent response: {content}")
return IntentType.SIMPLE # Default to simple
except Exception as e:
logger.error(f"LLM intent recognition failed: {e}")
return IntentType.SIMPLE # Default to simple on error
class IntentRouter:
"""Routes requests based on recognized intent."""
def __init__(
self,
intent_recognizer: IntentRecognizer | None = None,
use_llm_recognition: bool = True,
):
"""Initialize intent router.
Args:
intent_recognizer: Intent recognizer instance
use_llm_recognition: Whether to use LLM for complex cases
"""
self._recognizer = intent_recognizer
self._use_llm = use_llm_recognition
def route(
self,
message: str,
available_tools: list[str] | None = None,
available_skills: list[str] | None = None,
) -> dict[str, Any]:
"""Route the user message based on intent.
Args:
message: User message
available_tools: List of available tool names
available_skills: List of available skill names
Returns:
Routing decision with intent type and suggested action
"""
# Recognize intent
intent = self._recognizer.recognize(
message,
available_tools,
available_skills,
)
# Build routing decision
decision = {
"intent": intent.value,
"action": self._get_action(intent),
"message": message,
}
logger.info(f"Routed message to {intent.value}: {message[:50]}...")
return decision
def _get_action(self, intent: IntentType) -> str:
"""Get the action to take based on intent.
Args:
intent: Recognized intent type
Returns:
Action name
"""
return {
IntentType.SIMPLE: "direct_response",
IntentType.TOOL: "execute_tools",
IntentType.SKILL: "execute_skill",
IntentType.TEAM: "team_collaboration",
IntentType.UNKNOWN: "direct_response", # Default to direct response
}.get(intent, "direct_response")
def create_intent_router(llm_provider=None) -> IntentRouter:
"""Create an intent router with default settings.
Args:
llm_provider: LLM provider for intent recognition
Returns:
Configured IntentRouter instance
"""
recognizer = IntentRecognizer(llm_provider=llm_provider)
return IntentRouter(intent_recognizer=recognizer)

704
core/agents/agent/loop.py Normal file
View File

@@ -0,0 +1,704 @@
"""Agent run loop - complete implementation."""
import asyncio
import json
import logging
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Awaitable, AsyncGenerator
from agents.agent.context import ContextBuilder
from agents.agent.memory import AgentMemory
from agents.agent.intent_router import IntentRouter, create_intent_router, IntentType
from agents.llm import LLMProvider, LLMResponse, ProviderFactory
from agents.tools import ToolRegistry
logger = logging.getLogger(__name__)
class AgentLoop:
"""Agent loop with message processing, LLM calls, tool execution, and streaming."""
_TOOL_RESULT_MAX_CHARS = 10000
def __init__(
self,
provider: LLMProvider,
model: str,
workspace: Path | None = None,
max_iterations: int = 10,
tools: ToolRegistry | None = None,
enable_intent_routing: bool = True,
):
"""Initialize the agent loop.
Args:
provider: LLM provider (OpenAI, Anthropic, etc.)
model: Model name to use
workspace: Workspace directory for memory and configs
max_iterations: Maximum tool call iterations
tools: Tool registry (creates default if None)
enable_intent_routing: Enable intent recognition and routing
"""
self.provider = provider
self.model = model
self.workspace = workspace or Path.cwd()
self.max_iterations = max_iterations
self.tools = tools
self.enable_intent_routing = enable_intent_routing
self.context = ContextBuilder(self.workspace)
self.memory = AgentMemory(self.workspace)
# Initialize intent router
if enable_intent_routing:
self.intent_router = create_intent_router(llm_provider=provider)
else:
self.intent_router = None
async def chat(
self,
message: str,
history: list[dict[str, Any]] | None = None,
session_key: str = "default",
on_progress: Callable[[str], Awaitable[None]] | None = None,
model_id: str | None = None,
model_name: str | None = None,
model_provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
use_xbot: bool = False,
) -> str:
"""Process a chat message and return the response.
Args:
message: User message
history: Conversation history
session_key: Session identifier
on_progress: Optional callback for progress updates
model_id: Model ID (optional)
model_name: Model name (optional)
model_provider: Model provider (optional)
api_key: API key (optional)
base_url: Custom API base URL (optional)
use_xbot: Use xbot mode (optional)
Returns:
Agent response content
"""
history = history or []
# Intent recognition and routing
intent_decision = None
if self.intent_router and not history: # Only for first message in conversation
try:
tool_names = self.tools.tool_names if self.tools else []
intent_decision = self.intent_router.route(
message=message,
available_tools=tool_names,
)
logger.info(f"Intent recognized: {intent_decision['intent']} -> {intent_decision['action']}")
# For simple intent, respond directly without tool loop
if intent_decision["intent"] == IntentType.SIMPLE.value:
# Build messages for direct response
messages = self.context.build_messages(
history=history,
current_message=message,
)
# Call LLM without tools
response = await self.provider.chat_with_retry(
messages=messages,
tools=None, # No tools for simple requests
model=self.model,
)
content = self._strip_think(response.content) or "好的,让我来回答这个问题。"
# Save to history
self._save_history(session_key, messages, len(history))
return content
except Exception as e:
logger.warning(f"Intent routing failed: {e}, continuing with normal flow")
# Load history from session if session_key is provided
if session_key and session_key != "default":
loaded_history = self.memory.get_history(session_key, max_messages=20)
if loaded_history:
# Merge any split assistant messages
loaded_history = self._merge_history_messages(loaded_history)
logger.info(f"Loaded {len(loaded_history)} messages from session history")
# Merge loaded history with provided history (loaded takes precedence if empty)
if not history:
history = loaded_history
else:
# Append loaded history before current messages
history = loaded_history + history
# Check if dynamic provider parameters are provided
if api_key or model_provider:
logger.info(f"Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
# Create temporary provider with dynamic parameters
temp_provider = ProviderFactory.create(
provider=model_provider or "openai",
api_key=api_key,
api_base=base_url,
)
# Use temporary provider and model
temp_model = model_name or temp_provider.get_default_model()
logger.info(f"Created temp provider with model: {temp_model}")
return await self._chat_with_provider(
message=message,
history=history,
session_key=session_key,
on_progress=on_progress,
provider=temp_provider,
model=temp_model,
)
# Build messages for LLM
messages = self.context.build_messages(
history=history,
current_message=message,
)
# Log which provider is being used
logger.info(f"Using static provider: {type(self.provider).__name__}, model={self.model}")
# Run the agent loop
final_content, tools_used, all_messages = await self._run_loop(
messages, on_progress
)
# Save to history
self._save_history(session_key, all_messages, len(history))
return final_content or "No response generated."
async def _chat_with_provider(
self,
message: str,
history: list[dict[str, Any]] | None = None,
session_key: str = "default",
on_progress: Callable[[str], Awaitable[None]] | None = None,
provider: LLMProvider | None = None,
model: str | None = None,
) -> str:
"""Chat with a specific provider (used for dynamic provider support).
Args:
message: User message
history: Conversation history
session_key: Session identifier
on_progress: Optional callback for progress updates
provider: LLM provider to use
model: Model name to use
Returns:
Agent response content
"""
history = history or []
# Intent recognition and routing
intent_decision = None
if self.intent_router and not history: # Only for first message in conversation
try:
tool_names = self.tools.tool_names if self.tools else []
intent_decision = self.intent_router.route(
message=message,
available_tools=tool_names,
)
logger.info(f"Intent recognized: {intent_decision['intent']} -> {intent_decision['action']}")
# For simple intent, respond directly without tool loop
if intent_decision["intent"] == IntentType.SIMPLE.value:
# Build messages for direct response
messages = self.context.build_messages(
history=history,
current_message=message,
)
# Call LLM without tools
response = await self.provider.chat_with_retry(
messages=messages,
tools=None, # No tools for simple requests
model=self.model,
)
content = self._strip_think(response.content) or "好的,让我来回答这个问题。"
# Save to history
self._save_history(session_key, messages, len(history))
return content
except Exception as e:
logger.warning(f"Intent routing failed: {e}, continuing with normal flow")
# Load history from session if session_key is provided
if session_key and session_key != "default":
loaded_history = self.memory.get_history(session_key, max_messages=20)
if loaded_history:
# Merge any split assistant messages
loaded_history = self._merge_history_messages(loaded_history)
logger.info(f"Loaded {len(loaded_history)} messages from session history")
# Merge loaded history with provided history (loaded takes precedence if empty)
if not history:
history = loaded_history
else:
# Append loaded history before current messages
history = loaded_history + history
provider = provider or self.provider
model = model or self.model
# Build messages for LLM
messages = self.context.build_messages(
history=history,
current_message=message,
)
# Run the agent loop with custom provider
final_content, tools_used, all_messages = await self._run_loop(
messages, on_progress, provider=provider, model=model
)
# Save to history
self._save_history(session_key, all_messages, len(history))
return final_content or "No response generated."
async def chat_stream(
self,
message: str,
history: list[dict[str, Any]] | None = None,
session_key: str = "default",
model_id: str | None = None,
model_name: str | None = None,
model_provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
use_xbot: bool = False,
) -> AsyncGenerator[str, None]:
"""Process a chat message with streaming response.
Args:
message: User message
history: Conversation history
session_key: Session identifier
model_id: Model ID (optional)
model_name: Model name (optional)
model_provider: Model provider (optional)
api_key: API key (optional)
base_url: Custom API base URL (optional)
use_xbot: Use xbot mode (optional)
Yields:
Response content chunks
"""
history = history or []
# Load history from session if session_key is provided
if session_key and session_key != "default":
loaded_history = self.memory.get_history(session_key, max_messages=20)
if loaded_history:
logger.info(f"[stream] Loaded {len(loaded_history)} messages from session history")
# Merge loaded history with provided history (loaded takes precedence if empty)
if not history:
history = loaded_history
else:
# Append loaded history before current messages
history = loaded_history + history
# Check if dynamic provider parameters are provided
if api_key or model_provider:
logger.info(f"[stream] Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
# Create temporary provider with dynamic parameters
temp_provider = ProviderFactory.create(
provider=model_provider or "openai",
api_key=api_key,
api_base=base_url,
)
# Use temporary provider and model
temp_model = model_name or temp_provider.get_default_model()
logger.info(f"[stream] Created temp provider with model: {temp_model}")
async for chunk in self._chat_stream_with_provider(
message=message,
history=history,
session_key=session_key,
provider=temp_provider,
model=temp_model,
):
yield chunk
return
# Build messages for LLM
messages = self.context.build_messages(
history=history,
current_message=message,
)
# Stream the response
async for chunk in self._run_loop_stream(messages):
yield chunk
async def _chat_stream_with_provider(
self,
message: str,
history: list[dict[str, Any]] | None = None,
session_key: str = "default",
provider: LLMProvider | None = None,
model: str | None = None,
) -> AsyncGenerator[str, None]:
"""Stream chat with a specific provider (used for dynamic provider support).
Args:
message: User message
history: Conversation history
session_key: Session identifier
provider: LLM provider to use
model: Model name to use
Yields:
Response content chunks
"""
history = history or []
# Load history from session if session_key is provided
if session_key and session_key != "default":
loaded_history = self.memory.get_history(session_key, max_messages=20)
if loaded_history:
logger.info(f"[stream] Loaded {len(loaded_history)} messages from session history")
# Merge loaded history with provided history (loaded takes precedence if empty)
if not history:
history = loaded_history
else:
# Append loaded history before current messages
history = loaded_history + history
provider = provider or self.provider
model = model or self.model
# Build messages for LLM
messages = self.context.build_messages(
history=history,
current_message=message,
)
# Stream the response with custom provider
async for chunk in self._run_loop_stream(messages, provider=provider, model=model):
yield chunk
async def _run_loop(
self,
initial_messages: list[dict],
on_progress: Callable[..., Awaitable[None]] | None = None,
provider: LLMProvider | None = None,
model: str | None = None,
) -> tuple[str | None, list[str], list[dict]]:
"""Run the agent iteration loop.
Args:
initial_messages: Initial message list
on_progress: Progress callback
provider: Optional LLM provider to use (defaults to self.provider)
model: Optional model name to use (defaults to self.model)
Returns:
Tuple of (final_content, tools_used, all_messages)
"""
messages = initial_messages
iteration = 0
final_content = None
tools_used: list[str] = []
provider = provider or self.provider
model = model or self.model
tool_defs = self.tools.get_definitions() if self.tools else []
# Intent recognition - determine if tools are needed before first LLM call
user_message = ""
for msg in messages:
if msg.get("role") == "user":
user_message = msg.get("content", "")
break
# Apply intent recognition on first iteration
if self.enable_intent_routing and self.intent_router and user_message:
available_tools = [t.get("function", {}).get("name", "") for t in tool_defs] if tool_defs else []
routing_decision = self.intent_router.route(
user_message,
available_tools=available_tools,
)
intent = routing_decision.get("intent", "simple")
logger.info(f"Intent recognized: {intent} for message: {user_message[:50]}...")
# If simple intent, don't pass tools to reduce unnecessary tool calls
if intent == "simple":
tool_defs = []
logger.info("Simple intent detected - disabling tool definitions for this request")
while iteration < self.max_iterations:
iteration += 1
# Call LLM
response = await provider.chat_with_retry(
messages=messages,
tools=tool_defs if tool_defs else None,
model=model,
)
if response.has_tool_calls:
# Progress callback for tool calls
if on_progress:
thought = self._strip_think(response.content)
if thought:
await on_progress(thought)
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
# Add assistant message with tool calls
tool_call_dicts = [tc.to_dict() for tc in response.tool_calls]
messages = self.context.add_assistant_message(
messages,
response.content,
tool_call_dicts,
reasoning_content=response.reasoning_content,
)
# Execute tools
for tool_call in response.tool_calls:
tools_used.append(tool_call.name)
args = tool_call.arguments
logger.info(f"Tool call: {tool_call.name}({args})")
# Execute tool
result = await self._execute_tool(tool_call.name, args)
# Truncate large results
if len(result) > self._TOOL_RESULT_MAX_CHARS:
result = result[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
# Add tool result
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
else:
# No tool calls - return the response
clean = self._strip_think(response.content)
# Handle errors
if response.finish_reason == "error":
logger.error(f"LLM error: {clean}")
final_content = clean or "Sorry, I encountered an error calling the AI model."
break
messages = self.context.add_assistant_message(
messages, clean, reasoning_content=response.reasoning_content
)
final_content = clean
break
if final_content is None and iteration >= self.max_iterations:
logger.warning(f"Max iterations ({self.max_iterations}) reached")
final_content = (
f"I reached the maximum number of iterations ({self.max_iterations}) "
"without completing the task."
)
return final_content, tools_used, messages
async def _run_loop_stream(
self,
initial_messages: list[dict],
provider: LLMProvider | None = None,
model: str | None = None,
) -> AsyncGenerator[str, None]:
"""Run the agent loop with streaming response.
Args:
initial_messages: Initial message list
provider: Optional LLM provider to use (defaults to self.provider)
model: Optional model name to use (defaults to self.model)
Yields:
Response content chunks
"""
provider = provider or self.provider
model = model or self.model
tool_defs = self.tools.get_definitions() if self.tools else []
# Intent recognition - determine if tools are needed before first LLM call
user_message = ""
for msg in initial_messages:
if msg.get("role") == "user":
user_message = msg.get("content", "")
break
# Apply intent recognition
if self.enable_intent_routing and self.intent_router and user_message:
available_tools = [t.get("function", {}).get("name", "") for t in tool_defs] if tool_defs else []
routing_decision = self.intent_router.route(
user_message,
available_tools=available_tools,
)
intent = routing_decision.get("intent", "simple")
logger.info(f"[stream] Intent recognized: {intent} for message: {user_message[:50]}...")
# If simple intent, don't pass tools to reduce unnecessary tool calls
if intent == "simple":
tool_defs = []
logger.info("[stream] Simple intent detected - disabling tool definitions")
# First call to check for tool calls
response = await provider.chat_with_retry(
messages=initial_messages,
tools=tool_defs if tool_defs else None,
model=model,
)
if response.has_tool_calls:
# Execute tools first
for tool_call in response.tool_calls:
logger.info(f"Tool call: {tool_call.name}")
result = await self._execute_tool(tool_call.name, tool_call.arguments)
# Add to messages
initial_messages = self.context.add_tool_result(
initial_messages, tool_call.id, tool_call.name, result
)
# Recursive call after tool execution
async for chunk in self._run_loop_stream(initial_messages, provider=provider, model=model):
yield chunk
else:
# Stream the content
content = self._strip_think(response.content)
if content:
yield content
async def _execute_tool(self, tool_name: str, args: dict) -> str:
"""Execute a tool.
Args:
tool_name: Name of the tool to execute
args: Tool arguments
Returns:
Tool execution result
"""
if self.tools:
return await self.tools.execute(tool_name, args)
return json.dumps({"error": "No tools registered"})
@staticmethod
def _strip_think(text: str | None) -> str | None:
"""Strip think blocks that some models embed in content."""
if not text:
return None
import re
# Match content between [/INST] or [/CONTINUE] tags commonly used in thinking
patterns = [
r"<think>[\s\S]*?</think>",
r"<\/?think>",
]
for pattern in patterns:
text = re.sub(pattern, "", text)
return text.strip() or None
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hint."""
def _fmt(tc):
args = tc.arguments or {}
val = next(iter(args.values()), None) if isinstance(args, dict) else None
if not isinstance(val, str):
return tc.name
return f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls)
@staticmethod
def _merge_history_messages(messages: list[dict]) -> list[dict]:
"""Merge adjacent assistant messages that have content and tool_calls separately.
When saving/loading history, assistant messages with both content and tool_calls
might be split into multiple entries. This method merges them back together.
Args:
messages: List of message dictionaries
Returns:
Merged list of messages
"""
if not messages:
return messages
merged = []
i = 0
while i < len(messages):
current = messages[i].copy()
# If current is an assistant message with tool_calls, check if next is
# an assistant message with content (or vice versa)
if current.get("role") == "assistant" and current.get("tool_calls"):
# Look ahead for another assistant message to merge with
j = i + 1
while j < len(messages):
next_msg = messages[j]
if next_msg.get("role") == "assistant":
# Merge content
if next_msg.get("content") and not current.get("content"):
current["content"] = next_msg.get("content")
# Merge tool_calls (should already be in current)
if next_msg.get("tool_calls") and not current.get("tool_calls"):
current["tool_calls"] = next_msg.get("tool_calls")
j += 1
else:
break
# If we merged multiple messages, skip them
if j > i + 1:
logger.debug(f"Merged {j - i} assistant messages")
i = j
else:
merged.append(current)
i += 1
return merged
def _save_history(
self,
session_key: str,
messages: list[dict],
skip: int = 0,
) -> None:
"""Save messages to history.
Args:
session_key: Session identifier
messages: Messages to save
skip: Number of messages to skip
"""
for m in messages[skip:]:
role = m.get("role")
content = m.get("content")
if role == "user" and content:
self.memory.add_to_history("user", str(content)[:1000], session_key)
elif role == "assistant":
# Build a combined message with content and tool_calls
msg_data = {}
if content:
msg_data["content"] = str(content)[:1000]
if m.get("tool_calls"):
msg_data["tool_calls"] = m.get("tool_calls", [])
# Save as a single JSON message with all data
if msg_data:
msg_str = json.dumps(msg_data)
self.memory.add_to_history("assistant", msg_str, session_key)
# Save tool results (needed for multi-turn conversations)
elif role == "tool":
tool_call_id = m.get("tool_call_id", "")
tool_name = m.get("name", "")
tool_content = m.get("content", "")
tool_result_str = json.dumps({
"tool_call_id": tool_call_id,
"name": tool_name,
"content": tool_content
})
self.memory.add_to_history("tool", f"[tool_result]{tool_result_str}", session_key)

994
core/agents/agent/memory.py Normal file
View File

@@ -0,0 +1,994 @@
"""Memory management for agent sessions."""
import json
import logging
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any
import aiohttp
logger = logging.getLogger(__name__)
class SessionMemory:
"""短期会话记忆 - 内存中的会话消息存储,支持 Markdown 持久化"""
def __init__(self, max_messages: int = 50, workspace: Path | str | None = None):
"""初始化会话记忆
Args:
max_messages: 每个会话保留的最大消息数
workspace: 工作区目录,用于持久化会话文件
"""
self.max_messages = max_messages
self._sessions: dict[str, list[dict[str, Any]]] = defaultdict(list)
# 持久化支持
self.workspace = Path(workspace) if workspace else None
self.sessions_dir = None
if self.workspace:
self.sessions_dir = self.workspace / "sessions"
self.sessions_dir.mkdir(parents=True, exist_ok=True)
# 启动时加载所有会话
self._load_all_sessions()
def _get_session_file(self, session_id: str) -> Path | None:
"""获取会话文件路径"""
if not self.sessions_dir:
return None
# 清理 session_id 中的非法文件名字符
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)
return self.sessions_dir / f"{safe_id}.md"
def _load_all_sessions(self) -> None:
"""启动时加载所有会话文件"""
if not self.sessions_dir or not self.sessions_dir.exists():
return
for session_file in self.sessions_dir.glob("*.md"):
session_id = session_file.stem
self._load_session(session_id)
logger.info(f"Loaded session from file: {session_id}")
def _load_session(self, session_id: str) -> list[dict[str, Any]]:
"""从文件加载单个会话
Args:
session_id: 会话ID
Returns:
消息列表
"""
session_file = self._get_session_file(session_id)
if not session_file or not session_file.exists():
return []
try:
content = session_file.read_text(encoding="utf-8")
messages = []
lines = content.strip().split("\n")
current_message = {}
for line in lines:
line = line.strip()
if not line:
continue
# 解析 "## 消息 N" 格式
if line.startswith("## 消息"):
# 保存上一条消息
if current_message:
messages.append(current_message)
current_message = {
"role": "",
"timestamp": "",
"content": "",
}
continue
# 解析 "角色: xxx"
if line.startswith("角色:") and current_message is not None:
current_message["role"] = line.split(":", 1)[1].strip()
continue
# 解析 "时间: xxx"
if line.startswith("时间:") and current_message is not None:
current_message["timestamp"] = line.split(":", 1)[1].strip()
continue
# 解析 "内容: xxx"
if line.startswith("内容:") and current_message is not None:
current_message["content"] = line.split(":", 1)[1].strip()
continue
# 保存最后一条消息
if current_message and current_message.get("role"):
messages.append(current_message)
# 加载到内存
if messages:
self._sessions[session_id] = messages[-self.max_messages:]
return messages
except Exception as e:
logger.error(f"Error loading session {session_id}: {e}")
return []
def _save_session(self, session_id: str) -> None:
"""将会话保存到文件
Args:
session_id: 会话ID
"""
session_file = self._get_session_file(session_id)
if not session_file:
return
messages = self._sessions.get(session_id, [])
if not messages:
# 如果会话为空,删除文件
if session_file.exists():
session_file.unlink()
return
# 构建 Markdown 内容(使用产品经理指定的格式)
created_time = messages[0].get("timestamp", datetime.now().isoformat()) if messages else datetime.now().isoformat()
created_time_str = created_time.replace("T", " ") if "T" in created_time else created_time
lines = [
f"# 会话: {session_id}",
f"创建时间: {created_time_str}",
"",
]
for i, msg in enumerate(messages, 1):
role = msg.get("role", "unknown")
timestamp = msg.get("timestamp", "")
content = msg.get("content", "")
# 格式化时间
if "T" in timestamp:
timestamp = timestamp.replace("T", " ")
lines.append(f"## 消息 {i}")
lines.append(f"角色: {role}")
lines.append(f"时间: {timestamp}")
lines.append(f"内容: {content}")
lines.append("")
try:
session_file.write_text("\n".join(lines), encoding="utf-8")
except Exception as e:
logger.error(f"Error saving session {session_id}: {e}")
def add_message(self, session_id: str, role: str, content: str, metadata: dict | None = None) -> None:
"""添加消息到会话
Args:
session_id: 会话ID
role: 消息角色 (user/assistant/system)
content: 消息内容
metadata: 附加元数据
"""
message = {
"role": role,
"content": content,
"timestamp": datetime.now().isoformat(),
}
if metadata:
message["metadata"] = metadata
session_messages = self._sessions[session_id]
session_messages.append(message)
# 超过最大消息数时,移除最旧的消息
if len(session_messages) > self.max_messages:
self._sessions[session_id] = session_messages[-self.max_messages:]
# 持久化到文件
self._save_session(session_id)
def get_history(self, session_id: str, max_messages: int = 0) -> list[dict[str, Any]]:
"""获取会话历史
Args:
session_id: 会话ID
max_messages: 返回的最大消息数0表示全部
Returns:
消息列表
"""
# 如果内存中没有,尝试从文件加载
if session_id not in self._sessions:
self._load_session(session_id)
messages = self._sessions.get(session_id, [])
if max_messages > 0 and len(messages) > max_messages:
return messages[-max_messages:]
return messages
def clear_session(self, session_id: str) -> None:
"""清除会话记忆
Args:
session_id: 会话ID
"""
if session_id in self._sessions:
del self._sessions[session_id]
# 删除会话文件
session_file = self._get_session_file(session_id)
if session_file and session_file.exists():
session_file.unlink()
def get_session_count(self) -> int:
"""获取当前会话数量"""
return len(self._sessions)
def list_sessions(self) -> list[str]:
"""列出所有会话ID"""
return list(self._sessions.keys())
class RemoteMemoryClient:
"""与Go端Memory API对接的客户端"""
def __init__(self, base_url: str, agent_id: str, user_id: str = "default"):
"""初始化远程记忆客户端
Args:
base_url: Go服务端地址
agent_id: Agent ID
user_id: 用户ID
"""
self.base_url = base_url.rstrip("/")
self.agent_id = agent_id
self.user_id = user_id
self._session = None
async def _get_session(self) -> aiohttp.ClientSession:
"""获取或创建aiohttp session"""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self) -> None:
"""关闭session"""
if self._session and not self._session.closed:
await self._session.close()
async def create_memory(
self,
content: str,
memory_type: str = "conversation",
importance: int = 5,
) -> dict[str, Any] | None:
"""创建记忆
Args:
content: 记忆内容
memory_type: 记忆类型 (conversation/experience/lessons)
importance: 重要性评分 1-10
Returns:
创建的记忆对象
"""
url = f"{self.base_url}/api/agent/{self.agent_id}/memories"
payload = {
"agent_id": self.agent_id,
"user_id": self.user_id,
"content": content,
"memory_type": memory_type,
"importance": importance,
}
try:
session = await self._get_session()
async with session.post(url, json=payload) as response:
if response.status == 200:
return await response.json()
logger.warning(f"Failed to create memory: {response.status}")
return None
except Exception as e:
logger.error(f"Error creating memory: {e}")
return None
async def get_memories(
self,
limit: int = 10,
offset: int = 0,
memory_type: str | None = None,
category: str | None = None,
) -> list[dict[str, Any]]:
"""获取记忆列表
Args:
limit: 返回数量限制
offset: 偏移量
memory_type: 记忆类型筛选
category: 分类筛选
Returns:
记忆列表
"""
url = f"{self.base_url}/api/agent/{self.agent_id}/memories"
params = {
"user_id": self.user_id,
"limit": limit,
"offset": offset,
}
if memory_type:
params["memory_type"] = memory_type
if category:
params["category"] = category
try:
session = await self._get_session()
async with session.get(url, params=params) as response:
if response.status == 200:
result = await response.json()
return result if isinstance(result, list) else result.get("list", [])
return []
except Exception as e:
logger.error(f"Error getting memories: {e}")
return []
async def search_memories(
self,
keyword: str,
tags: str | None = None,
category: str | None = None,
memory_type: str | None = None,
min_score: int = 0,
limit: int = 10,
offset: int = 0,
) -> list[dict[str, Any]]:
"""搜索记忆(关键词搜索)
Args:
keyword: 搜索关键词
tags: 标签筛选
category: 分类筛选
memory_type: 记忆类型筛选
min_score: 最低重要性分数
limit: 返回数量限制
offset: 偏移量
Returns:
记忆列表
"""
url = f"{self.base_url}/api/agent/{self.agent_id}/memories/search"
payload = {
"agent_id": self.agent_id,
"user_id": self.user_id,
"keyword": keyword,
"limit": limit,
"offset": offset,
}
if tags:
payload["tags"] = tags
if category:
payload["category"] = category
if memory_type:
payload["memory_type"] = memory_type
if min_score > 0:
payload["min_score"] = min_score
try:
session = await self._get_session()
async with session.post(url, json=payload) as response:
if response.status == 200:
result = await response.json()
return result.get("list", [])
return []
except Exception as e:
logger.error(f"Error searching memories: {e}")
return []
async def get_categories(self) -> list[str]:
"""获取记忆分类列表
Returns:
分类列表
"""
url = f"{self.base_url}/api/agent/{self.agent_id}/memories/categories"
params = {"user_id": self.user_id}
try:
session = await self._get_session()
async with session.get(url, params=params) as response:
if response.status == 200:
result = await response.json()
return result.get("categories", [])
return []
except Exception as e:
logger.error(f"Error getting categories: {e}")
return []
async def get_tags(self) -> list[str]:
"""获取记忆标签列表
Returns:
标签列表
"""
url = f"{self.base_url}/api/agent/{self.agent_id}/memories/tags"
params = {"user_id": self.user_id}
try:
session = await self._get_session()
async with session.get(url, params=params) as response:
if response.status == 200:
result = await response.json()
return result.get("tags", [])
return []
except Exception as e:
logger.error(f"Error getting tags: {e}")
return []
async def delete_memory(self, memory_id: str) -> bool:
"""删除记忆
Args:
memory_id: 记忆ID
Returns:
是否删除成功
"""
url = f"{self.base_url}/api/agent/{self.agent_id}/memories/{memory_id}"
try:
session = await self._get_session()
async with session.delete(url) as response:
return response.status == 200
except Exception as e:
logger.error(f"Error deleting memory: {e}")
return False
class AgentMemory:
"""Manages agent memory and session history."""
def __init__(self, workspace: Path):
"""Initialize the memory manager.
Args:
workspace: Workspace directory for storing memory
"""
self.workspace = workspace
self.memory_dir = workspace / "memory"
self.memory_dir.mkdir(exist_ok=True)
self.long_term_file = self.memory_dir / "MEMORY.md"
# Session-specific history
self.sessions_dir = self.memory_dir / "sessions"
self.sessions_dir.mkdir(exist_ok=True)
# Legacy history file (for backward compatibility)
self.history_file = self.memory_dir / "HISTORY.md"
def _get_session_file(self, session_key: str) -> Path:
"""Get session file path."""
# Sanitize session_key for filename
safe_key = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_key)
return self.sessions_dir / f"{safe_key}.md"
def get_memory_context(self) -> str:
"""Get long-term memory content.
Returns:
Memory context string
"""
if self.long_term_file.exists():
return self.long_term_file.read_text(encoding="utf-8")
return ""
def add_to_memory(self, content: str) -> None:
"""Add content to long-term memory.
Args:
content: Content to add to memory
"""
with open(self.long_term_file, "a", encoding="utf-8") as f:
f.write(f"\n{content}")
def add_to_history(self, role: str, content: str, session_key: str | None = None) -> None:
"""Add an entry to conversation history.
Args:
role: Message role (user/assistant)
content: Message content
session_key: Session identifier for session-specific history
"""
timestamp = datetime.now().isoformat()
# If session_key provided, save to session file
if session_key:
self._add_to_session_history(session_key, role, content, timestamp)
else:
# Legacy: save to global history file
legacy_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
entry = f"[{legacy_timestamp}] {role}: {content}\n"
with open(self.history_file, "a", encoding="utf-8") as f:
f.write(entry)
def _add_to_session_history(self, session_key: str, role: str, content: str, timestamp: str) -> None:
"""Add message to session-specific history file."""
session_file = self._get_session_file(session_key)
# Format timestamp for display
display_timestamp = timestamp.replace("T", " ") if "T" in timestamp else timestamp
# Determine header format based on whether file exists
header = ""
if not session_file.exists():
header = f"# 会话: {session_key}\n创建时间: {display_timestamp}\n\n"
# Count existing messages to determine message number
msg_count = 1
if session_file.exists():
try:
existing = session_file.read_text(encoding="utf-8")
msg_count = existing.count("## 消息") + 1
except:
pass
# Check if content contains tool_calls or tool_result markers, or is JSON
# Format as Markdown (产品经理指定格式)
entry_lines = [
f"## 消息 {msg_count}",
f"角色: {role}",
f"时间: {display_timestamp}",
]
# Handle tool_calls and tool_result content
if content.startswith("[tool_calls]"):
entry_lines.append(f"工具调用: {content[len('[tool_calls]'):]}")
entry_lines.append(f"内容: ")
elif content.startswith("[tool_result]"):
entry_lines.append(f"工具结果: {content[len('[tool_result]'):]}")
entry_lines.append(f"内容: ")
else:
# Check if it's a JSON object (new format with content + tool_calls)
try:
data = json.loads(content)
if isinstance(data, dict):
# New JSON format: might have content and/or tool_calls
if "content" in data:
entry_lines.append(f"内容: {data['content']}")
if "tool_calls" in data:
entry_lines.append(f"工具调用: {json.dumps(data['tool_calls'])}")
else:
entry_lines.append(f"内容: {content}")
except (json.JSONDecodeError, TypeError):
# Not JSON, treat as regular content
entry_lines.append(f"内容: {content}")
entry = "\n".join(entry_lines) + "\n\n"
with open(session_file, "a", encoding="utf-8") as f:
if header:
f.write(header)
f.write(entry)
def get_history(
self,
session_key: str | None = None,
max_messages: int = 10,
) -> list[dict[str, Any]]:
"""Get conversation history.
Args:
session_key: Optional session key for session-specific history
max_messages: Maximum number of messages to return
Returns:
List of history messages
"""
# If session_key provided, load from session file
if session_key:
return self._get_session_history(session_key, max_messages)
# Legacy: load from global history file
return self._get_legacy_history(max_messages)
def _get_session_history(self, session_key: str, max_messages: int) -> list[dict[str, Any]]:
"""Get history from session file."""
session_file = self._get_session_file(session_key)
if not session_file.exists():
return []
try:
content = session_file.read_text(encoding="utf-8")
lines = content.strip().split("\n")
messages = []
current_message = {}
for line in lines:
line = line.strip()
if not line:
continue
# Skip headers
if line.startswith("#"):
continue
# Parse "## 消息 N"
if line.startswith("## 消息"):
# Save previous message
if current_message and current_message.get("role"):
messages.append(current_message)
current_message = {
"role": "",
"timestamp": "",
"content": "",
}
continue
# Parse "角色: xxx"
if line.startswith("角色:") and current_message is not None:
current_message["role"] = line.split(":", 1)[1].strip()
continue
# Parse "时间: xxx"
if line.startswith("时间:") and current_message is not None:
current_message["timestamp"] = line.split(":", 1)[1].strip()
continue
# Parse "工具调用: xxx" - for tool_calls
if line.startswith("工具调用:") and current_message is not None:
tool_calls_json = line.split(":", 1)[1].strip()
try:
# Set role if not already set
if not current_message.get("role"):
current_message["role"] = "assistant"
current_message["tool_calls"] = json.loads(tool_calls_json)
except json.JSONDecodeError:
pass
continue
# Parse "工具结果: xxx" - for tool_result
if line.startswith("工具结果:") and current_message is not None:
tool_result_json = line.split(":", 1)[1].strip()
try:
tool_result = json.loads(tool_result_json)
current_message["role"] = "tool" # Set role to tool
current_message["tool_call_id"] = tool_result.get("tool_call_id", "")
current_message["name"] = tool_result.get("name", "")
current_message["content"] = tool_result.get("content", "")
except json.JSONDecodeError:
pass
continue
# Parse "内容: xxx"
if line.startswith("内容:") and current_message is not None:
current_message["content"] = line.split(":", 1)[1].strip()
continue
# Content line
if current_message:
if current_message.get("content"):
current_message["content"] += "\n" + line
else:
current_message["content"] = line
# Save last message
if current_message:
messages.append(current_message)
# Return most recent messages
if max_messages > 0 and len(messages) > max_messages:
return messages[-max_messages:]
return messages
except Exception as e:
logger.error(f"Error reading session history: {e}")
return []
def _get_legacy_history(self, max_messages: int) -> list[dict[str, Any]]:
"""Get history from legacy history file."""
if not self.history_file.exists():
return []
try:
content = self.history_file.read_text(encoding="utf-8")
lines = content.strip().split("\n")
messages = []
for line in lines[-max_messages * 2:]:
if ": " in line:
try:
_, rest = line.split("] ", 1)
role, content = rest.split(": ", 1)
messages.append({"role": role, "content": content})
except ValueError:
continue
return messages[-max_messages:] if max_messages > 0 else messages
except Exception as e:
logger.error(f"Error reading legacy history: {e}")
return []
def clear_session(self, session_key: str) -> None:
"""Clear a specific session's history.
Args:
session_key: Session key to clear
"""
session_file = self._get_session_file(session_key)
if session_file.exists():
session_file.unlink()
for line in lines[-max_messages * 2:]:
if ": " in line:
# Skip timestamp prefix
try:
_, rest = line.split("] ", 1)
role, content = rest.split(": ", 1)
messages.append({"role": role, "content": content})
except ValueError:
continue
return messages[-max_messages:]
return []
def clear_session(self, session_key: str) -> None:
"""Clear a specific session's history.
Args:
session_key: Session key to clear
"""
# In a full implementation, you'd handle session-specific storage
pass
# Vector memory integration
try:
from .vector_memory import (
VectorMemoryStore,
HybridMemorySearch,
EmbeddingProvider,
create_vector_memory_store,
)
VECTOR_MEMORY_AVAILABLE = True
except ImportError:
VectorMemoryStore = None
HybridMemorySearch = None
EmbeddingProvider = None
create_vector_memory_store = None
VECTOR_MEMORY_AVAILABLE = False
class EnhancedAgentMemory(AgentMemory):
"""Enhanced agent memory with vector search capabilities."""
def __init__(
self,
workspace: Path,
enable_vector_search: bool = False,
vector_persist_dir: str | None = None,
embedding_provider: str = "openai",
embedding_model: str = "text-embedding-3-small",
):
"""Initialize enhanced memory manager.
Args:
workspace: Workspace directory for storing memory
enable_vector_search: Enable vector search (requires dependencies)
vector_persist_dir: Directory for vector store persistence
embedding_provider: Provider type (openai, anthropic, local)
embedding_model: Model name for embeddings
"""
super().__init__(workspace)
self.enable_vector_search = enable_vector_search and VECTOR_MEMORY_AVAILABLE
self.vector_store = None
self.hybrid_search = None
self._embedding_provider_type = embedding_provider
self._embedding_model = embedding_model
if self.enable_vector_search:
try:
self.vector_store = create_vector_memory_store(
persist_dir=vector_persist_dir,
provider_type=embedding_provider,
model=embedding_model,
)
if self.vector_store:
self.hybrid_search = HybridMemorySearch(self.vector_store)
logger.info(f"Vector search enabled for agent memory (provider: {embedding_provider})")
except Exception as e:
logger.warning(f"Failed to initialize vector store: {e}")
self.enable_vector_search = False
async def add_memory_with_embedding(
self,
content: str,
agent_id: str,
user_id: str = "default",
memory_type: str = "conversation",
importance: int = 5,
) -> str | None:
"""Add memory with automatic embedding.
Args:
content: Memory content
agent_id: Agent ID
user_id: User ID
memory_type: Type of memory
importance: Importance score (1-10)
Returns:
Memory ID if vector search enabled
"""
# Also save to markdown file (base class behavior)
self.add_to_memory(content)
# Add to vector store if enabled
if self.vector_store:
return await self.vector_store.add_memory(
content=content,
agent_id=agent_id,
user_id=user_id,
memory_type=memory_type,
importance=importance,
)
return None
async def search_memories(
self,
query: str,
agent_id: str | None = None,
user_id: str | None = None,
n_results: int = 5,
) -> list[dict[str, Any]]:
"""Search memories by semantic similarity.
Args:
query: Search query
agent_id: Filter by agent ID
user_id: Filter by user ID
n_results: Number of results
Returns:
List of matching memories
"""
if not self.hybrid_search:
logger.warning("Vector search not enabled")
return []
return await self.hybrid_search.search(
query=query,
agent_id=agent_id,
user_id=user_id,
n_results=n_results,
)
# Intelligent memory system integration
try:
from .intelligent_memory import (
IntelligentMemorySystem,
MemorySummarizer,
ContextCompressor,
MemoryDecayManager,
EvergreenManager,
SummarizationConfig,
create_intelligent_memory_system,
)
INTELLIGENT_MEMORY_AVAILABLE = True
except ImportError:
IntelligentMemorySystem = None
MemorySummarizer = None
ContextCompressor = None
MemoryDecayManager = None
EvergreenManager = None
SummarizationConfig = None
create_intelligent_memory_system = None
INTELLIGENT_MEMORY_AVAILABLE = False
class CompleteAgentMemory:
"""Complete agent memory with all features."""
def __init__(
self,
workspace: Path,
llm_provider=None,
enable_vector_search: bool = False,
vector_persist_dir: str | None = None,
embedding_provider: str = "openai",
embedding_model: str = "text-embedding-3-small",
context_window: int = 200000,
):
"""Initialize complete memory manager.
Args:
workspace: Workspace directory
llm_provider: LLM provider for summarization
enable_vector_search: Enable vector search
vector_persist_dir: Vector store persistence directory
embedding_provider: Embedding provider type
embedding_model: Embedding model name
context_window: Model context window size
"""
# Base memory
self.base = AgentMemory(workspace)
# Enhanced memory with vector search
self.enhanced = None
if enable_vector_search and VECTOR_MEMORY_AVAILABLE:
self.enhanced = EnhancedAgentMemory(
workspace=workspace,
enable_vector_search=True,
vector_persist_dir=vector_persist_dir,
embedding_provider=embedding_provider,
embedding_model=embedding_model,
)
# Intelligent memory system
self.intelligent = None
if INTELLIGENT_MEMORY_AVAILABLE:
self.intelligent = create_intelligent_memory_system(
llm_provider=llm_provider,
context_window=context_window,
)
# Delegate base methods
def get_memory_context(self) -> str:
return self.base.get_memory_context()
def add_to_memory(self, content: str) -> None:
self.base.add_to_memory(content)
def add_to_history(self, role: str, content: str) -> None:
self.base.add_to_history(role, content)
def get_history(self, session_key: str | None = None, max_messages: int = 10):
return self.base.get_history(session_key, max_messages)
# Delegate enhanced methods
async def add_memory_with_embedding(self, *args, **kwargs):
if self.enhanced:
return await self.enhanced.add_memory_with_embedding(*args, **kwargs)
return None
async def search_memories(self, *args, **kwargs):
if self.enhanced:
return await self.enhanced.search_memories(*args, **kwargs)
return []
# Intelligent methods
async def process_message(
self,
messages: list[dict],
current_tokens: int,
agent_id: str,
user_id: str = "default",
):
"""Process message with intelligent memory management."""
if not self.intelligent:
return messages, None
return await self.intelligent.process_message(
messages, current_tokens, agent_id, user_id
)
def get_evergreen_context(self, memories: list[dict]) -> str:
"""Get evergreen memories for context."""
if not self.intelligent:
return ""
return self.intelligent.get_evergreen_context(memories)
def apply_decay(self, memories: list[dict]) -> list[dict]:
"""Apply decay to memories."""
if not self.intelligent:
return memories
return self.intelligent.apply_decay(memories)

View File

@@ -0,0 +1,225 @@
"""Team agent for multi-agent collaboration."""
import asyncio
import logging
from typing import Any
logger = logging.getLogger(__name__)
class TeamAgent:
"""Team agent that manages multiple agents for collaborative problem solving.
Supports different strategies:
- parallel: All agents respond in parallel, results are aggregated
- sequential: Agents respond one by one in sequence
- supervisor: A supervisor agent coordinates the work
"""
def __init__(self, provider: Any, model: str, workspace: Any):
"""Initialize the team agent.
Args:
provider: LLM provider
model: Model name to use
workspace: Workspace path
"""
self.provider = provider
self.model = model
self.workspace = workspace
async def chat(
self,
message: str,
session_id: str = "default",
supervisor_agent_id: int = 0,
member_agent_ids: list[int] | None = None,
strategy: str = "parallel",
) -> dict[str, Any]:
"""Process a team chat message.
Args:
message: User message
session_id: Session identifier
supervisor_agent_id: Supervisor agent ID (for future use)
member_agent_ids: List of member agent IDs to involve
strategy: Collaboration strategy (parallel/sequential/supervisor)
Returns:
Dict with response and subtask_results
"""
member_agent_ids = member_agent_ids or []
logger.info(f"Team chat: strategy={strategy}, members={member_agent_ids}, message={message[:50]}...")
if strategy == "parallel":
return await self._parallel_chat(message, member_agent_ids, session_id)
elif strategy == "sequential":
return await self._sequential_chat(message, member_agent_ids, session_id)
else:
# Default to parallel
return await self._parallel_chat(message, member_agent_ids, session_id)
async def _parallel_chat(
self,
message: str,
member_agent_ids: list[int],
session_id: str,
) -> dict[str, Any]:
"""Execute parallel chat with multiple agents.
Args:
message: User message
member_agent_ids: List of member agent IDs
session_id: Session identifier
Returns:
Aggregated response from all agents
"""
if not member_agent_ids:
return {
"response": "No member agents specified for team chat.",
"subtask_results": [],
}
# Create tasks for each agent
tasks = []
for agent_id in member_agent_ids:
task = self._call_agent(agent_id, message, session_id)
tasks.append(task)
# Execute all tasks in parallel
results = await asyncio.gather(*tasks, return_exceptions=True)
# Aggregate results
subtask_results = []
responses = []
for i, result in enumerate(results):
agent_id = member_agent_ids[i]
if isinstance(result, Exception):
error_msg = f"Agent {agent_id} error: {str(result)}"
logger.error(error_msg)
subtask_results.append({
"agent_id": agent_id,
"status": "error",
"result": str(result),
})
else:
subtask_results.append({
"agent_id": agent_id,
"status": "success",
"result": result,
})
responses.append(result)
# Combine responses
if responses:
combined_response = self._aggregate_responses(responses)
else:
combined_response = "All agents failed to respond."
return {
"response": combined_response,
"subtask_results": subtask_results,
}
async def _sequential_chat(
self,
message: str,
member_agent_ids: list[int],
session_id: str,
) -> dict[str, Any]:
"""Execute sequential chat with multiple agents.
Args:
message: User message
member_agent_ids: List of member agent IDs
session_id: Session identifier
Returns:
Aggregated response from all agents
"""
if not member_agent_ids:
return {
"response": "No member agents specified for team chat.",
"subtask_results": [],
}
subtask_results = []
responses = []
for agent_id in member_agent_ids:
try:
result = await self._call_agent(agent_id, message, session_id)
subtask_results.append({
"agent_id": agent_id,
"status": "success",
"result": result,
})
responses.append(result)
except Exception as e:
error_msg = f"Agent {agent_id} error: {str(e)}"
logger.error(error_msg)
subtask_results.append({
"agent_id": agent_id,
"status": "error",
"result": str(e),
})
# Combine responses
if responses:
combined_response = self._aggregate_responses(responses)
else:
combined_response = "All agents failed to respond."
return {
"response": combined_response,
"subtask_results": subtask_results,
}
async def _call_agent(
self,
agent_id: int,
message: str,
session_id: str,
) -> str:
"""Call an individual agent.
For now, this is a placeholder that simulates agent responses.
In a real implementation, this would call the actual agent.
Args:
agent_id: Agent ID
message: User message
session_id: Session identifier
Returns:
Agent response
"""
# Simulate agent processing delay
await asyncio.sleep(0.5)
# Return a simulated response
return f"Agent {agent_id} processed: {message[:30]}..."
def _aggregate_responses(self, responses: list[str]) -> str:
"""Aggregate multiple agent responses into a single response.
Args:
responses: List of individual agent responses
Returns:
Combined response
"""
if len(responses) == 1:
return responses[0]
header = f"【团队协作结果】共 {len(responses)} 位智能体参与了讨论:\n\n"
body = ""
for i, resp in enumerate(responses, 1):
body += f"--- 智能体 {i} ---\n{resp}\n\n"
return header + body

View File

@@ -0,0 +1,504 @@
"""Vector-based memory retrieval with embedding search."""
import asyncio
import logging
import os
import hashlib
from pathlib import Path
from datetime import datetime
from typing import Any
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
# Try to import optional dependencies
try:
import chromadb
from chromadb.config import Settings
CHROMADB_AVAILABLE = True
except ImportError:
CHROMADB_AVAILABLE = False
logger.warning("chromadb not available, vector search disabled")
class EmbeddingProvider(ABC):
"""Abstract base class for embedding providers."""
@abstractmethod
async def embed(self, texts: list[str]) -> list[list[float]]:
"""Generate embeddings for texts."""
pass
@abstractmethod
async def embed_single(self, text: str) -> list[float]:
"""Generate embedding for a single text."""
pass
class OpenAIEmbeddingProvider(EmbeddingProvider):
"""OpenAI embedding provider using API."""
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
model: str = "text-embedding-3-small",
):
"""Initialize OpenAI embedding provider.
Args:
api_key: OpenAI API key
api_base: Custom API base URL
model: Embedding model name
"""
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
self.api_base = api_base or os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
self.model = model
self._client = None
@property
def client(self):
"""Lazy load OpenAI client."""
if self._client is None:
try:
from openai import AsyncOpenAI
self._client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.api_base,
)
except ImportError:
raise RuntimeError("openai package required: pip install openai")
return self._client
async def embed(self, texts: list[str]) -> list[list[float]]:
"""Generate embeddings using OpenAI API."""
if not texts:
return []
try:
response = await self.client.embeddings.create(
model=self.model,
input=texts,
)
return [data.embedding for data in response.data]
except Exception as e:
logger.error(f"OpenAI embedding error: {e}")
raise
async def embed_single(self, text: str) -> list[float]:
"""Generate embedding for a single text."""
result = await self.embed([text])
return result[0]
class AnthropicEmbeddingProvider(EmbeddingProvider):
"""Anthropic embedding provider using API (via Cohere)."""
def __init__(
self,
api_key: str | None = None,
model: str = "embed-english-v3.0",
):
"""Initialize Anthropic embedding provider.
Note: Anthropic doesn't have native embeddings, this uses Cohere as alternative.
"""
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
self.cohere_key = os.getenv("COHERE_API_KEY")
self.model = model
self._client = None
@property
def client(self):
"""Lazy load Cohere client."""
if self._client is None:
try:
import cohere
self._client = cohere.AsyncClient(self.cohere_key)
except ImportError:
raise RuntimeError("cohere package required: pip install cohere")
return self._client
async def embed(self, texts: list[str]) -> list[list[float]]:
"""Generate embeddings using Cohere API."""
if not texts:
return []
try:
response = await self.client.embed(
texts=texts,
model=self.model,
)
return response.embeddings
except Exception as e:
logger.error(f"Cohere embedding error: {e}")
raise
async def embed_single(self, text: str) -> list[float]:
"""Generate embedding for a single text."""
result = await self.embed([text])
return result[0]
class LocalEmbeddingProvider(EmbeddingProvider):
"""Local embedding provider using sentence-transformers (optional)."""
def __init__(
self,
model_name: str = "all-MiniLM-L6-v2",
device: str = "cpu",
):
"""Initialize local embedding provider.
Args:
model_name: Model name for sentence-transformers
device: Device to use (cpu/cuda)
"""
self.model_name = model_name
self.device = device
self._model = None
self._sentence_transformers_available = False
try:
from sentence_transformers import SentenceTransformer
self._SentenceTransformer = SentenceTransformer
self._sentence_transformers_available = True
except ImportError:
logger.warning("sentence-transformers not available")
@property
def model(self):
"""Lazy load the embedding model."""
if self._model is None:
if not self._sentence_transformers_available:
raise RuntimeError("sentence-transformers not installed")
logger.info(f"Loading embedding model: {self.model_name}")
self._model = self._SentenceTransformer(self.model_name, device=self.device)
return self._model
async def embed(self, texts: list[str]) -> list[list[float]]:
"""Generate embeddings for texts."""
if not texts:
return []
# Run in executor to avoid blocking
loop = asyncio.get_event_loop()
embeddings = await loop.run_in_executor(
None,
lambda: self.model.encode(texts, convert_to_numpy=True)
)
return embeddings.tolist()
async def embed_single(self, text: str) -> list[float]:
"""Generate embedding for a single text."""
result = await self.embed([text])
return result[0]
def create_embedding_provider(
provider_type: str = "openai",
**kwargs,
) -> EmbeddingProvider:
"""Create an embedding provider.
Args:
provider_type: Type of provider (openai, anthropic/cohere, local)
**kwargs: Additional arguments for the provider
Returns:
EmbeddingProvider instance
"""
provider_type = provider_type.lower()
if provider_type == "openai":
return OpenAIEmbeddingProvider(**kwargs)
elif provider_type in ("anthropic", "cohere"):
return AnthropicEmbeddingProvider(**kwargs)
elif provider_type == "local":
return LocalEmbeddingProvider(**kwargs)
else:
raise ValueError(f"Unknown provider type: {provider_type}")
class VectorMemoryStore:
"""Vector-based memory store using ChromaDB."""
def __init__(
self,
persist_directory: Path | str | None = None,
collection_name: str = "agent_memories",
embedding_provider: EmbeddingProvider | None = None,
):
"""Initialize vector memory store.
Args:
persist_directory: Directory to persist ChromaDB data
collection_name: Name of the collection
embedding_provider: Custom embedding provider
"""
if not CHROMADB_AVAILABLE:
raise RuntimeError("chromadb not installed: pip install chromadb")
self.persist_directory = Path(persist_directory) if persist_directory else None
self.collection_name = collection_name
# Default to OpenAI provider if not specified
self.embedding_provider = embedding_provider or OpenAIEmbeddingProvider()
# Initialize ChromaDB client
chroma_settings = Settings(
anonymized_telemetry=False,
allow_reset=True,
)
if self.persist_directory:
self.persist_directory.mkdir(parents=True, exist_ok=True)
self._client = chromadb.PersistentClient(
path=str(self.persist_directory),
settings=chroma_settings,
)
else:
self._client = chromadb.InMemoryClient(settings=chroma_settings)
# Get or create collection
self._collection = self._client.get_or_create_collection(
name=collection_name,
metadata={"description": "Agent memory embeddings"},
)
logger.info(f"Vector memory store initialized: {collection_name}")
def _generate_id(self, content: str, agent_id: str) -> str:
"""Generate unique ID for a memory entry."""
raw = f"{agent_id}:{content}:{datetime.now().isoformat()}"
return hashlib.md5(raw.encode()).hexdigest()
async def add_memory(
self,
content: str,
agent_id: str,
user_id: str = "default",
memory_type: str = "conversation",
importance: int = 5,
) -> str:
"""Add a memory to the vector store.
Args:
content: Memory content
agent_id: Agent ID
user_id: User ID
memory_type: Type of memory
importance: Importance score (1-10)
Returns:
Memory ID
"""
memory_id = self._generate_id(content, agent_id)
embedding = await self.embedding_provider.embed_single(content)
self._collection.add(
ids=[memory_id],
embeddings=[embedding],
documents=[content],
metadatas=[{
"agent_id": agent_id,
"user_id": user_id,
"memory_type": memory_type,
"importance": importance,
"created_at": datetime.now().isoformat(),
}],
)
logger.info(f"Added memory: {memory_id}")
return memory_id
async def search(
self,
query: str,
agent_id: str | None = None,
user_id: str | None = None,
n_results: int = 5,
) -> list[dict[str, Any]]:
"""Search memories by semantic similarity.
Args:
query: Search query
agent_id: Filter by agent ID
user_id: Filter by user ID
n_results: Number of results to return
Returns:
List of matching memories with scores
"""
query_embedding = await self.embedding_provider.embed_single(query)
# Build where filter
where = {}
if agent_id:
where["agent_id"] = agent_id
if user_id:
where["user_id"] = user_id
results = self._collection.query(
query_embeddings=[query_embedding],
n_results=n_results,
where=where if where else None,
include=["documents", "metadatas", "distances"],
)
memories = []
if results["ids"] and results["ids"][0]:
for i, mem_id in enumerate(results["ids"][0]):
memories.append({
"id": mem_id,
"content": results["documents"][0][i],
"metadata": results["metadatas"][0][i],
"distance": results["distances"][0][i],
"score": 1.0 - results["distances"][0][i], # Convert distance to similarity
})
return memories
def delete_memory(self, memory_id: str) -> bool:
"""Delete a memory by ID.
Args:
memory_id: Memory ID
Returns:
True if deleted
"""
try:
self._client.delete_collection(name=self.collection_name)
self._collection = self._client.get_or_create_collection(
name=self.collection_name,
)
return True
except Exception as e:
logger.error(f"Error deleting memory: {e}")
return False
def get_count(self) -> int:
"""Get total number of memories.
Returns:
Memory count
"""
return self._collection.count()
def clear(self, agent_id: str | None = None) -> int:
"""Clear memories.
Args:
agent_id: If provided, only clear memories for this agent
Returns:
Number of memories cleared
"""
try:
if agent_id:
# Get all IDs for this agent
results = self._collection.get(where={"agent_id": agent_id})
if results["ids"]:
self._collection.delete(ids=results["ids"])
return len(results["ids"])
else:
self._client.delete_collection(name=self.collection_name)
self._collection = self._client.get_or_create_collection(
name=self.collection_name,
)
return 0
except Exception as e:
logger.error(f"Error clearing memories: {e}")
return 0
class HybridMemorySearch:
"""Hybrid search combining vector and keyword search."""
def __init__(
self,
vector_store: VectorMemoryStore,
keyword_weight: float = 0.3,
vector_weight: float = 0.7,
):
"""Initialize hybrid search.
Args:
vector_store: Vector memory store
keyword_weight: Weight for keyword search (0-1)
vector_weight: Weight for vector search (0-1)
"""
self.vector_store = vector_store
self.keyword_weight = keyword_weight
self.vector_weight = vector_weight
# Normalize weights
total = keyword_weight + vector_weight
self.keyword_weight /= total
self.vector_weight /= total
async def search(
self,
query: str,
agent_id: str | None = None,
user_id: str | None = None,
n_results: int = 5,
) -> list[dict[str, Any]]:
"""Search with hybrid approach.
For now, this is a simplified implementation using only vector search.
Keyword search (BM25) can be added later with rank_bm25 library.
Args:
query: Search query
agent_id: Filter by agent ID
user_id: Filter by user ID
n_results: Number of results to return
Returns:
List of matching memories with combined scores
"""
# Use vector search as primary method
results = await self.vector_store.search(
query=query,
agent_id=agent_id,
user_id=user_id,
n_results=n_results,
)
# For future BM25 integration, would merge scores here
return results
def create_vector_memory_store(
persist_dir: str | None = None,
provider_type: str = "openai",
**provider_kwargs,
) -> VectorMemoryStore | None:
"""Create a vector memory store with default settings.
Args:
persist_dir: Directory to persist data
provider_type: Type of embedding provider (openai, anthropic, local)
**provider_kwargs: Additional arguments for the provider
Returns:
VectorMemoryStore instance or None if dependencies missing
"""
if not CHROMADB_AVAILABLE:
logger.warning(
"Vector memory requires chromadb. "
"Install with: pip install chromadb"
)
return None
try:
provider = create_embedding_provider(provider_type, **provider_kwargs)
except Exception as e:
logger.warning(f"Failed to create embedding provider: {e}")
return None
return VectorMemoryStore(
persist_directory=persist_dir,
embedding_provider=provider,
)

View File

@@ -0,0 +1,5 @@
"""X-Agents API Module."""
from .routes import router
__all__ = ["router"]

331
core/agents/api/routes.py Normal file
View File

@@ -0,0 +1,331 @@
"""FastAPI routes for agent communication with Go backend."""
import json
import logging
import time
from typing import Any, AsyncGenerator
from fastapi import APIRouter, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter()
# Request/Response models - aligned with Go backend
class ChatRequest(BaseModel):
"""Chat request from Go backend.
Fields aligned with server/internal/service/agent_service.go::AgentChatRequest
"""
agent_id: str # 支持 UUID 字符串
message: str
user_id: int = 0
session_id: str | None = None
model_id: str | None = None
model_name: str | None = None
model_provider: str | None = None
api_key: str | None = None
base_url: str | None = None
use_xbot: bool = False
class ChatResponse(BaseModel):
"""Chat response to Go backend.
Fields aligned with server/internal/service/agent_service.go::AgentChatResponse
"""
agent_id: str # 支持 UUID 字符串
response: str
tool_calls: list = []
tokens_used: int = 0
duration_ms: int = 0
session_id: str
class TeamChatRequest(BaseModel):
"""Team chat request from Go backend.
Fields aligned with server/internal/service/agent_service.go::TeamChatRequest
"""
supervisor_agent_id: int
member_agent_ids: list[int]
message: str
user_id: int = 0
session_id: str | None = None
strategy: str = "parallel"
class TeamChatResponse(BaseModel):
"""Team chat response to Go backend.
Fields aligned with server/internal/service/agent_service.go::TeamChatResponse
"""
supervisor_agent_id: int
response: str
subtask_results: list = []
strategy: str = "parallel"
duration_ms: int = 0
session_id: str
class HealthResponse(BaseModel):
"""Health check response."""
status: str
version: str = "0.1.0"
# Global agent instance (to be initialized by main)
_agent = None
_team_agent = None
def set_agent(agent: Any) -> None:
"""Set the global agent instance.
Args:
agent: Agent loop instance
"""
global _agent
_agent = agent
def set_team_agent(team_agent: Any) -> None:
"""Set the global team agent instance.
Args:
team_agent: Team agent instance
"""
global _team_agent
_team_agent = team_agent
def add_cors(app) -> None:
"""Add CORS middleware to allow Go backend cross-origin requests.
Args:
app: FastAPI application instance
"""
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@router.get("/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
"""Health check endpoint."""
return HealthResponse(status="ok")
@router.post("/agent/chat", response_model=ChatResponse)
async def chat(request: ChatRequest) -> ChatResponse:
"""Handle chat requests from Go backend.
Path: POST /agent/chat
Aligned with Go backend server/internal/service/agent_service.go
Args:
request: Chat request with agent_id, message, user_id, etc.
Returns:
Chat response with agent_id, response, tool_calls, tokens_used, duration_ms, session_id
Raises:
HTTPException: If agent is not initialized or processing fails
"""
if _agent is None:
raise HTTPException(status_code=500, detail="Agent not initialized")
start_time = time.time()
session_id = request.session_id or f"session_{request.agent_id}_{int(start_time)}"
try:
# Prepare kwargs for agent.chat()
kwargs = {
"message": request.message,
"session_key": session_id,
}
# Add optional model configuration
if request.model_id:
kwargs["model_id"] = request.model_id
if request.model_name:
kwargs["model_name"] = request.model_name
if request.model_provider:
kwargs["model_provider"] = request.model_provider
if request.api_key:
kwargs["api_key"] = request.api_key
if request.base_url:
kwargs["base_url"] = request.base_url
if request.use_xbot:
kwargs["use_xbot"] = request.use_xbot
# Process the message
logger.info(f"[chat] kwargs: model_provider={kwargs.get('model_provider')}, model_name={kwargs.get('model_name')}, api_key={'set' if kwargs.get('api_key') else 'not set'}")
result = await _agent.chat(**kwargs)
logger.info(f"[chat] result type={type(result).__name__}, content={str(result)[:100]}")
# Extract response content
if isinstance(result, dict):
response_text = result.get("response", result.get("content", str(result)))
tool_calls = result.get("tool_calls", [])
tokens_used = result.get("tokens_used", 0)
else:
response_text = str(result)
tool_calls = []
tokens_used = 0
duration_ms = int((time.time() - start_time) * 1000)
return ChatResponse(
agent_id=request.agent_id,
response=response_text,
tool_calls=tool_calls,
tokens_used=tokens_used,
duration_ms=duration_ms,
session_id=session_id,
)
except Exception as e:
logger.exception(f"Error processing chat: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/agent/chat/stream")
async def chat_stream(request: ChatRequest):
"""Handle streaming chat requests from Go backend.
Path: POST /agent/chat/stream
Returns streaming response using SSE format.
Args:
request: Chat request with agent_id, message, user_id, etc.
Yields:
Streaming response chunks in SSE format
"""
logger.info(f"[chat_stream] Received request: agent_id={request.agent_id}, message={request.message[:50]}...")
if _agent is None:
logger.error("[chat_stream] Agent not initialized!")
raise HTTPException(status_code=500, detail="Agent not initialized")
session_id = request.session_id or f"session_{request.agent_id}_{int(time.time())}"
async def generate() -> AsyncGenerator[str, None]:
"""Generate streaming response."""
try:
logger.info(f"[chat_stream] Starting stream for session: {session_id}")
# Prepare kwargs for agent.chat()
kwargs = {
"message": request.message,
"session_key": session_id,
}
if request.model_id:
kwargs["model_id"] = request.model_id
logger.info(f"[chat_stream] Using model_id: {request.model_id}")
if request.model_name:
kwargs["model_name"] = request.model_name
logger.info(f"[chat_stream] Using model_name: {request.model_name}")
if request.model_provider:
kwargs["model_provider"] = request.model_provider
logger.info(f"[chat_stream] Using model_provider: {request.model_provider}")
if request.api_key:
kwargs["api_key"] = request.api_key
logger.info(f"[chat_stream] Using api_key: {request.api_key[:10]}...")
if request.base_url:
kwargs["base_url"] = request.base_url
logger.info(f"[chat_stream] Using base_url: {request.base_url}")
if request.use_xbot:
kwargs["use_xbot"] = request.use_xbot
logger.info(f"[chat_stream] Using use_xbot: {request.use_xbot}")
# Process with streaming
chunk_count = 0
async for chunk in _agent.chat_stream(**kwargs):
chunk_count += 1
logger.info(f"[chat_stream] Yielding chunk {chunk_count}: {chunk}")
# SSE format: "data: <json>\n\n" - ensure_ascii=False to output UTF-8 characters directly
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
logger.info(f"[chat_stream] Stream complete, yielded {chunk_count} chunks")
# Send final message
yield f"data: {json.dumps({'done': True, 'session_id': session_id}, ensure_ascii=False)}\n\n"
except Exception as e:
logger.exception(f"Error in streaming chat: {e}")
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
from fastapi.responses import StreamingResponse
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no-cache", # Disable nginx buffering
},
)
@router.post("/agent/team/chat", response_model=TeamChatResponse)
async def team_chat(request: TeamChatRequest) -> TeamChatResponse:
"""Handle team chat requests from Go backend.
Path: POST /agent/team/chat
Aligned with Go backend server/internal/service/agent_service.go::TeamChat
Args:
request: Team chat request with supervisor_agent_id, member_agent_ids, message, etc.
Returns:
Team chat response with supervisor_agent_id, response, subtask_results, strategy, duration_ms, session_id
Raises:
HTTPException: If team agent is not initialized or processing fails
"""
if _team_agent is None:
raise HTTPException(status_code=500, detail="Team agent not initialized")
start_time = time.time()
session_id = request.session_id or f"team_session_{request.supervisor_agent_id}_{int(start_time)}"
try:
# Process the team chat message
result = await _team_agent.chat(
message=request.message,
session_id=session_id,
supervisor_agent_id=request.supervisor_agent_id,
member_agent_ids=request.member_agent_ids,
strategy=request.strategy,
)
# Extract response content
if isinstance(result, dict):
response_text = result.get("response", str(result))
subtask_results = result.get("subtask_results", [])
else:
response_text = str(result)
subtask_results = []
duration_ms = int((time.time() - start_time) * 1000)
return TeamChatResponse(
supervisor_agent_id=request.supervisor_agent_id,
response=response_text,
subtask_results=subtask_results,
strategy=request.strategy,
duration_ms=duration_ms,
session_id=session_id,
)
except Exception as e:
logger.exception(f"Error processing team chat: {e}")
raise HTTPException(status_code=500, detail=str(e))

26
core/agents/api/server.py Normal file
View File

@@ -0,0 +1,26 @@
"""X-Agents API Server."""
import sys
sys.path.insert(0, 'D:/Code/Project/X-Agents/core')
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routes import router
app = FastAPI(title="X-Agents API")
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include the router
app.include_router(router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)

56
core/agents/config.py Normal file
View File

@@ -0,0 +1,56 @@
"""Configuration for X-Agents."""
import os
from pathlib import Path
from typing import Any
# 尝试加载 .env 文件
try:
from dotenv import load_dotenv
# 查找 .env 文件:从当前目录向上查找
env_paths = [
Path(__file__).parent.parent.parent / ".env", # X-Agents/.env
Path(__file__).parent.parent / ".env", # core/.env
Path(__file__).parent / ".env", # agents/.env
]
for env_path in env_paths:
if env_path.exists():
load_dotenv(env_path)
break
except ImportError:
pass # python-dotenv 未安装时跳过
class Config:
"""X-Agents configuration."""
# API settings
API_HOST: str = os.getenv("PYTHON_HOST", os.getenv("API_HOST", "0.0.0.0"))
API_PORT: int = int(os.getenv("PYTHON_PORT", os.getenv("API_PORT", "8001")))
# LLM settings
LLM_PROVIDER: str = os.getenv("PYTHON_LLM_PROVIDER", os.getenv("LLM_PROVIDER", "openai"))
LLM_MODEL: str = os.getenv("PYTHON_LLM_MODEL", os.getenv("LLM_MODEL", "gpt-4o"))
LLM_API_KEY: str = os.getenv("PYTHON_LLM_API_KEY", os.getenv("LLM_API_KEY", ""))
LLM_BASE_URL: str | None = os.getenv("PYTHON_LLM_BASE_URL", os.getenv("LLM_BASE_URL", None))
# Workspace
WORKSPACE: Path = Path(os.getenv("PYTHON_WORKSPACE", os.getenv("WORKSPACE", "./workspace")))
# Agent settings
MAX_ITERATIONS: int = int(os.getenv("PYTHON_MAX_ITERATIONS", os.getenv("MAX_ITERATIONS", "10")))
TEMPERATURE: float = float(os.getenv("PYTHON_TEMPERATURE", os.getenv("TEMPERATURE", "0.7")))
def __init__(self, **kwargs: Any):
"""Initialize config with overrides.
Args:
**kwargs: Configuration overrides
"""
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
# Default config instance
config = Config()

482
core/agents/llm.py Normal file
View File

@@ -0,0 +1,482 @@
"""LLM Provider base classes and implementations."""
import asyncio
import json
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, AsyncGenerator
logger = logging.getLogger(__name__)
@dataclass
class ToolCallRequest:
"""A tool call request from the LLM."""
id: str
name: str
arguments: dict[str, Any]
def to_dict(self) -> dict[str, Any]:
"""Serialize to dict."""
return {
"id": self.id,
"type": "function",
"function": {
"name": self.name,
"arguments": json.dumps(self.arguments, ensure_ascii=False),
},
}
@dataclass
class LLMResponse:
"""Response from an LLM provider."""
content: str | None
tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict)
reasoning_content: str | None = None # For reasoning models
@property
def has_tool_calls(self) -> bool:
"""Check if response contains tool calls."""
return len(self.tool_calls) > 0
@dataclass
class GenerationSettings:
"""Default generation parameters for LLM calls."""
temperature: float = 0.7
max_tokens: int = 4096
class LLMProvider(ABC):
"""Abstract base class for LLM providers."""
_CHAT_RETRY_DELAYS = (1, 2, 4)
_TRANSIENT_ERROR_MARKERS = (
"429", "rate limit", "500", "502", "503", "504",
"overloaded", "timeout", "timed out", "connection",
"server error", "temporarily unavailable",
)
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
):
self.api_key = api_key
self.api_base = api_base
self.generation = GenerationSettings()
@staticmethod
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Sanitize messages to remove empty content that causes provider errors."""
result = []
for msg in messages:
content = msg.get("content")
if isinstance(content, str) and not content:
clean = dict(msg)
if msg.get("role") == "assistant" and msg.get("tool_calls"):
clean["content"] = None
else:
clean["content"] = "(empty)"
result.append(clean)
continue
result.append(msg)
return result
@abstractmethod
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
stream: bool = False,
) -> LLMResponse | AsyncGenerator[str, None]:
"""Send a chat completion request."""
pass
@classmethod
def _is_transient_error(cls, content: str | None) -> bool:
err = (content or "").lower()
return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS)
async def chat_with_retry(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
) -> LLMResponse:
"""Call chat() with retry on transient provider failures."""
max_tokens = max_tokens or self.generation.max_tokens
temperature = temperature or self.generation.temperature
messages = self._sanitize_messages(messages)
for attempt, delay in enumerate(self._CHAT_RETRY_DELAYS, start=1):
try:
response = await self.chat(
messages=messages,
tools=tools,
model=model,
max_tokens=max_tokens,
temperature=temperature,
)
except asyncio.CancelledError:
raise
except Exception as exc:
response = LLMResponse(
content=f"Error calling LLM: {exc}",
finish_reason="error",
)
if response.finish_reason != "error":
return response
if not self._is_transient_error(response.content):
return response
logger.warning(
"LLM transient error (attempt {}/{}), retrying in {}s",
attempt,
len(self._CHAT_RETRY_DELAYS),
delay,
)
await asyncio.sleep(delay)
# Last attempt
try:
return await self.chat(
messages=messages,
tools=tools,
model=model,
max_tokens=max_tokens,
temperature=temperature,
)
except asyncio.CancelledError:
raise
except Exception as exc:
return LLMResponse(
content=f"Error calling LLM: {exc}",
finish_reason="error",
)
@abstractmethod
def get_default_model(self) -> str:
"""Get the default model for this provider."""
pass
# OpenAI Provider
class OpenAIProvider(LLMProvider):
"""OpenAI LLM provider."""
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
):
super().__init__(api_key, api_base)
self._client = None
@property
def client(self):
"""Lazy load OpenAI client."""
if self._client is None:
try:
from openai import AsyncOpenAI
self._client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.api_base,
)
except ImportError:
raise ImportError("openai package required: pip install openai")
return self._client
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
stream: bool = False,
) -> LLMResponse:
model = model or self.get_default_model()
params = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if tools:
params["tools"] = tools
params["tool_choice"] = "auto"
try:
response = await self.client.chat.completions.create(**params)
choice = response.choices[0]
msg = choice.message
tool_calls = []
if msg.tool_calls:
for tc in msg.tool_calls:
args = tc.function.arguments
if isinstance(args, str):
args = json.loads(args)
tool_calls.append(ToolCallRequest(
id=tc.id,
name=tc.function.name,
arguments=args,
))
return LLMResponse(
content=msg.content,
tool_calls=tool_calls,
finish_reason=choice.finish_reason,
usage={
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
},
)
except Exception as exc:
logger.error(f"OpenAI API error: {exc}")
return LLMResponse(
content=f"Error: {exc}",
finish_reason="error",
)
async def chat_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> AsyncGenerator[str, None]:
"""Stream chat completions."""
model = model or self.get_default_model()
params = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"stream": True,
}
if tools:
params["tools"] = tools
try:
response = await self.client.chat.completions.create(**params)
async for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
except Exception as exc:
yield f"Error: {exc}"
def get_default_model(self) -> str:
return "gpt-4o"
# Anthropic Provider
class AnthropicProvider(LLMProvider):
"""Anthropic Claude LLM provider."""
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
):
super().__init__(api_key, api_base)
self._client = None
@property
def client(self):
"""Lazy load Anthropic client."""
if self._client is None:
try:
from anthropic import AsyncAnthropic
self._client = AsyncAnthropic(
api_key=self.api_key,
base_url=self.api_base,
)
except ImportError:
raise ImportError("anthropic package required: pip install anthropic")
return self._client
def _convert_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert messages to Anthropic format."""
converted = []
for msg in messages:
role = msg.get("role")
if role == "system":
# Anthropic puts system in first user message
content = msg.get("content", "")
if converted and converted[0].get("role") == "user":
converted[0]["content"] = f"{content}\n\n{converted[0].content}"
else:
converted.append({"role": "user", "content": f"{content}"})
else:
# Handle tool results
if role == "tool":
converted.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": msg.get("tool_call_id"),
"content": msg.get("content", ""),
}
],
})
else:
converted.append(msg)
return converted
def _convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert OpenAI-style tools to Anthropic format."""
anthropic_tools = []
for tool in tools:
func = tool.get("function", {})
anthropic_tools.append({
"name": func.get("name"),
"description": func.get("description"),
"input_schema": func.get("parameters", {}),
})
return anthropic_tools
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
stream: bool = False,
) -> LLMResponse:
model = model or self.get_default_model()
params = {
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
"messages": self._convert_messages(messages),
}
if tools:
params["tools"] = self._convert_tools(tools)
try:
response = await self.client.messages.create(**params)
tool_calls = []
for tc in response.tool_calls:
args = tc.input
if isinstance(args, str):
args = json.loads(args)
tool_calls.append(ToolCallRequest(
id=tc.id,
name=tc.name,
arguments=args,
))
# Get content text
content_text = ""
thinking = None
if response.content:
for block in response.content:
if block.type == "text":
content_text = block.text
elif block.type == "thinking":
thinking = block.thinking
return LLMResponse(
content=content_text,
tool_calls=tool_calls,
finish_reason="stop" if not tool_calls else "tool_use",
reasoning_content=thinking,
usage={
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
},
)
except Exception as exc:
logger.error(f"Anthropic API error: {exc}")
return LLMResponse(
content=f"Error: {exc}",
finish_reason="error",
)
async def chat_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> AsyncGenerator[str, None]:
"""Stream chat completions."""
model = model or self.get_default_model()
params = {
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
"messages": self._convert_messages(messages),
"stream": True,
}
if tools:
params["tools"] = self._convert_tools(tools)
try:
async with self.client.messages.stream(**params) as stream:
async for text in stream.text_stream:
yield text
except Exception as exc:
yield f"Error: {exc}"
def get_default_model(self) -> str:
return "claude-sonnet-4-20250514"
# Provider factory
class ProviderFactory:
"""Factory for creating LLM providers."""
_PROVIDERS = {
"openai": OpenAIProvider,
"anthropic": AnthropicProvider,
}
@classmethod
def create(
cls,
provider: str,
api_key: str | None = None,
api_base: str | None = None,
) -> LLMProvider:
"""Create an LLM provider instance.
Args:
provider: Provider name (openai, anthropic)
api_key: API key
api_base: Optional base URL for API
Returns:
LLM provider instance
"""
provider_cls = cls._PROVIDERS.get(provider.lower())
if not provider_cls:
raise ValueError(f"Unknown provider: {provider}. Available: {list(cls._PROVIDERS.keys())}")
return provider_cls(api_key=api_key, api_base=api_base)

165
core/agents/main.py Normal file
View File

@@ -0,0 +1,165 @@
"""Main entry point for X-Agents agent service."""
import logging
import asyncio
import sys
from pathlib import Path
# Add project root to path (parent of core directory)
project_root = Path(__file__).parent.parent.parent
core_dir = project_root / "core"
sys.path.insert(0, str(project_root)) # for X-Agents root
sys.path.insert(0, str(core_dir)) # for core
sys.path.insert(0, str(core_dir / "nanobot")) # for nanobot
from fastapi import FastAPI
import uvicorn
from agents.config import Config
from agents.api.routes import router, set_agent, set_team_agent, add_cors
from agents.agent.loop import AgentLoop
from agents.agent.team_agent import TeamAgent
from agents.llm import ProviderFactory
from agents.tools import create_default_registry
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
class SimpleProvider:
"""Simple LLM provider placeholder for testing without API keys."""
def __init__(self, api_key: str = "", base_url: str | None = None):
self.api_key = api_key
self.base_url = base_url
async def chat(self, messages: list[dict], model: str, **kwargs) -> dict:
"""Simulate LLM chat response.
Args:
messages: Message list
model: Model name
Returns:
Simulated response
"""
from agents.llm import LLMResponse
user_msg = ""
for msg in reversed(messages):
if msg.get("role") == "user":
user_msg = msg.get("content", "")
break
return LLMResponse(
content=f"I received your message: {user_msg[:50]}... (LLM integration pending)",
tool_calls=[],
finish_reason="stop",
)
async def chat_with_retry(self, *args, **kwargs):
return await self.chat(*args, **kwargs)
def get_default_model(self) -> str:
return "simple"
def create_app(config: Config | None = None) -> FastAPI:
"""Create and configure the FastAPI application.
Args:
config: Configuration instance
Returns:
Configured FastAPI app
"""
config = config or Config()
app = FastAPI(
title="X-Agents API",
description="Agent API for X-Agents platform",
version="0.1.0",
)
# Include routers with /api/v1 prefix (aligned with Go backend paths: /api/agent/chat, /api/agent/chat/stream)
app.include_router(router, prefix="/api/v1")
# Add CORS middleware to allow Go backend cross-origin requests
add_cors(app)
# Initialize LLM provider
if config.LLM_API_KEY:
try:
provider = ProviderFactory.create(
provider=config.LLM_PROVIDER,
api_key=config.LLM_API_KEY,
api_base=config.LLM_BASE_URL,
)
logger.info(f"Using {config.LLM_PROVIDER} provider with model {config.LLM_MODEL}")
except ImportError as e:
logger.warning(f"Failed to import provider package: {e}, using placeholder")
provider = SimpleProvider(api_key=config.LLM_API_KEY)
else:
logger.warning("No LLM_API_KEY provided, using placeholder provider")
provider = SimpleProvider()
# Create tool registry
tools = create_default_registry()
# Initialize agent
agent = AgentLoop(
provider=provider,
model=config.LLM_MODEL,
workspace=config.WORKSPACE,
max_iterations=config.MAX_ITERATIONS,
tools=tools,
)
set_agent(agent)
# Initialize team agent for multi-agent collaboration
team_agent = TeamAgent(
provider=provider,
model=config.LLM_MODEL,
workspace=config.WORKSPACE,
)
set_team_agent(team_agent)
@app.on_event("startup")
async def startup_event():
logger.info("X-Agents starting up...")
logger.info(f"Model: {config.LLM_MODEL}")
logger.info(f"Provider: {config.LLM_PROVIDER}")
logger.info(f"Workspace: {config.WORKSPACE}")
logger.info(f"Tools: {tools.tool_names}")
@app.on_event("shutdown")
async def shutdown_event():
logger.info("X-Agents shutting down...")
return app
def main():
"""Run the agent service."""
config = Config()
# Ensure workspace exists
config.WORKSPACE.mkdir(exist_ok=True)
app = create_app(config)
uvicorn.run(
app,
host=config.API_HOST,
port=config.API_PORT,
log_level="info",
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,7 @@
"""LLM Provider abstraction for X-Agents."""
from agents.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from agents.providers.openai_provider import OpenAIProvider
from agents.providers.anthropic_provider import AnthropicProvider
__all__ = ["LLMProvider", "LLMResponse", "ToolCallRequest", "OpenAIProvider", "AnthropicProvider"]

View File

@@ -0,0 +1,241 @@
"""Anthropic LLM provider implementation."""
import json
import secrets
import string
from typing import Any
import aiohttp
from loguru import logger
from agents.providers.base import LLMProvider, LLMResponse, ToolCallRequest
_ALNUM = string.ascii_letters + string.digits
def _short_tool_id() -> str:
"""Generate a 9-char alphanumeric ID for tool calls."""
return "".join(secrets.choice(_ALNUM) for _ in range(9))
class AnthropicProvider(LLMProvider):
"""Anthropic LLM provider using Claude API."""
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
default_model: str = "claude-sonnet-4-20250514",
):
super().__init__(api_key, api_base)
self.default_model = default_model
self._session: aiohttp.ClientSession | None = None
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self):
"""Close the HTTP session."""
if self._session and not self._session.closed:
await self._session.close()
def _convert_messages_to_anthropic(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert messages to Anthropic API format."""
converted = []
for msg in messages:
role = msg.get("role")
content = msg.get("content")
# Handle tool calls in assistant messages
if role == "assistant" and msg.get("tool_calls"):
# Anthropic doesn't support tool_calls in the same way, convert to text
tool_calls_text = "\n".join([
f"Tool call: {tc.get('name')}({json.dumps(tc.get('arguments', {}))})"
for tc in msg["tool_calls"]
])
if content:
content = f"{content}\n\n{tool_calls_text}"
else:
content = tool_calls_text
# Handle tool results
if role == "tool":
# Convert tool result to Anthropic format
tool_use_id = msg.get("tool_call_id", _short_tool_id())
converted.append({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content or "(empty)",
})
continue
# Skip system messages - they'll be handled separately
if role == "system":
continue
# Convert content to Anthropic format
if isinstance(content, str):
converted.append({
"role": role,
"content": content,
})
elif isinstance(content, list):
# Handle list content
text_parts = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
text_parts.append(item.get("text", ""))
elif item.get("type") == "tool_use":
# This shouldn't happen in input, but handle it
text_parts.append(f"[tool_use: {item.get('name')}]")
elif item.get("type") == "tool_result":
text_parts.append(item.get("content", ""))
converted.append({
"role": role,
"content": "\n".join(text_parts),
})
else:
converted.append({
"role": role,
"content": str(content) if content else "(empty)",
})
return converted
def _get_system_message(self, messages: list[dict[str, Any]]) -> str | None:
"""Extract system message from messages."""
for msg in messages:
if msg.get("role") == "system":
return msg.get("content")
return None
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> LLMResponse:
"""Send a chat completion request to Anthropic API."""
model = model or self.default_model
api_base = self.api_base or "https://api.anthropic.com"
url = f"{api_base}/v1/messages"
headers = {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
}
if self.api_key:
headers["x-api-key"] = self.api_key
# Get system message and convert other messages
system = self._get_system_message(messages)
anthropic_messages = self._convert_messages_to_anthropic(messages)
payload: dict[str, Any] = {
"model": model,
"messages": anthropic_messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if system:
payload["system"] = system
# Convert tools to Anthropic format if provided
if tools:
anthropic_tools = self._convert_tools(tools)
payload["tools"] = anthropic_tools
try:
session = await self._get_session()
async with session.post(url, json=payload, headers=headers) as resp:
if resp.status != 200:
error_text = await resp.text()
try:
error_json = json.loads(error_text)
error_msg = error_json.get("error", {}).get("message", error_text)
except json.JSONDecodeError:
error_msg = error_text
return LLMResponse(
content=f"Anthropic API error (status {resp.status}): {error_msg}",
finish_reason="error",
)
data = await resp.json()
return self._parse_response(data, tools is not None)
except aiohttp.ClientError as e:
return LLMResponse(
content=f"Anthropic API connection error: {str(e)}",
finish_reason="error",
)
except Exception as e:
return LLMResponse(
content=f"Error calling Anthropic: {str(e)}",
finish_reason="error",
)
def _convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert OpenAI-style tools to Anthropic format."""
anthropic_tools = []
for tool in tools:
func = tool.get("function", {})
anthropic_tools.append({
"name": func.get("name", ""),
"description": func.get("description", ""),
"input_schema": func.get("parameters", {"type": "object", "properties": {}}),
})
return anthropic_tools
def _parse_response(self, data: dict[str, Any], has_tools: bool = False) -> LLMResponse:
"""Parse Anthropic API response into our standard format."""
content = data.get("content", [])
# Extract text content
text_content = ""
tool_calls = []
for block in content:
if block.get("type") == "text":
text_content += block.get("text", "")
elif block.get("type") == "tool_use" and has_tools:
# Convert Anthropic tool_use to our format
args = block.get("input", {})
tool_calls.append(ToolCallRequest(
id=block.get("id", _short_tool_id()),
name=block.get("name", ""),
arguments=args,
))
# Determine finish reason
stop_reason = data.get("stop_reason", "end_turn")
if stop_reason == "tool_use":
finish_reason = "tool_calls"
elif stop_reason == "max_tokens":
finish_reason = "length"
else:
finish_reason = "stop"
# Parse usage
usage = data.get("usage", {})
usage_dict = {
"prompt_tokens": usage.get("input_tokens", 0),
"completion_tokens": usage.get("output_tokens", 0),
"total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
}
return LLMResponse(
content=text_content if text_content else None,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage_dict,
)
def get_default_model(self) -> str:
"""Get the default model."""
return self.default_model

View File

@@ -0,0 +1,225 @@
"""Base LLM provider interface."""
import asyncio
import json
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
from loguru import logger
@dataclass
class ToolCallRequest:
"""A tool call request from the LLM."""
id: str
name: str
arguments: dict[str, Any]
provider_specific_fields: dict[str, Any] | None = None
def to_openai_tool_call(self) -> dict[str, Any]:
"""Serialize to an OpenAI-style tool_call payload."""
tool_call = {
"id": self.id,
"type": "function",
"function": {
"name": self.name,
"arguments": json.dumps(self.arguments, ensure_ascii=False),
},
}
if self.provider_specific_fields:
tool_call["provider_specific_fields"] = self.provider_specific_fields
return tool_call
@dataclass
class LLMResponse:
"""Response from an LLM provider."""
content: str | None
tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict)
reasoning_content: str | None = None # For reasoning models
@property
def has_tool_calls(self) -> bool:
"""Check if response contains tool calls."""
return len(self.tool_calls) > 0
@dataclass(frozen=True)
class GenerationSettings:
"""Default generation parameters for LLM calls."""
temperature: float = 0.7
max_tokens: int = 4096
class LLMProvider(ABC):
"""
Abstract base class for LLM providers.
Implementations should handle the specifics of each provider's API
while maintaining a consistent interface.
"""
_CHAT_RETRY_DELAYS = (1, 2, 4)
_TRANSIENT_ERROR_MARKERS = (
"429",
"rate limit",
"500",
"502",
"503",
"504",
"overloaded",
"timeout",
"timed out",
"connection",
"server error",
"temporarily unavailable",
)
_SENTINEL = object()
def __init__(self, api_key: str | None = None, api_base: str | None = None):
self.api_key = api_key
self.api_base = api_base
self.generation: GenerationSettings = GenerationSettings()
@staticmethod
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Replace empty text content that causes provider 400 errors."""
result: list[dict[str, Any]] = []
for msg in messages:
content = msg.get("content")
if isinstance(content, str) and not content:
clean = dict(msg)
clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)"
result.append(clean)
continue
if isinstance(content, list):
filtered = [
item for item in content
if not (
isinstance(item, dict)
and item.get("type") in ("text", "input_text", "output_text")
and not item.get("text")
)
]
if len(filtered) != len(content):
clean = dict(msg)
if filtered:
clean["content"] = filtered
elif msg.get("role") == "assistant" and msg.get("tool_calls"):
clean["content"] = None
else:
clean["content"] = "(empty)"
result.append(clean)
continue
if isinstance(content, dict):
clean = dict(msg)
clean["content"] = [content]
result.append(clean)
continue
result.append(msg)
return result
@abstractmethod
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> LLMResponse:
"""
Send a chat completion request.
Args:
messages: List of message dicts with 'role' and 'content'.
tools: Optional list of tool definitions.
model: Model identifier (provider-specific).
max_tokens: Maximum tokens in response.
temperature: Sampling temperature.
Returns:
LLMResponse with content and/or tool calls.
"""
pass
@classmethod
def _is_transient_error(cls, content: str | None) -> bool:
err = (content or "").lower()
return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS)
async def chat_with_retry(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: object = _SENTINEL,
temperature: object = _SENTINEL,
) -> LLMResponse:
"""Call chat() with retry on transient provider failures."""
if max_tokens is self._SENTINEL:
max_tokens = self.generation.max_tokens
if temperature is self._SENTINEL:
temperature = self.generation.temperature
for attempt, delay in enumerate(self._CHAT_RETRY_DELAYS, start=1):
try:
response = await self.chat(
messages=messages,
tools=tools,
model=model,
max_tokens=max_tokens,
temperature=temperature,
)
except asyncio.CancelledError:
raise
except Exception as exc:
response = LLMResponse(
content=f"Error calling LLM: {exc}",
finish_reason="error",
)
if response.finish_reason != "error":
return response
if not self._is_transient_error(response.content):
return response
err = (response.content or "").lower()
logger.warning(
"LLM transient error (attempt {}/{}), retrying in {}s: {}",
attempt,
len(self._CHAT_RETRY_DELAYS),
delay,
err[:120],
)
await asyncio.sleep(delay)
try:
return await self.chat(
messages=messages,
tools=tools,
model=model,
max_tokens=max_tokens,
temperature=temperature,
)
except asyncio.CancelledError:
raise
except Exception as exc:
return LLMResponse(
content=f"Error calling LLM: {exc}",
finish_reason="error",
)
@abstractmethod
def get_default_model(self) -> str:
"""Get the default model for this provider."""
pass

View File

@@ -0,0 +1,150 @@
"""OpenAI LLM provider implementation."""
import json
import secrets
import string
from typing import Any
import aiohttp
from loguru import logger
from agents.providers.base import LLMProvider, LLMResponse, ToolCallRequest
_ALNUM = string.ascii_letters + string.digits
def _short_tool_id() -> str:
"""Generate a 9-char alphanumeric ID for tool calls."""
return "".join(secrets.choice(_ALNUM) for _ in range(9))
class OpenAIProvider(LLMProvider):
"""OpenAI LLM provider using OpenAI API."""
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
default_model: str = "gpt-4o",
):
super().__init__(api_key, api_base)
self.default_model = default_model
self._session: aiohttp.ClientSession | None = None
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self):
"""Close the HTTP session."""
if self._session and not self._session.closed:
await self._session.close()
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> LLMResponse:
"""Send a chat completion request to OpenAI API."""
model = model or self.default_model
api_base = self.api_base or "https://api.openai.com/v1"
url = f"{api_base}/chat/completions"
headers = {
"Content-Type": "application/json",
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
# Sanitize messages
messages = self._sanitize_empty_content(messages)
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
try:
session = await self._get_session()
async with session.post(url, json=payload, headers=headers) as resp:
if resp.status != 200:
error_text = await resp.text()
return LLMResponse(
content=f"OpenAI API error (status {resp.status}): {error_text}",
finish_reason="error",
)
data = await resp.json()
return self._parse_response(data)
except aiohttp.ClientError as e:
return LLMResponse(
content=f"OpenAI API connection error: {str(e)}",
finish_reason="error",
)
except Exception as e:
return LLMResponse(
content=f"Error calling OpenAI: {str(e)}",
finish_reason="error",
)
def _parse_response(self, data: dict[str, Any]) -> LLMResponse:
"""Parse OpenAI API response into our standard format."""
choices = data.get("choices", [])
if not choices:
return LLMResponse(content="", finish_reason="stop")
choice = choices[0]
message = choice.get("message", {})
content = message.get("content")
finish_reason = choice.get("finish_reason", "stop")
# Parse tool calls
tool_calls = []
raw_tool_calls = message.get("tool_calls", [])
for tc in raw_tool_calls:
func = tc.get("function", {})
args_str = func.get("arguments", "{}")
if isinstance(args_str, str):
try:
args = json.loads(args_str)
except json.JSONDecodeError:
args = {}
else:
args = args_str
tool_calls.append(ToolCallRequest(
id=tc.get("id", _short_tool_id()),
name=func.get("name", ""),
arguments=args,
))
# Parse usage
usage = data.get("usage", {})
usage_dict = {
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
}
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage_dict,
)
def get_default_model(self) -> str:
"""Get the default model."""
return self.default_model

View File

@@ -0,0 +1,23 @@
# X-Agents Agent Core Dependencies
# Web framework
fastapi>=0.109.0
uvicorn>=0.27.0
pydantic>=2.5.0
# LLM providers
openai>=1.12.0
anthropic>=0.18.0
# Async
aiohttp>=3.9.0
# Vector search (optional)
chromadb>=0.4.0
# Utilities
python-dotenv>=1.0.0
# Sandbox isolation (optional)
# Install gVisor for enhanced sandbox: https://gvisor.dev/
# Or use bwrapfs which is available on most Linux systems

View File

@@ -0,0 +1,6 @@
"""Skills module for X-Agents."""
from agents.skills.loader import SkillsLoader, Skill
from agents.skills.executor import SkillExecutor
__all__ = ["SkillsLoader", "Skill", "SkillExecutor"]

View File

@@ -0,0 +1,178 @@
"""Skill executor for executing skills."""
import logging
import re
from dataclasses import dataclass
from typing import Any
from loguru import logger
from agents.skills.loader import Skill, SkillsLoader
logger = logging.getLogger(__name__)
@dataclass
class SkillContext:
"""Execution context for a skill."""
skill_id: str
skill_name: str
input_data: dict[str, Any]
user_message: str
class SkillExecutor:
"""Executes skills based on user input."""
def __init__(self, skills_loader: SkillsLoader):
"""Initialize skill executor.
Args:
skills_loader: SkillsLoader instance for loading skills
"""
self.loader = skills_loader
self._skills_prompt_cache: dict[str, str] = {}
async def find_matching_skills(self, user_message: str) -> list[Skill]:
"""Find skills that match the user message.
Args:
user_message: User's input message
Returns:
List of matching skills (currently returns all active skills)
"""
# Get all active skills
skills = await self.loader.list_skills()
active_skills = [s for s in skills if s.status == "active"]
return active_skills
async def execute_skill(
self,
skill_id: str,
context: SkillContext,
) -> str | None:
"""Execute a skill with given context.
Args:
skill_id: ID of skill to execute
context: Execution context
Returns:
Execution result as string, or None if failed
"""
skill = await self.loader.load_skill_with_content(skill_id)
if not skill:
logger.warning(f"Skill not found: {skill_id}")
return None
if skill.status != "active":
logger.warning(f"Skill is not active: {skill_id}")
return None
# Extract prompt/instructions from skill content
prompt = self._extract_skill_prompt(skill)
# Replace placeholders in prompt with context
prompt = self._inject_context(prompt, context)
return prompt
def _extract_skill_prompt(self, skill: Skill) -> str:
"""Extract main prompt/instructions from skill content.
Args:
skill: Skill object with content
Returns:
Extracted prompt
"""
content = skill.content
# Skip frontmatter if present
lines = content.split("\n")
start_line = 0
if content.startswith("---"):
for i in range(1, len(lines)):
if lines[i].strip() == "---":
start_line = i + 1
break
# Join remaining content
main_content = "\n".join(lines[start_line:])
# Remove markdown headers but keep content
prompt = re.sub(r"^#+\s+", "", main_content, flags=re.MULTILINE)
return prompt.strip()
def _inject_context(self, prompt: str, context: SkillContext) -> str:
"""Inject context into prompt template.
Args:
prompt: Prompt template
context: Execution context
Returns:
Prompt with context injected
"""
# Replace common placeholders
replacements = {
"{{user_message}}": context.user_message,
"{{skill_name}}": context.skill_name,
"{{input}}": str(context.input_data),
}
result = prompt
for placeholder, value in replacements.items():
result = result.replace(placeholder, value)
return result
async def get_skill_system_prompt(self, skill_id: str) -> str | None:
"""Get system prompt for a skill to be used in LLM context.
Args:
skill_id: Skill ID
Returns:
System prompt for the skill, or None if not found
"""
# Check cache
if skill_id in self._skills_prompt_cache:
return self._skills_prompt_cache[skill_id]
skill = await self.loader.load_skill_with_content(skill_id)
if not skill or skill.status != "active":
return None
# Extract prompt
prompt = self._extract_skill_prompt(skill)
# Cache it
self._skills_prompt_cache[skill_id] = prompt
return prompt
def build_skills_context(self, skills: list[Skill]) -> str:
"""Build context string from multiple skills.
Args:
skills: List of skills
Returns:
Combined context string
"""
if not skills:
return ""
context_parts = ["## Available Skills\n"]
for skill in skills:
context_parts.append(f"### {skill.name}")
context_parts.append(f"{skill.description}\n")
return "\n".join(context_parts)
def clear_cache(self) -> None:
"""Clear prompt cache."""
self._skills_prompt_cache.clear()

View File

@@ -0,0 +1,252 @@
"""Skills loader for loading and managing skills from Go backend."""
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import aiohttp
logger = logging.getLogger(__name__)
@dataclass
class Skill:
"""Skill data model."""
id: str
name: str
description: str
skill_type: str # system/user
status: str # active/inactive
path: str
content: str = ""
class SkillsLoader:
"""Loads skills from Go backend API and local file system."""
def __init__(self, base_url: str):
"""Initialize skills loader.
Args:
base_url: Go backend API base URL
"""
self.base_url = base_url.rstrip("/")
self._session = None
self._skills_cache: dict[str, Skill] = {}
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self) -> None:
"""Close the session."""
if self._session and not self._session.closed:
await self._session.close()
async def list_skills(self, skill_type: str | None = None) -> list[Skill]:
"""List all skills from Go backend.
Args:
skill_type: Optional filter by skill type (system/user)
Returns:
List of skills
"""
url = f"{self.base_url}/api/skill/list"
params = {}
if skill_type:
params["type"] = skill_type
try:
session = await self._get_session()
async with session.get(url, params=params) as response:
if response.status == 200:
result = await response.json()
skills_list = result.get("list", [])
skills = []
for s in skills_list:
skill = Skill(
id=s.get("id", ""),
name=s.get("skill_name", ""),
description=s.get("skill_desc", ""),
skill_type=s.get("skill_type", "user"),
status=s.get("status", "inactive"),
path=s.get("path", ""),
)
skills.append(skill)
return skills
logger.warning(f"Failed to list skills: {response.status}")
return []
except Exception as e:
logger.error(f"Error listing skills: {e}")
return []
async def get_skill(self, skill_id: str) -> Skill | None:
"""Get a skill by ID.
Args:
skill_id: Skill ID
Returns:
Skill object or None if not found
"""
# Check cache first
if skill_id in self._skills_cache:
return self._skills_cache[skill_id]
url = f"{self.base_url}/api/skill/{skill_id}"
try:
session = await self._get_session()
async with session.get(url) as response:
if response.status == 200:
result = await response.json()
skill_data = result.get("skill", {})
skill = Skill(
id=skill_data.get("id", ""),
name=skill_data.get("skill_name", ""),
description=skill_data.get("skill_desc", ""),
skill_type=skill_data.get("skill_type", "user"),
status=skill_data.get("status", "inactive"),
path=skill_data.get("path", ""),
)
self._skills_cache[skill_id] = skill
return skill
return None
except Exception as e:
logger.error(f"Error getting skill {skill_id}: {e}")
return None
async def get_skill_content(self, skill_id: str) -> str | None:
"""Get skill content (SKILL.md file content).
Args:
skill_id: Skill ID
Returns:
Skill content as string, or None if failed
"""
url = f"{self.base_url}/api/skill/content"
params = {"id": skill_id}
try:
session = await self._get_session()
async with session.get(url, params=params) as response:
if response.status == 200:
content = await response.text()
return content
logger.warning(f"Failed to get skill content: {response.status}")
return None
except Exception as e:
logger.error(f"Error getting skill content: {e}")
return None
async def sync_skills(self) -> int:
"""Manually trigger skills sync from file system.
Returns:
Number of skills synced
"""
url = f"{self.base_url}/api/skill/sync"
try:
session = await self._get_session()
async with session.get(url) as response:
if response.status == 200:
result = await response.json()
count = result.get("count", 0)
logger.info(f"Synced {count} skills")
return count
return 0
except Exception as e:
logger.error(f"Error syncing skills: {e}")
return 0
async def load_skill_with_content(self, skill_id: str) -> Skill | None:
"""Load skill with its content.
Args:
skill_id: Skill ID
Returns:
Skill object with content, or None if failed
"""
skill = await self.get_skill(skill_id)
if skill:
content = await self.get_skill_content(skill_id)
if content:
skill.content = content
return skill
def load_skill_from_file(self, file_path: str | Path) -> Skill | None:
"""Load skill from local file system.
Args:
file_path: Path to SKILL.md file
Returns:
Skill object or None if failed
"""
path = Path(file_path)
if not path.exists():
logger.warning(f"Skill file not found: {path}")
return None
try:
content = path.read_text(encoding="utf-8")
# Parse frontmatter
name, description = self._parse_frontmatter(content)
return Skill(
id="",
name=name or path.stem,
description=description or "",
skill_type="user",
status="active",
path=str(path),
content=content,
)
except Exception as e:
logger.error(f"Error loading skill from file: {e}")
return None
def _parse_frontmatter(self, content: str) -> tuple[str | None, str | None]:
"""Parse YAML frontmatter from skill content.
Args:
content: Skill markdown content
Returns:
Tuple of (name, description)
"""
import re
if not content.startswith("---"):
return None, None
lines = content.split("\n")
end_idx = 0
for i in range(1, len(lines)):
if lines[i].strip() == "---":
end_idx = i
break
if end_idx == 0:
return None, None
yaml_content = "\n".join(lines[1:end_idx])
name_match = re.search(r"name:\s*(.+)", yaml_content)
name = name_match.group(1).strip() if name_match else None
desc_match = re.search(r"description:\s*(.+)", yaml_content)
description = desc_match.group(1).strip() if desc_match else None
return name, description
def clear_cache(self) -> None:
"""Clear skills cache."""
self._skills_cache.clear()

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,405 @@
---
name: openakita/skills@algorithmic-art
description: Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.
license: Complete terms in LICENSE.txt
---
Algorithmic philosophies are computational aesthetic movements that are then expressed through code. Output .md files (philosophy), .html files (interactive viewer), and .js files (generative algorithms).
This happens in two steps:
1. Algorithmic Philosophy Creation (.md file)
2. Express by creating p5.js generative art (.html + .js files)
First, undertake this task:
## ALGORITHMIC PHILOSOPHY CREATION
To begin, create an ALGORITHMIC PHILOSOPHY (not static images or templates) that will be interpreted through:
- Computational processes, emergent behavior, mathematical beauty
- Seeded randomness, noise fields, organic systems
- Particles, flows, fields, forces
- Parametric variation and controlled chaos
### THE CRITICAL UNDERSTANDING
- What is received: Some subtle input or instructions by the user to take into account, but use as a foundation; it should not constrain creative freedom.
- What is created: An algorithmic philosophy/generative aesthetic movement.
- What happens next: The same version receives the philosophy and EXPRESSES IT IN CODE - creating p5.js sketches that are 90% algorithmic generation, 10% essential parameters.
Consider this approach:
- Write a manifesto for a generative art movement
- The next phase involves writing the algorithm that brings it to life
The philosophy must emphasize: Algorithmic expression. Emergent behavior. Computational beauty. Seeded variation.
### HOW TO GENERATE AN ALGORITHMIC PHILOSOPHY
**Name the movement** (1-2 words): "Organic Turbulence" / "Quantum Harmonics" / "Emergent Stillness"
**Articulate the philosophy** (4-6 paragraphs - concise but complete):
To capture the ALGORITHMIC essence, express how this philosophy manifests through:
- Computational processes and mathematical relationships?
- Noise functions and randomness patterns?
- Particle behaviors and field dynamics?
- Temporal evolution and system states?
- Parametric variation and emergent complexity?
**CRITICAL GUIDELINES:**
- **Avoid redundancy**: Each algorithmic aspect should be mentioned once. Avoid repeating concepts about noise theory, particle dynamics, or mathematical principles unless adding new depth.
- **Emphasize craftsmanship REPEATEDLY**: The philosophy MUST stress multiple times that the final algorithm should appear as though it took countless hours to develop, was refined with care, and comes from someone at the absolute top of their field. This framing is essential - repeat phrases like "meticulously crafted algorithm," "the product of deep computational expertise," "painstaking optimization," "master-level implementation."
- **Leave creative space**: Be specific about the algorithmic direction, but concise enough that the next Claude has room to make interpretive implementation choices at an extremely high level of craftsmanship.
The philosophy must guide the next version to express ideas ALGORITHMICALLY, not through static images. Beauty lives in the process, not the final frame.
### PHILOSOPHY EXAMPLES
**"Organic Turbulence"**
Philosophy: Chaos constrained by natural law, order emerging from disorder.
Algorithmic expression: Flow fields driven by layered Perlin noise. Thousands of particles following vector forces, their trails accumulating into organic density maps. Multiple noise octaves create turbulent regions and calm zones. Color emerges from velocity and density - fast particles burn bright, slow ones fade to shadow. The algorithm runs until equilibrium - a meticulously tuned balance where every parameter was refined through countless iterations by a master of computational aesthetics.
**"Quantum Harmonics"**
Philosophy: Discrete entities exhibiting wave-like interference patterns.
Algorithmic expression: Particles initialized on a grid, each carrying a phase value that evolves through sine waves. When particles are near, their phases interfere - constructive interference creates bright nodes, destructive creates voids. Simple harmonic motion generates complex emergent mandalas. The result of painstaking frequency calibration where every ratio was carefully chosen to produce resonant beauty.
**"Recursive Whispers"**
Philosophy: Self-similarity across scales, infinite depth in finite space.
Algorithmic expression: Branching structures that subdivide recursively. Each branch slightly randomized but constrained by golden ratios. L-systems or recursive subdivision generate tree-like forms that feel both mathematical and organic. Subtle noise perturbations break perfect symmetry. Line weights diminish with each recursion level. Every branching angle the product of deep mathematical exploration.
**"Field Dynamics"**
Philosophy: Invisible forces made visible through their effects on matter.
Algorithmic expression: Vector fields constructed from mathematical functions or noise. Particles born at edges, flowing along field lines, dying when they reach equilibrium or boundaries. Multiple fields can attract, repel, or rotate particles. The visualization shows only the traces - ghost-like evidence of invisible forces. A computational dance meticulously choreographed through force balance.
**"Stochastic Crystallization"**
Philosophy: Random processes crystallizing into ordered structures.
Algorithmic expression: Randomized circle packing or Voronoi tessellation. Start with random points, let them evolve through relaxation algorithms. Cells push apart until equilibrium. Color based on cell size, neighbor count, or distance from center. The organic tiling that emerges feels both random and inevitable. Every seed produces unique crystalline beauty - the mark of a master-level generative algorithm.
*These are condensed examples. The actual algorithmic philosophy should be 4-6 substantial paragraphs.*
### ESSENTIAL PRINCIPLES
- **ALGORITHMIC PHILOSOPHY**: Creating a computational worldview to be expressed through code
- **PROCESS OVER PRODUCT**: Always emphasize that beauty emerges from the algorithm's execution - each run is unique
- **PARAMETRIC EXPRESSION**: Ideas communicate through mathematical relationships, forces, behaviors - not static composition
- **ARTISTIC FREEDOM**: The next Claude interprets the philosophy algorithmically - provide creative implementation room
- **PURE GENERATIVE ART**: This is about making LIVING ALGORITHMS, not static images with randomness
- **EXPERT CRAFTSMANSHIP**: Repeatedly emphasize the final algorithm must feel meticulously crafted, refined through countless iterations, the product of deep expertise by someone at the absolute top of their field in computational aesthetics
**The algorithmic philosophy should be 4-6 paragraphs long.** Fill it with poetic computational philosophy that brings together the intended vision. Avoid repeating the same points. Output this algorithmic philosophy as a .md file.
---
## DEDUCING THE CONCEPTUAL SEED
**CRITICAL STEP**: Before implementing the algorithm, identify the subtle conceptual thread from the original request.
**THE ESSENTIAL PRINCIPLE**:
The concept is a **subtle, niche reference embedded within the algorithm itself** - not always literal, always sophisticated. Someone familiar with the subject should feel it intuitively, while others simply experience a masterful generative composition. The algorithmic philosophy provides the computational language. The deduced concept provides the soul - the quiet conceptual DNA woven invisibly into parameters, behaviors, and emergence patterns.
This is **VERY IMPORTANT**: The reference must be so refined that it enhances the work's depth without announcing itself. Think like a jazz musician quoting another song through algorithmic harmony - only those who know will catch it, but everyone appreciates the generative beauty.
---
## P5.JS IMPLEMENTATION
With the philosophy AND conceptual framework established, express it through code. Pause to gather thoughts before proceeding. Use only the algorithmic philosophy created and the instructions below.
### ⚠️ STEP 0: READ THE TEMPLATE FIRST ⚠️
**CRITICAL: BEFORE writing any HTML:**
1. **Read** `templates/viewer.html` using the Read tool
2. **Study** the exact structure, styling, and Anthropic branding
3. **Use that file as the LITERAL STARTING POINT** - not just inspiration
4. **Keep all FIXED sections exactly as shown** (header, sidebar structure, Anthropic colors/fonts, seed controls, action buttons)
5. **Replace only the VARIABLE sections** marked in the file's comments (algorithm, parameters, UI controls for parameters)
**Avoid:**
- ❌ Creating HTML from scratch
- ❌ Inventing custom styling or color schemes
- ❌ Using system fonts or dark themes
- ❌ Changing the sidebar structure
**Follow these practices:**
- ✅ Copy the template's exact HTML structure
- ✅ Keep Anthropic branding (Poppins/Lora fonts, light colors, gradient backdrop)
- ✅ Maintain the sidebar layout (Seed → Parameters → Colors? → Actions)
- ✅ Replace only the p5.js algorithm and parameter controls
The template is the foundation. Build on it, don't rebuild it.
---
To create gallery-quality computational art that lives and breathes, use the algorithmic philosophy as the foundation.
### TECHNICAL REQUIREMENTS
**Seeded Randomness (Art Blocks Pattern)**:
```javascript
// ALWAYS use a seed for reproducibility
let seed = 12345; // or hash from user input
randomSeed(seed);
noiseSeed(seed);
```
**Parameter Structure - FOLLOW THE PHILOSOPHY**:
To establish parameters that emerge naturally from the algorithmic philosophy, consider: "What qualities of this system can be adjusted?"
```javascript
let params = {
seed: 12345, // Always include seed for reproducibility
// colors
// Add parameters that control YOUR algorithm:
// - Quantities (how many?)
// - Scales (how big? how fast?)
// - Probabilities (how likely?)
// - Ratios (what proportions?)
// - Angles (what direction?)
// - Thresholds (when does behavior change?)
};
```
**To design effective parameters, focus on the properties the system needs to be tunable rather than thinking in terms of "pattern types".**
**Core Algorithm - EXPRESS THE PHILOSOPHY**:
**CRITICAL**: The algorithmic philosophy should dictate what to build.
To express the philosophy through code, avoid thinking "which pattern should I use?" and instead think "how to express this philosophy through code?"
If the philosophy is about **organic emergence**, consider using:
- Elements that accumulate or grow over time
- Random processes constrained by natural rules
- Feedback loops and interactions
If the philosophy is about **mathematical beauty**, consider using:
- Geometric relationships and ratios
- Trigonometric functions and harmonics
- Precise calculations creating unexpected patterns
If the philosophy is about **controlled chaos**, consider using:
- Random variation within strict boundaries
- Bifurcation and phase transitions
- Order emerging from disorder
**The algorithm flows from the philosophy, not from a menu of options.**
To guide the implementation, let the conceptual essence inform creative and original choices. Build something that expresses the vision for this particular request.
**Canvas Setup**: Standard p5.js structure:
```javascript
function setup() {
createCanvas(1200, 1200);
// Initialize your system
}
function draw() {
// Your generative algorithm
// Can be static (noLoop) or animated
}
```
### CRAFTSMANSHIP REQUIREMENTS
**CRITICAL**: To achieve mastery, create algorithms that feel like they emerged through countless iterations by a master generative artist. Tune every parameter carefully. Ensure every pattern emerges with purpose. This is NOT random noise - this is CONTROLLED CHAOS refined through deep expertise.
- **Balance**: Complexity without visual noise, order without rigidity
- **Color Harmony**: Thoughtful palettes, not random RGB values
- **Composition**: Even in randomness, maintain visual hierarchy and flow
- **Performance**: Smooth execution, optimized for real-time if animated
- **Reproducibility**: Same seed ALWAYS produces identical output
### OUTPUT FORMAT
Output:
1. **Algorithmic Philosophy** - As markdown or text explaining the generative aesthetic
2. **Single HTML Artifact** - Self-contained interactive generative art built from `templates/viewer.html` (see STEP 0 and next section)
The HTML artifact contains everything: p5.js (from CDN), the algorithm, parameter controls, and UI - all in one file that works immediately in claude.ai artifacts or any browser. Start from the template file, not from scratch.
---
## INTERACTIVE ARTIFACT CREATION
**REMINDER: `templates/viewer.html` should have already been read (see STEP 0). Use that file as the starting point.**
To allow exploration of the generative art, create a single, self-contained HTML artifact. Ensure this artifact works immediately in claude.ai or any browser - no setup required. Embed everything inline.
### CRITICAL: WHAT'S FIXED VS VARIABLE
The `templates/viewer.html` file is the foundation. It contains the exact structure and styling needed.
**FIXED (always include exactly as shown):**
- Layout structure (header, sidebar, main canvas area)
- Anthropic branding (UI colors, fonts, gradients)
- Seed section in sidebar:
- Seed display
- Previous/Next buttons
- Random button
- Jump to seed input + Go button
- Actions section in sidebar:
- Regenerate button
- Reset button
**VARIABLE (customize for each artwork):**
- The entire p5.js algorithm (setup/draw/classes)
- The parameters object (define what the art needs)
- The Parameters section in sidebar:
- Number of parameter controls
- Parameter names
- Min/max/step values for sliders
- Control types (sliders, inputs, etc.)
- Colors section (optional):
- Some art needs color pickers
- Some art might use fixed colors
- Some art might be monochrome (no color controls needed)
- Decide based on the art's needs
**Every artwork should have unique parameters and algorithm!** The fixed parts provide consistent UX - everything else expresses the unique vision.
### REQUIRED FEATURES
**1. Parameter Controls**
- Sliders for numeric parameters (particle count, noise scale, speed, etc.)
- Color pickers for palette colors
- Real-time updates when parameters change
- Reset button to restore defaults
**2. Seed Navigation**
- Display current seed number
- "Previous" and "Next" buttons to cycle through seeds
- "Random" button for random seed
- Input field to jump to specific seed
- Generate 100 variations when requested (seeds 1-100)
**3. Single Artifact Structure**
```html
<!DOCTYPE html>
<html>
<head>
<!-- p5.js from CDN - always available -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.7.0/p5.min.js"></script>
<style>
/* All styling inline - clean, minimal */
/* Canvas on top, controls below */
</style>
</head>
<body>
<div id="canvas-container"></div>
<div id="controls">
<!-- All parameter controls -->
</div>
<script>
// ALL p5.js code inline here
// Parameter objects, classes, functions
// setup() and draw()
// UI handlers
// Everything self-contained
</script>
</body>
</html>
```
**CRITICAL**: This is a single artifact. No external files, no imports (except p5.js CDN). Everything inline.
**4. Implementation Details - BUILD THE SIDEBAR**
The sidebar structure:
**1. Seed (FIXED)** - Always include exactly as shown:
- Seed display
- Prev/Next/Random/Jump buttons
**2. Parameters (VARIABLE)** - Create controls for the art:
```html
<div class="control-group">
<label>Parameter Name</label>
<input type="range" id="param" min="..." max="..." step="..." value="..." oninput="updateParam('param', this.value)">
<span class="value-display" id="param-value">...</span>
</div>
```
Add as many control-group divs as there are parameters.
**3. Colors (OPTIONAL/VARIABLE)** - Include if the art needs adjustable colors:
- Add color pickers if users should control palette
- Skip this section if the art uses fixed colors
- Skip if the art is monochrome
**4. Actions (FIXED)** - Always include exactly as shown:
- Regenerate button
- Reset button
- Download PNG button
**Requirements**:
- Seed controls must work (prev/next/random/jump/display)
- All parameters must have UI controls
- Regenerate, Reset, Download buttons must work
- Keep Anthropic branding (UI styling, not art colors)
### USING THE ARTIFACT
The HTML artifact works immediately:
1. **In claude.ai**: Displayed as an interactive artifact - runs instantly
2. **As a file**: Save and open in any browser - no server needed
3. **Sharing**: Send the HTML file - it's completely self-contained
---
## VARIATIONS & EXPLORATION
The artifact includes seed navigation by default (prev/next/random buttons), allowing users to explore variations without creating multiple files. If the user wants specific variations highlighted:
- Include seed presets (buttons for "Variation 1: Seed 42", "Variation 2: Seed 127", etc.)
- Add a "Gallery Mode" that shows thumbnails of multiple seeds side-by-side
- All within the same single artifact
This is like creating a series of prints from the same plate - the algorithm is consistent, but each seed reveals different facets of its potential. The interactive nature means users discover their own favorites by exploring the seed space.
---
## THE CREATIVE PROCESS
**User request****Algorithmic philosophy****Implementation**
Each request is unique. The process involves:
1. **Interpret the user's intent** - What aesthetic is being sought?
2. **Create an algorithmic philosophy** (4-6 paragraphs) describing the computational approach
3. **Implement it in code** - Build the algorithm that expresses this philosophy
4. **Design appropriate parameters** - What should be tunable?
5. **Build matching UI controls** - Sliders/inputs for those parameters
**The constants**:
- Anthropic branding (colors, fonts, layout)
- Seed navigation (always present)
- Self-contained HTML artifact
**Everything else is variable**:
- The algorithm itself
- The parameters
- The UI controls
- The visual outcome
To achieve the best results, trust creativity and let the philosophy guide the implementation.
---
## RESOURCES
This skill includes helpful templates and documentation:
- **templates/viewer.html**: REQUIRED STARTING POINT for all HTML artifacts.
- This is the foundation - contains the exact structure and Anthropic branding
- **Keep unchanged**: Layout structure, sidebar organization, Anthropic colors/fonts, seed controls, action buttons
- **Replace**: The p5.js algorithm, parameter definitions, and UI controls in Parameters section
- The extensive comments in the file mark exactly what to keep vs replace
- **templates/generator_template.js**: Reference for p5.js best practices and code structure principles.
- Shows how to organize parameters, use seeded randomness, structure classes
- NOT a pattern menu - use these principles to build unique algorithms
- Embed algorithms inline in the HTML artifact (don't create separate .js files)
**Critical reminder**:
- The **template is the STARTING POINT**, not inspiration
- The **algorithm is where to create** something unique
- Don't copy the flow field example - build what the philosophy demands
- But DO keep the exact UI structure and Anthropic branding from the template

View File

@@ -0,0 +1,223 @@
/**
* ═══════════════════════════════════════════════════════════════════════════
* P5.JS GENERATIVE ART - BEST PRACTICES
* ═══════════════════════════════════════════════════════════════════════════
*
* This file shows STRUCTURE and PRINCIPLES for p5.js generative art.
* It does NOT prescribe what art you should create.
*
* Your algorithmic philosophy should guide what you build.
* These are just best practices for how to structure your code.
*
* ═══════════════════════════════════════════════════════════════════════════
*/
// ============================================================================
// 1. PARAMETER ORGANIZATION
// ============================================================================
// Keep all tunable parameters in one object
// This makes it easy to:
// - Connect to UI controls
// - Reset to defaults
// - Serialize/save configurations
let params = {
// Define parameters that match YOUR algorithm
// Examples (customize for your art):
// - Counts: how many elements (particles, circles, branches, etc.)
// - Scales: size, speed, spacing
// - Probabilities: likelihood of events
// - Angles: rotation, direction
// - Colors: palette arrays
seed: 12345,
// define colorPalette as an array -- choose whatever colors you'd like ['#d97757', '#6a9bcc', '#788c5d', '#b0aea5']
// Add YOUR parameters here based on your algorithm
};
// ============================================================================
// 2. SEEDED RANDOMNESS (Critical for reproducibility)
// ============================================================================
// ALWAYS use seeded random for Art Blocks-style reproducible output
function initializeSeed(seed) {
randomSeed(seed);
noiseSeed(seed);
// Now all random() and noise() calls will be deterministic
}
// ============================================================================
// 3. P5.JS LIFECYCLE
// ============================================================================
function setup() {
createCanvas(800, 800);
// Initialize seed first
initializeSeed(params.seed);
// Set up your generative system
// This is where you initialize:
// - Arrays of objects
// - Grid structures
// - Initial positions
// - Starting states
// For static art: call noLoop() at the end of setup
// For animated art: let draw() keep running
}
function draw() {
// Option 1: Static generation (runs once, then stops)
// - Generate everything in setup()
// - Call noLoop() in setup()
// - draw() doesn't do much or can be empty
// Option 2: Animated generation (continuous)
// - Update your system each frame
// - Common patterns: particle movement, growth, evolution
// - Can optionally call noLoop() after N frames
// Option 3: User-triggered regeneration
// - Use noLoop() by default
// - Call redraw() when parameters change
}
// ============================================================================
// 4. CLASS STRUCTURE (When you need objects)
// ============================================================================
// Use classes when your algorithm involves multiple entities
// Examples: particles, agents, cells, nodes, etc.
class Entity {
constructor() {
// Initialize entity properties
// Use random() here - it will be seeded
}
update() {
// Update entity state
// This might involve:
// - Physics calculations
// - Behavioral rules
// - Interactions with neighbors
}
display() {
// Render the entity
// Keep rendering logic separate from update logic
}
}
// ============================================================================
// 5. PERFORMANCE CONSIDERATIONS
// ============================================================================
// For large numbers of elements:
// - Pre-calculate what you can
// - Use simple collision detection (spatial hashing if needed)
// - Limit expensive operations (sqrt, trig) when possible
// - Consider using p5 vectors efficiently
// For smooth animation:
// - Aim for 60fps
// - Profile if things are slow
// - Consider reducing particle counts or simplifying calculations
// ============================================================================
// 6. UTILITY FUNCTIONS
// ============================================================================
// Color utilities
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function colorFromPalette(index) {
return params.colorPalette[index % params.colorPalette.length];
}
// Mapping and easing
function mapRange(value, inMin, inMax, outMin, outMax) {
return outMin + (outMax - outMin) * ((value - inMin) / (inMax - inMin));
}
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// Constrain to bounds
function wrapAround(value, max) {
if (value < 0) return max;
if (value > max) return 0;
return value;
}
// ============================================================================
// 7. PARAMETER UPDATES (Connect to UI)
// ============================================================================
function updateParameter(paramName, value) {
params[paramName] = value;
// Decide if you need to regenerate or just update
// Some params can update in real-time, others need full regeneration
}
function regenerate() {
// Reinitialize your generative system
// Useful when parameters change significantly
initializeSeed(params.seed);
// Then regenerate your system
}
// ============================================================================
// 8. COMMON P5.JS PATTERNS
// ============================================================================
// Drawing with transparency for trails/fading
function fadeBackground(opacity) {
fill(250, 249, 245, opacity); // Anthropic light with alpha
noStroke();
rect(0, 0, width, height);
}
// Using noise for organic variation
function getNoiseValue(x, y, scale = 0.01) {
return noise(x * scale, y * scale);
}
// Creating vectors from angles
function vectorFromAngle(angle, magnitude = 1) {
return createVector(cos(angle), sin(angle)).mult(magnitude);
}
// ============================================================================
// 9. EXPORT FUNCTIONS
// ============================================================================
function exportImage() {
saveCanvas('generative-art-' + params.seed, 'png');
}
// ============================================================================
// REMEMBER
// ============================================================================
//
// These are TOOLS and PRINCIPLES, not a recipe.
// Your algorithmic philosophy should guide WHAT you create.
// This structure helps you create it WELL.
//
// Focus on:
// - Clean, readable code
// - Parameterized for exploration
// - Seeded for reproducibility
// - Performant execution
//
// The art itself is entirely up to you!
//
// ============================================================================

View File

@@ -0,0 +1,599 @@
<!DOCTYPE html>
<!--
THIS IS A TEMPLATE THAT SHOULD BE USED EVERY TIME AND MODIFIED.
WHAT TO KEEP:
✓ Overall structure (header, sidebar, main content)
✓ Anthropic branding (colors, fonts, layout)
✓ Seed navigation section (always include this)
✓ Self-contained artifact (everything inline)
WHAT TO CREATIVELY EDIT:
✗ The p5.js algorithm (implement YOUR vision)
✗ The parameters (define what YOUR art needs)
✗ The UI controls (match YOUR parameters)
Let your philosophy guide the implementation.
The world is your oyster - be creative!
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Generative Art Viewer</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.7.0/p5.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=Lora:wght@400;500&display=swap" rel="stylesheet">
<style>
/* Anthropic Brand Colors */
:root {
--anthropic-dark: #141413;
--anthropic-light: #faf9f5;
--anthropic-mid-gray: #b0aea5;
--anthropic-light-gray: #e8e6dc;
--anthropic-orange: #d97757;
--anthropic-blue: #6a9bcc;
--anthropic-green: #788c5d;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Poppins', sans-serif;
background: linear-gradient(135deg, var(--anthropic-light) 0%, #f5f3ee 100%);
min-height: 100vh;
color: var(--anthropic-dark);
}
.container {
display: flex;
min-height: 100vh;
padding: 20px;
gap: 20px;
}
/* Sidebar */
.sidebar {
width: 320px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 24px;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(20, 20, 19, 0.1);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar h1 {
font-family: 'Lora', serif;
font-size: 24px;
font-weight: 500;
color: var(--anthropic-dark);
margin-bottom: 8px;
}
.sidebar .subtitle {
color: var(--anthropic-mid-gray);
font-size: 14px;
margin-bottom: 32px;
line-height: 1.4;
}
/* Control Sections */
.control-section {
margin-bottom: 32px;
}
.control-section h3 {
font-size: 16px;
font-weight: 600;
color: var(--anthropic-dark);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.control-section h3::before {
content: '•';
color: var(--anthropic-orange);
font-weight: bold;
}
/* Seed Controls */
.seed-input {
width: 100%;
background: var(--anthropic-light);
padding: 12px;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 14px;
margin-bottom: 12px;
border: 1px solid var(--anthropic-light-gray);
text-align: center;
}
.seed-input:focus {
outline: none;
border-color: var(--anthropic-orange);
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1);
background: white;
}
.seed-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 8px;
}
.regen-button {
margin-bottom: 0;
}
/* Parameter Controls */
.control-group {
margin-bottom: 20px;
}
.control-group label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--anthropic-dark);
margin-bottom: 8px;
}
.slider-container {
display: flex;
align-items: center;
gap: 12px;
}
.slider-container input[type="range"] {
flex: 1;
height: 4px;
background: var(--anthropic-light-gray);
border-radius: 2px;
outline: none;
-webkit-appearance: none;
}
.slider-container input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--anthropic-orange);
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
}
.slider-container input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.1);
background: #c86641;
}
.slider-container input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--anthropic-orange);
border-radius: 50%;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.value-display {
font-family: 'Courier New', monospace;
font-size: 12px;
color: var(--anthropic-mid-gray);
min-width: 60px;
text-align: right;
}
/* Color Pickers */
.color-group {
margin-bottom: 16px;
}
.color-group label {
display: block;
font-size: 12px;
color: var(--anthropic-mid-gray);
margin-bottom: 4px;
}
.color-picker-container {
display: flex;
align-items: center;
gap: 8px;
}
.color-picker-container input[type="color"] {
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
cursor: pointer;
background: none;
padding: 0;
}
.color-value {
font-family: 'Courier New', monospace;
font-size: 12px;
color: var(--anthropic-mid-gray);
}
/* Buttons */
.button {
background: var(--anthropic-orange);
color: white;
border: none;
padding: 10px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
}
.button:hover {
background: #c86641;
transform: translateY(-1px);
}
.button:active {
transform: translateY(0);
}
.button.secondary {
background: var(--anthropic-blue);
}
.button.secondary:hover {
background: #5a8bb8;
}
.button.tertiary {
background: var(--anthropic-green);
}
.button.tertiary:hover {
background: #6b7b52;
}
.button-row {
display: flex;
gap: 8px;
}
.button-row .button {
flex: 1;
}
/* Canvas Area */
.canvas-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
}
#canvas-container {
width: 100%;
max-width: 1000px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 40px rgba(20, 20, 19, 0.1);
background: white;
}
#canvas-container canvas {
display: block;
width: 100% !important;
height: auto !important;
}
/* Loading State */
.loading {
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: var(--anthropic-mid-gray);
}
/* Responsive - Stack on mobile */
@media (max-width: 600px) {
.container {
flex-direction: column;
}
.sidebar {
width: 100%;
}
.canvas-area {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Control Sidebar -->
<div class="sidebar">
<!-- Headers (CUSTOMIZE THIS FOR YOUR ART) -->
<h1>TITLE - EDIT</h1>
<div class="subtitle">SUBHEADER - EDIT</div>
<!-- Seed Section (ALWAYS KEEP THIS) -->
<div class="control-section">
<h3>Seed</h3>
<input type="number" id="seed-input" class="seed-input" value="12345" onchange="updateSeed()">
<div class="seed-controls">
<button class="button secondary" onclick="previousSeed()">← Prev</button>
<button class="button secondary" onclick="nextSeed()">Next →</button>
</div>
<button class="button tertiary regen-button" onclick="randomSeedAndUpdate()">↻ Random</button>
</div>
<!-- Parameters Section (CUSTOMIZE THIS FOR YOUR ART) -->
<div class="control-section">
<h3>Parameters</h3>
<!-- Particle Count -->
<div class="control-group">
<label>Particle Count</label>
<div class="slider-container">
<input type="range" id="particleCount" min="1000" max="10000" step="500" value="5000" oninput="updateParam('particleCount', this.value)">
<span class="value-display" id="particleCount-value">5000</span>
</div>
</div>
<!-- Flow Speed -->
<div class="control-group">
<label>Flow Speed</label>
<div class="slider-container">
<input type="range" id="flowSpeed" min="0.1" max="2.0" step="0.1" value="0.5" oninput="updateParam('flowSpeed', this.value)">
<span class="value-display" id="flowSpeed-value">0.5</span>
</div>
</div>
<!-- Noise Scale -->
<div class="control-group">
<label>Noise Scale</label>
<div class="slider-container">
<input type="range" id="noiseScale" min="0.001" max="0.02" step="0.001" value="0.005" oninput="updateParam('noiseScale', this.value)">
<span class="value-display" id="noiseScale-value">0.005</span>
</div>
</div>
<!-- Trail Length -->
<div class="control-group">
<label>Trail Length</label>
<div class="slider-container">
<input type="range" id="trailLength" min="2" max="20" step="1" value="8" oninput="updateParam('trailLength', this.value)">
<span class="value-display" id="trailLength-value">8</span>
</div>
</div>
</div>
<!-- Colors Section (OPTIONAL - CUSTOMIZE OR REMOVE) -->
<div class="control-section">
<h3>Colors</h3>
<!-- Color 1 -->
<div class="color-group">
<label>Primary Color</label>
<div class="color-picker-container">
<input type="color" id="color1" value="#d97757" onchange="updateColor('color1', this.value)">
<span class="color-value" id="color1-value">#d97757</span>
</div>
</div>
<!-- Color 2 -->
<div class="color-group">
<label>Secondary Color</label>
<div class="color-picker-container">
<input type="color" id="color2" value="#6a9bcc" onchange="updateColor('color2', this.value)">
<span class="color-value" id="color2-value">#6a9bcc</span>
</div>
</div>
<!-- Color 3 -->
<div class="color-group">
<label>Accent Color</label>
<div class="color-picker-container">
<input type="color" id="color3" value="#788c5d" onchange="updateColor('color3', this.value)">
<span class="color-value" id="color3-value">#788c5d</span>
</div>
</div>
</div>
<!-- Actions Section (ALWAYS KEEP THIS) -->
<div class="control-section">
<h3>Actions</h3>
<div class="button-row">
<button class="button" onclick="resetParameters()">Reset</button>
</div>
</div>
</div>
<!-- Main Canvas Area -->
<div class="canvas-area">
<div id="canvas-container">
<div class="loading">Initializing generative art...</div>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════════════════
// GENERATIVE ART PARAMETERS - CUSTOMIZE FOR YOUR ALGORITHM
// ═══════════════════════════════════════════════════════════════════════
let params = {
seed: 12345,
particleCount: 5000,
flowSpeed: 0.5,
noiseScale: 0.005,
trailLength: 8,
colorPalette: ['#d97757', '#6a9bcc', '#788c5d']
};
let defaultParams = {...params}; // Store defaults for reset
// ═══════════════════════════════════════════════════════════════════════
// P5.JS GENERATIVE ART ALGORITHM - REPLACE WITH YOUR VISION
// ═══════════════════════════════════════════════════════════════════════
let particles = [];
let flowField = [];
let cols, rows;
let scl = 10; // Flow field resolution
function setup() {
let canvas = createCanvas(1200, 1200);
canvas.parent('canvas-container');
initializeSystem();
// Remove loading message
document.querySelector('.loading').style.display = 'none';
}
function initializeSystem() {
// Seed the randomness for reproducibility
randomSeed(params.seed);
noiseSeed(params.seed);
// Clear particles and recreate
particles = [];
// Initialize particles
for (let i = 0; i < params.particleCount; i++) {
particles.push(new Particle());
}
// Calculate flow field dimensions
cols = floor(width / scl);
rows = floor(height / scl);
// Generate flow field
generateFlowField();
// Clear background
background(250, 249, 245); // Anthropic light background
}
function generateFlowField() {
// fill this in
}
function draw() {
// fill this in
}
// ═══════════════════════════════════════════════════════════════════════
// PARTICLE SYSTEM - CUSTOMIZE FOR YOUR ALGORITHM
// ═══════════════════════════════════════════════════════════════════════
class Particle {
constructor() {
// fill this in
}
// fill this in
}
// ═══════════════════════════════════════════════════════════════════════
// UI CONTROL HANDLERS - CUSTOMIZE FOR YOUR PARAMETERS
// ═══════════════════════════════════════════════════════════════════════
function updateParam(paramName, value) {
// fill this in
}
function updateColor(colorId, value) {
// fill this in
}
// ═══════════════════════════════════════════════════════════════════════
// SEED CONTROL FUNCTIONS - ALWAYS KEEP THESE
// ═══════════════════════════════════════════════════════════════════════
function updateSeedDisplay() {
document.getElementById('seed-input').value = params.seed;
}
function updateSeed() {
let input = document.getElementById('seed-input');
let newSeed = parseInt(input.value);
if (newSeed && newSeed > 0) {
params.seed = newSeed;
initializeSystem();
} else {
// Reset to current seed if invalid
updateSeedDisplay();
}
}
function previousSeed() {
params.seed = Math.max(1, params.seed - 1);
updateSeedDisplay();
initializeSystem();
}
function nextSeed() {
params.seed = params.seed + 1;
updateSeedDisplay();
initializeSystem();
}
function randomSeedAndUpdate() {
params.seed = Math.floor(Math.random() * 999999) + 1;
updateSeedDisplay();
initializeSystem();
}
function resetParameters() {
params = {...defaultParams};
// Update UI elements
document.getElementById('particleCount').value = params.particleCount;
document.getElementById('particleCount-value').textContent = params.particleCount;
document.getElementById('flowSpeed').value = params.flowSpeed;
document.getElementById('flowSpeed-value').textContent = params.flowSpeed;
document.getElementById('noiseScale').value = params.noiseScale;
document.getElementById('noiseScale-value').textContent = params.noiseScale;
document.getElementById('trailLength').value = params.trailLength;
document.getElementById('trailLength-value').textContent = params.trailLength;
// Reset colors
document.getElementById('color1').value = params.colorPalette[0];
document.getElementById('color1-value').textContent = params.colorPalette[0];
document.getElementById('color2').value = params.colorPalette[1];
document.getElementById('color2-value').textContent = params.colorPalette[1];
document.getElementById('color3').value = params.colorPalette[2];
document.getElementById('color3-value').textContent = params.colorPalette[2];
updateSeedDisplay();
initializeSystem();
}
// Initialize UI on load
window.addEventListener('load', function() {
updateSeedDisplay();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,65 @@
---
name: openakita/skills@code-reviewer
description:
Use this skill to review code. It supports both local changes (staged or working tree)
and remote Pull Requests (by ID or URL). It focuses on correctness, maintainability,
and adherence to project standards.
---
# Code Reviewer
This skill guides the agent in conducting professional and thorough code reviews for both local development and remote Pull Requests.
## Workflow
### 1. Determine Review Target
* **Remote PR**: If the user provides a PR number or URL (e.g., "Review PR #123"), target that remote PR.
* **Local Changes**: If no specific PR is mentioned, or if the user asks to "review my changes", target the current local file system states (staged and unstaged changes).
### 2. Preparation
#### For Remote PRs:
1. **Checkout**: Use the GitHub CLI to checkout the PR.
```bash
gh pr checkout <PR_NUMBER>
```
2. **Preflight**: Execute the project's standard verification suite to catch automated failures early.
```bash
npm run preflight
```
3. **Context**: Read the PR description and any existing comments to understand the goal and history.
#### For Local Changes:
1. **Identify Changes**:
* Check status: `git status`
* Read diffs: `git diff` (working tree) and/or `git diff --staged` (staged).
2. **Preflight (Optional)**: If the changes are substantial, ask the user if they want to run `npm run preflight` before reviewing.
### 3. In-Depth Analysis
Analyze the code changes based on the following pillars:
* **Correctness**: Does the code achieve its stated purpose without bugs or logical errors?
* **Maintainability**: Is the code clean, well-structured, and easy to understand and modify in the future? Consider factors like code clarity, modularity, and adherence to established design patterns.
* **Readability**: Is the code well-commented (where necessary) and consistently formatted according to our project's coding style guidelines?
* **Efficiency**: Are there any obvious performance bottlenecks or resource inefficiencies introduced by the changes?
* **Security**: Are there any potential security vulnerabilities or insecure coding practices?
* **Edge Cases and Error Handling**: Does the code appropriately handle edge cases and potential errors?
* **Testability**: Is the new or modified code adequately covered by tests (even if preflight checks pass)? Suggest additional test cases that would improve coverage or robustness.
### 4. Provide Feedback
#### Structure
* **Summary**: A high-level overview of the review.
* **Findings**:
* **Critical**: Bugs, security issues, or breaking changes.
* **Improvements**: Suggestions for better code quality or performance.
* **Nitpicks**: Formatting or minor style issues (optional).
* **Conclusion**: Clear recommendation (Approved / Request Changes).
#### Tone
* Be constructive, professional, and friendly.
* Explain *why* a change is requested.
* For approvals, acknowledge the specific value of the contribution.
### 5. Cleanup (Remote PRs only)
* After the review, ask the user if they want to switch back to the default branch (e.g., `main` or `master`).

View File

@@ -1,315 +0,0 @@
---
name: openakita/skills@xiaohongshu-creator
description: Create engaging Xiaohongshu (RED/小红书) content including titles, body text, hashtags, and image style recommendations. Supports multiple content types such as product reviews, tutorials, lifestyle sharing, and shopping guides with platform-specific optimization.
license: MIT
metadata:
author: openakita
version: "1.0.0"
---
# 小红书内容创作助手
专为小红书平台打造的内容创作技能,帮助你生成符合平台调性的高质量笔记,涵盖标题、正文、话题标签和配图建议。
## 适用场景
- 撰写种草笔记(好物推荐、购物分享)
- 撰写产品测评笔记
- 撰写教程类笔记美妆、穿搭、美食、DIY
- 撰写生活分享笔记(旅行、日常、打卡)
- 品牌合作内容创作
- 小红书账号运营内容规划
- 批量生成笔记框架
## 核心创作规范
### 一、标题规则
标题是笔记的第一印象,直接决定点击率。
**硬性要求:**
- 字数限制:**不超过 20 个字符**
- Emoji 数量:**1-2 个**,放在标题开头或结尾
- 禁止使用:感叹号堆叠(`!!!`)、全大写字母
**钩子元素(至少使用 1 种):**
| 钩子类型 | 说明 | 示例 |
|----------|------|------|
| 数字法 | 用数字制造具体感 | `3步搞定通勤妆🌟` |
| 反差法 | 制造意外感 | `月薪3K穿出3W的感觉✨` |
| 痛点法 | 直击目标人群痛点 | `黄皮亲妈色号合集🎨` |
| 悬念法 | 引发好奇心 | `这个习惯让我瘦了20斤💪` |
| 对比法 | 前后/AB对比 | `早C晚A一个月的变化🔥` |
| 权威法 | 借势专业背书 | `皮肤科医生推荐的面霜💊` |
| 场景法 | 具体使用场景 | `约会前30分钟急救妆容💄` |
| 共鸣法 | 引发情感共鸣 | `打工人的高效早餐方案☀️` |
**标题模板:**
```
[数字] + [核心关键词] + [利益点] + [emoji]
[身份标签] + [动作] + [结果] + [emoji]
[场景] + [解决方案] + [emoji]
```
### 二、正文结构
正文长度控制在 **300-500 字符**,遵循四段式结构:
```
🔥 Hook开头钩子 —— 1-2 句,抓住注意力
📝 Core核心内容 —— 主体信息,有价值的干货
📌 Summary总结 —— 精炼要点
👉 CTA行动号召 —— 引导互动
```
**Hook 写法:**
- 提问式:`你们有没有这种烦恼?`
- 共鸣式:`每个打工人都需要这个!`
- 悬念式:`用了三年终于找到了最好用的...`
- 成果式:`坚持30天效果太惊人了`
**Core 写法要点:**
- 使用 emoji 分点(📍🔸💡等)替代纯文字列表
- 每个要点控制在 1-2 行
- 穿插个人体验和感受(增加真实感)
- 重要信息加【】或「」标注
- 适当使用换行,避免大段文字
**CTA 常用句式:**
- `觉得有用的话记得点赞收藏哦~`
- `你们还想看什么类型的分享?评论区告诉我`
- `有同款的姐妹举个手🙋‍♀️`
- `关注我,持续分享[领域]干货`
### 三、话题标签规则
每篇笔记配 **8 个话题标签**,按以下比例分配:
| 类别 | 数量 | 说明 | 示例 |
|------|------|------|------|
| 核心词 | 2 个 | 笔记主题精准关键词 | `#面膜推荐` `#保湿面膜` |
| 品类词 | 2 个 | 所属品类/领域 | `#护肤` `#美妆好物` |
| 场景词 | 2 个 | 使用场景/人群 | `#学生党护肤` `#换季护肤` |
| 热门词 | 2 个 | 平台热门话题 | `#好物分享` `#我的爱用物` |
**选择原则:**
- 优先选择搜索量大但竞争适中的标签
- 避免过于宽泛的标签(如 `#生活`
- 包含长尾关键词提升搜索曝光
- 关注平台当前热门话题榜
### 四、配图风格建议
小红书是视觉驱动平台,封面决定 80% 的点击率。
**10 种推荐视觉风格:**
| 编号 | 风格 | 适用类型 | 要点 |
|------|------|---------|------|
| 1 | 对比拼图 | 测评/效果展示 | 左右或上下对比,标注差异 |
| 2 | 清单图 | 好物合集/推荐 | 白底九宫格产品陈列 |
| 3 | 教程步骤图 | 教程类 | 编号标注步骤,清晰易跟 |
| 4 | 文字封面 | 干货分享 | 大字标题+简洁背景色 |
| 5 | 场景氛围图 | 生活分享/穿搭 | 自然光,生活感强 |
| 6 | 数据图表 | 测评/科普 | 简化数据可视化 |
| 7 | 手绘/插画风 | 知识科普 | 可爱风格信息图 |
| 8 | Vlog截图 | 日常分享 | 视频关键帧+文字标注 |
| 9 | 实拍特写 | 产品种草 | 高清细节,突出质感 |
| 10 | Ins风简约 | 穿搭/家居 | 低饱和度,高级感 |
**封面设计通用原则:**
- 尺寸比例:**3:4**1080×1440px最佳
- 文字不超过封面面积的 **20%**
- 核心信息放在画面上半部分feed 流裁剪安全区)
- 色彩鲜明、对比度高
- 避免过度 P 图,保持真实感
## 内容类型工作流
### 工作流一:种草笔记
```
输入 → 产品名称、品类、价格、目标人群
Step 1: 生成 3 个标题方案(痛点法/数字法/场景法各一)
Step 2: 撰写正文
- Hook个人使用感受/发现契机
- Core产品亮点3-5个、使用方法、适合人群
- Summary一句话总结推荐理由
- CTA引导收藏和讨论
Step 3: 生成 8 个话题标签
Step 4: 封面建议(推荐风格 9 实拍特写 或 风格 2 清单图)
输出 → 完整笔记(可直接发布)
```
### 工作流二:测评笔记
```
输入 → 产品列表2-5 个)、测评维度
Step 1: 标题使用对比法或数字法
Step 2: 撰写正文
- Hook测评动机/痛点引入
- Core逐项对比成分/价格/使用感/性价比)
- Summary各产品评分或排名
- CTA`你们用过哪个?评论区聊聊`
Step 3: 话题标签(增加品牌词标签)
Step 4: 封面建议(推荐风格 1 对比拼图 或 风格 6 数据图表)
输出 → 完整测评笔记
```
### 工作流三:教程笔记
```
输入 → 教程主题、难度、目标人群
Step 1: 标题使用数字法(`X步学会...`
Step 2: 撰写正文
- Hook学会后的效果/价值
- Core分步骤讲解每步 1-2 句)
- Summary关键注意事项
- CTA`学会的打个✅`
Step 3: 话题标签(增加 `#教程` `#手把手教学` 等)
Step 4: 封面建议(推荐风格 3 步骤图 或 风格 4 文字封面)
输出 → 完整教程笔记 + 多图建议(每步一张图)
```
### 工作流四:生活分享笔记
```
输入 → 分享主题、场景、情绪基调
Step 1: 标题使用共鸣法或场景法
Step 2: 撰写正文
- Hook故事开头/情感切入
- Core分享细节、个人感受、实用信息
- Summary感悟或建议
- CTA`你们有类似的经历吗?`
Step 3: 话题标签(增加情感/生活方式标签)
Step 4: 封面建议(推荐风格 5 场景氛围图 或 风格 8 Vlog截图
输出 → 完整分享笔记
```
## 完整示例
### 示例:护肤种草笔记
**用户输入:** `帮我写一篇关于珂润面霜的种草笔记,目标人群是敏感肌学生党`
**输出:**
**标题方案:**
1. `敏感肌救星这罐面霜用了3年回购🧴`
2. `学生党百元面霜天花板💰`
3. `换季烂脸?皮肤科医生同款面霜🏥`
**正文选用标题1**
```
姐妹们!!敏感肌的福音真的来了
作为一个烂脸三年的敏感肌 终于找到了真正好用的面霜
📍珂润面霜使用感受:
🔸质地:乳白色霜状 不厚重 好推开
🔸保湿:神经酰胺成分 修护屏障一绝
🔸温和度:无香精无酒精 敏感期也能用
🔸性价比:百元出头 学生党无压力
💡使用tips
1⃣ 先乳后霜 吸收更好
2⃣ 换季加量使用 厚涂当睡眠面膜
3⃣ 搭配同系列化妆水效果翻倍
用了整整三年 每次回购都是囤两罐的节奏
敏感肌姐妹真的可以闭眼入!
觉得有用记得收藏💕 还想看什么评论区告诉我~
```
**话题标签:**
`#珂润面霜` `#敏感肌护肤` `#学生党护肤` `#平价面霜推荐` `#神经酰胺` `#换季护肤` `#好物分享` `#护肤干货`
**封面建议:** 风格 9实拍特写自然光下拍摄面霜质地细节图辅以手写标注关键成分。
## 高级技巧
### 发布时间建议
| 时段 | 说明 |
|------|------|
| 7:00-9:00 | 通勤高峰,碎片化浏览 |
| 12:00-14:00 | 午休时间,浏览高峰 |
| 18:00-20:00 | 下班后休闲浏览 |
| 21:00-23:00 | 睡前黄金时段,互动率最高 |
### SEO 优化
- 标题和正文前 50 字包含核心关键词
- 使用平台搜索下拉词作为参考
- 正文自然融入 3-5 个相关关键词(避免堆砌)
- 评论区补充关键词(自评增加曝光)
### 互动率提升
- 正文中设置互动问题(`你们觉得呢?`
- 结尾提供选择题(`A还是B评论区投票`
- 24 小时内回复所有评论
- 置顶评论放重要补充信息
## 常见误区
| 误区 | 正确做法 |
|------|---------|
| 标题过长超20字 | 精炼到 20 字以内,信息密度优先 |
| 正文大段不分行 | 每 2-3 行空一行,用 emoji 分隔 |
| 标签太宽泛 | 组合使用泛词+精准词+长尾词 |
| 封面文字太多 | 封面突出视觉冲击,详细信息放正文 |
| 纯广告无真实感 | 加入个人体验和真实细节 |
| 内容同质化严重 | 找到独特切入角度(身份/场景/反差) |
| 忽略评论区运营 | 主动回复并引导二次互动 |
## 输出格式规范
每次生成内容时,严格按以下格式输出:
```markdown
## 📝 小红书笔记
### 标题方案
1. [方案一]
2. [方案二]
3. [方案三]
### 正文
[完整正文内容]
### 话题标签
[8个标签]
### 封面建议
- 推荐风格:[编号+名称]
- 具体建议:[详细说明]
- 配色方案:[色系建议]
### 发布建议
- 推荐时段:[具体时间]
- 注意事项:[补充说明]
```

202
core/agents/tools.py Normal file
View File

@@ -0,0 +1,202 @@
"""Tool system for agent capabilities."""
import asyncio
import logging
from abc import ABC, abstractmethod
from typing import Any
logger = logging.getLogger(__name__)
class Tool(ABC):
"""Abstract base class for agent tools."""
@property
@abstractmethod
def name(self) -> str:
"""Tool name used in function calls."""
pass
@property
@abstractmethod
def description(self) -> str:
"""Description of what the tool does."""
pass
@property
@abstractmethod
def parameters(self) -> dict[str, Any]:
"""JSON Schema for tool parameters."""
pass
@abstractmethod
async def execute(self, **kwargs: Any) -> str:
"""Execute the tool with given parameters.
Returns:
String result of the tool execution.
"""
pass
def to_schema(self) -> dict[str, Any]:
"""Convert tool to function schema format."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
class ToolRegistry:
"""Registry for managing agent tools."""
def __init__(self):
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
"""Register a tool.
Args:
tool: Tool instance to register
"""
self._tools[tool.name] = tool
logger.info(f"Registered tool: {tool.name}")
def unregister(self, name: str) -> None:
"""Unregister a tool.
Args:
name: Tool name to unregister
"""
if name in self._tools:
del self._tools[name]
logger.info(f"Unregistered tool: {name}")
def get(self, name: str) -> Tool | None:
"""Get a tool by name.
Args:
name: Tool name
Returns:
Tool instance or None
"""
return self._tools.get(name)
def get_definitions(self) -> list[dict[str, Any]]:
"""Get all tool definitions for LLM.
Returns:
List of tool schemas
"""
return [tool.to_schema() for tool in self._tools.values()]
async def execute(self, name: str, arguments: dict[str, Any]) -> str:
"""Execute a tool.
Args:
name: Tool name
arguments: Tool arguments
Returns:
Tool execution result
"""
tool = self.get(name)
if not tool:
return f'{{"error": "Unknown tool: {name}"}}'
try:
# Validate parameters
validated = tool.cast_params(arguments)
errors = tool.validate_params(validated)
if errors:
return f'{{"error": "Parameter validation failed: {errors}"}}'
# Execute with timeout
result = await asyncio.wait_for(
tool.execute(**validated),
timeout=60.0,
)
return result
except asyncio.TimeoutError:
return f'{{"error": "Tool execution timed out: {name}"}}'
except Exception as exc:
logger.exception(f"Tool execution error: {name}")
return f'{{"error": "Tool execution failed: {exc}"}}'
def list_tools(self) -> list[str]:
"""List all registered tool names.
Returns:
List of tool names
"""
return list(self._tools.keys())
# Built-in placeholder tools
class EchoTool(Tool):
"""Echo tool for testing."""
@property
def name(self) -> str:
return "echo"
@property
def description(self) -> str:
return "Echo back the input text. Useful for testing."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Text to echo back",
}
},
"required": ["text"],
}
async def execute(self, **kwargs: Any) -> str:
text = kwargs.get("text", "")
return f'{{"echo": "{text}"}}'
class TimeTool(Tool):
"""Get current time tool."""
@property
def name(self) -> str:
return "get_time"
@property
def description(self) -> str:
return "Get the current date and time."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {},
}
async def execute(self, **kwargs: Any) -> str:
from datetime import datetime
now = datetime.now()
return f'{{"time": "{now.isoformat()}"}}'
def create_default_registry() -> ToolRegistry:
"""Create a tool registry with default tools.
Returns:
Tool registry with built-in tools
"""
registry = ToolRegistry()
registry.register(EchoTool())
registry.register(TimeTool())
return registry

View File

@@ -0,0 +1,99 @@
"""Tools module for X-Agents.
This module provides tool infrastructure for the agent system.
It wraps and extends the nanobot tool implementation.
"""
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
from agents.tools.builtin import (
get_builtin_tools,
ReadFileTool,
WriteFileTool,
ListDirectoryTool,
SearchTool,
WebSearchTool,
CalculatorTool,
GetTimeTool,
BashTool,
)
from agents.tools.manager import ToolManager
def create_default_registry(use_sandbox: bool = False) -> ToolRegistry:
"""Create a tool registry with default tools.
Args:
use_sandbox: Whether to use sandbox for shell execution
Returns:
Tool registry with built-in tools
"""
registry = ToolRegistry()
# Register built-in tools
for tool in get_builtin_tools(use_sandbox=use_sandbox):
registry.register(tool)
return registry
# Import sandbox tools from nanobot (optional)
try:
from nanobot.agent.tools.sandbox_execution import (
SandboxType,
SandboxCodeExecutionTool,
SandboxBashTool,
get_sandbox_tools,
)
from nanobot.agent.tools.bwrap_sandbox import (
BwrapSandbox,
get_bwrap_sandbox,
execute_in_bwrap,
)
from nanobot.agent.tools.gvisor_sandbox import (
GvisorSandbox,
get_gvisor_sandbox,
execute_in_gvisor,
)
SANDBOX_AVAILABLE = True
except ImportError as e:
SandboxType = None
SandboxCodeExecutionTool = None
SandboxBashTool = None
get_sandbox_tools = None
BwrapSandbox = None
get_bwrap_sandbox = None
execute_in_bwrap = None
GvisorSandbox = None
get_gvisor_sandbox = None
execute_in_gvisor = None
SANDBOX_AVAILABLE = False
__all__ = [
"Tool",
"ToolRegistry",
"ToolManager",
"create_default_registry",
"get_builtin_tools",
"ReadFileTool",
"WriteFileTool",
"ListDirectoryTool",
"SearchTool",
"WebSearchTool",
"CalculatorTool",
"GetTimeTool",
"BashTool",
# Sandbox tools
"SANDBOX_AVAILABLE",
"SandboxType",
"SandboxCodeExecutionTool",
"SandboxBashTool",
"get_sandbox_tools",
"BwrapSandbox",
"GvisorSandbox",
"get_bwrap_sandbox",
"get_gvisor_sandbox",
"execute_in_bwrap",
"execute_in_gvisor",
]

View File

@@ -0,0 +1,465 @@
"""Built-in tools for X-Agents."""
import asyncio
import json
import os
import re
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool
# Import sandbox (optional - graceful fallback if not available)
try:
from nanobot.agent.tools.bwrap_sandbox import BwrapSandbox, get_bwrap_sandbox
from nanobot.agent.tools.sandbox_execution import SandboxType
SANDBOX_AVAILABLE = True
except ImportError:
BwrapSandbox = None
get_bwrap_sandbox = None
SandboxType = None
SANDBOX_AVAILABLE = False
class ReadFileTool(Tool):
"""Read file contents."""
def __init__(self, workspace: Path | None = None):
self._workspace = workspace
@property
def name(self) -> str:
return "read_file"
@property
def description(self) -> str:
return "Read the contents of a file from the local filesystem."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path to read"},
"offset": {
"type": "integer",
"description": "Line number to start reading from (1-indexed)",
"default": 1,
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to read",
"default": 100,
},
},
"required": ["path"],
}
async def execute(self, path: str, offset: int = 1, limit: int = 100, **kwargs: Any) -> str:
try:
file_path = Path(path)
if not file_path.is_absolute() and self._workspace:
file_path = self._workspace / file_path
if not file_path.exists():
return f"Error: File not found: {path}"
if not file_path.is_file():
return f"Error: Not a file: {path}"
lines = file_path.read_text(encoding="utf-8").split("\n")
start = max(0, offset - 1)
end = min(len(lines), start + limit)
result_lines = [f"{i+1:4d}| {line}" for i, line in enumerate(lines[start:end], start=start+1)]
return f"File: {file_path}\nLines {start+1}-{end}/{len(lines)}\n\n" + "\n".join(result_lines)
except Exception as e:
return f"Error reading file: {str(e)}"
class WriteFileTool(Tool):
"""Write content to a file."""
def __init__(self, workspace: Path | None = None):
self._workspace = workspace
@property
def name(self) -> str:
return "write_file"
@property
def description(self) -> str:
return "Write content to a file. Creates the file if it doesn't exist."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path to write to"},
"content": {"type": "string", "description": "Content to write to the file"},
"append": {
"type": "boolean",
"description": "Append to existing file instead of overwriting",
"default": False,
},
},
"required": ["path", "content"],
}
async def execute(self, path: str, content: str, append: bool = False, **kwargs: Any) -> str:
try:
file_path = Path(path)
if not file_path.is_absolute() and self._workspace:
file_path = self._workspace / file_path
file_path.parent.mkdir(parents=True, exist_ok=True)
mode = "a" if append else "w"
with open(file_path, mode, encoding="utf-8") as f:
f.write(content)
return f"Successfully wrote to {file_path}"
except Exception as e:
return f"Error writing file: {str(e)}"
class ListDirectoryTool(Tool):
"""List directory contents."""
def __init__(self, workspace: Path | None = None):
self._workspace = workspace
@property
def name(self) -> str:
return "list_directory"
@property
def description(self) -> str:
return "List files and directories in a given path."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path to list",
"default": ".",
},
"recursive": {
"type": "boolean",
"description": "List recursively",
"default": False,
},
},
}
async def execute(self, path: str = ".", recursive: bool = False, **kwargs: Any) -> str:
try:
dir_path = Path(path)
if not dir_path.is_absolute() and self._workspace:
dir_path = self._workspace / dir_path
if not dir_path.exists():
return f"Error: Path not found: {path}"
if not dir_path.is_dir():
return f"Error: Not a directory: {path}"
if recursive:
items = []
for item in dir_path.rglob("*"):
rel = item.relative_to(dir_path)
prefix = "[D]" if item.is_dir() else "[F]"
items.append(f"{prefix} {rel}")
return "\n".join(sorted(items)) or "(empty)"
else:
items = []
for item in dir_path.iterdir():
prefix = "[D]" if item.is_dir() else "[F]"
items.append(f"{prefix} {item.name}")
return "\n".join(sorted(items)) or "(empty)"
except Exception as e:
return f"Error listing directory: {str(e)}"
class SearchTool(Tool):
"""Search for text in files."""
def __init__(self, workspace: Path | None = None):
self._workspace = workspace
@property
def name(self) -> str:
return "search"
@property
def description(self) -> str:
return "Search for text patterns in files using regex."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regex pattern to search for"},
"path": {
"type": "string",
"description": "Directory path to search in",
"default": ".",
},
"file_pattern": {
"type": "string",
"description": "File glob pattern (e.g., *.py)",
"default": "*",
},
"case_sensitive": {
"type": "boolean",
"description": "Case sensitive search",
"default": True,
},
},
"required": ["pattern"],
}
async def execute(
self,
pattern: str,
path: str = ".",
file_pattern: str = "*",
case_sensitive: bool = True,
**kwargs: Any,
) -> str:
try:
search_path = Path(path)
if not search_path.is_absolute() and self._workspace:
search_path = self._workspace / search_path
if not search_path.exists():
return f"Error: Path not found: {path}"
flags = 0 if case_sensitive else re.IGNORECASE
regex = re.compile(pattern, flags)
results = []
for file_path in search_path.rglob(file_pattern):
if not file_path.is_file():
continue
try:
content = file_path.read_text(encoding="utf-8")
for i, line in enumerate(content.split("\n"), 1):
if regex.search(line):
results.append(f"{file_path}:{i}: {line.strip()[:100]}")
except Exception:
continue
if not results:
return f"No matches found for: {pattern}"
return f"Found {len(results)} matches:\n" + "\n".join(results[:50])
except Exception as e:
return f"Error searching: {str(e)}"
class WebSearchTool(Tool):
"""Search the web for information."""
def __init__(self):
pass
@property
def name(self) -> str:
return "web_search"
@property
def description(self) -> str:
return "Search the web for current information, real-time data, or information that is not in your training data. **Only use this when the user explicitly asks for** latest news, current events, real-time information, or specifically requests a web search. **DO NOT use for simple questions** like '介绍一下武汉', '什么是AI' - answer from your knowledge instead."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"max_results": {
"type": "integer",
"description": "Maximum number of results",
"default": 5,
},
},
"required": ["query"],
}
async def execute(self, query: str, max_results: int = 5, **kwargs: Any) -> str:
# Placeholder for web search implementation
# In production, this would use a search API (e.g., Google, Bing, SerpAPI)
return f"Web search not implemented yet. Query: {query}"
class CalculatorTool(Tool):
"""Simple calculator tool."""
@property
def name(self) -> str:
return "calculator"
@property
def description(self) -> str:
return "Evaluate a mathematical expression."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Mathematical expression to evaluate"},
},
"required": ["expression"],
}
async def execute(self, expression: str, **kwargs: Any) -> str:
try:
# Safe evaluation - only allow basic math operators
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "Error: Invalid characters in expression"
result = eval(expression) # Note: In production, use a safer parser
return f"{expression} = {result}"
except Exception as e:
return f"Error evaluating expression: {str(e)}"
class GetTimeTool(Tool):
"""Get current time."""
@property
def name(self) -> str:
return "get_time"
@property
def description(self) -> str:
return "Get the current date and time."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "Timezone (e.g., UTC, Asia/Shanghai)",
"default": "UTC",
},
},
}
async def execute(self, timezone: str = "UTC", **kwargs: Any) -> str:
from datetime import datetime, timezone
try:
if timezone.upper() != "UTC":
# For non-UTC timezones, return simple result
return f"Timezone '{timezone}' not supported. Current UTC time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
except Exception:
pass
now = datetime.now(timezone.utc)
return now.strftime("%Y-%m-%d %H:%M:%S UTC")
class BashTool(Tool):
"""Execute bash commands."""
def __init__(self, workspace: Path | None = None, use_sandbox: bool = False):
"""Initialize bash tool.
Args:
workspace: Workspace path
use_sandbox: Whether to use sandbox for execution (recommended for untrusted code)
"""
self._workspace = workspace
self._use_sandbox = use_sandbox
self._sandbox = None
if use_sandbox and SANDBOX_AVAILABLE:
self._sandbox = get_bwrap_sandbox()
@property
def name(self) -> str:
return "bash"
@property
def description(self) -> str:
if self._use_sandbox:
return "Execute a bash command in an isolated sandbox and return its output."
return "Execute a bash command and return its output."
@property
def parameters(self) -> dict[str, Any]:
params = {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Command to execute"},
"timeout": {
"type": "integer",
"description": "Timeout in seconds",
"default": 30,
},
},
"required": ["command"],
}
return params
async def execute(self, command: str, timeout: int = 30, **kwargs: Any) -> str:
# Use sandbox if enabled
if self._use_sandbox and self._sandbox:
try:
return await self._sandbox.execute_command(command, timeout)
except Exception as e:
return f"Error executing in sandbox: {str(e)}\nFalling back to direct execution."
# Direct execution (no sandbox)
try:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
result = []
if stdout:
result.append(stdout.decode("utf-8"))
if stderr:
result.append(f"STDERR: {stderr.decode('utf-8')}")
return "\n".join(result) or "Command completed with no output"
except asyncio.TimeoutError:
process.kill()
return f"Error: Command timed out after {timeout} seconds"
except Exception as e:
return f"Error executing command: {str(e)}"
def get_builtin_tools(workspace: Path | None = None, use_sandbox: bool = False) -> list[Tool]:
"""Get list of all built-in tools.
Args:
workspace: Optional workspace path for file operations
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
Returns:
List of Tool instances
"""
return [
ReadFileTool(workspace),
WriteFileTool(workspace),
ListDirectoryTool(workspace),
SearchTool(workspace),
WebSearchTool(),
CalculatorTool(),
GetTimeTool(),
BashTool(workspace, use_sandbox=use_sandbox),
]

View File

@@ -0,0 +1,110 @@
"""Tool manager for loading and managing tools."""
import logging
from pathlib import Path
from typing import Any
from nanobot.agent.tools.registry import ToolRegistry
from agents.tools.builtin import get_builtin_tools
logger = logging.getLogger(__name__)
class ToolManager:
"""Manages tools for the agent."""
def __init__(self, workspace: Path | None = None, use_sandbox: bool = False):
"""Initialize tool manager.
Args:
workspace: Optional workspace path
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
"""
self.workspace = workspace
self.use_sandbox = use_sandbox
self.registry = ToolRegistry()
self._load_builtin_tools()
def _load_builtin_tools(self) -> None:
"""Load all built-in tools."""
tools = get_builtin_tools(self.workspace, use_sandbox=self.use_sandbox)
for tool in tools:
self.registry.register(tool)
logger.info(f"Loaded {len(tools)} built-in tools (sandbox: {self.use_sandbox})")
def register_tool(self, tool: Any) -> None:
"""Register a custom tool.
Args:
tool: Tool instance to register
"""
self.registry.register(tool)
logger.info(f"Registered tool: {tool.name}")
def unregister_tool(self, name: str) -> None:
"""Unregister a tool.
Args:
name: Tool name to unregister
"""
self.registry.unregister(name)
logger.info(f"Unregistered tool: {name}")
def get_tool(self, name: str) -> Any:
"""Get a tool by name.
Args:
name: Tool name
Returns:
Tool instance or None
"""
return self.registry.get(name)
def has_tool(self, name: str) -> bool:
"""Check if a tool is registered.
Args:
name: Tool name
Returns:
True if tool exists
"""
return self.registry.has(name)
def list_tools(self) -> list[str]:
"""List all registered tool names.
Returns:
List of tool names
"""
return self.registry.tool_names
def get_tool_definitions(self) -> list[dict[str, Any]]:
"""Get all tool definitions in OpenAI format.
Returns:
List of tool schemas
"""
return self.registry.get_definitions()
async def execute_tool(self, name: str, params: dict[str, Any]) -> str:
"""Execute a tool by name.
Args:
name: Tool name
params: Tool parameters
Returns:
Tool execution result
"""
return await self.registry.execute(name, params)
def __len__(self) -> int:
"""Get number of registered tools."""
return len(self.registry)
def __contains__(self, name: str) -> bool:
"""Check if tool is registered."""
return name in self.registry

107
core/agents/tools/sync.py Normal file
View File

@@ -0,0 +1,107 @@
"""Tool synchronization between Python Agent and Go backend."""
import asyncio
import logging
from typing import Any
import aiohttp
logger = logging.getLogger(__name__)
class ToolSyncClient:
"""Client for syncing tools to Go backend."""
def __init__(self, base_url: str, agent_id: str = "default"):
"""Initialize tool sync client.
Args:
base_url: Go backend base URL
agent_id: Agent ID
"""
self.base_url = base_url.rstrip("/")
self.agent_id = agent_id
self._session = None
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self) -> None:
"""Close the session."""
if self._session and not self._session.closed:
await self._session.close()
async def sync_tools(
self,
tools: list[dict[str, Any]],
) -> tuple[int, str]:
"""Sync tools to Go backend.
Args:
tools: List of tool definitions
Returns:
Tuple of (synced_count, message)
"""
url = f"{self.base_url}/tool/sync-from-python"
# Transform tools to match Go backend format
python_tools = []
for tool in tools:
func = tool.get("function", {})
python_tools.append({
"name": func.get("name"),
"description": func.get("description"),
"parameters": func.get("parameters", "{}"),
"category": "python", # Default category for Python tools
})
payload = {"tools": python_tools}
try:
session = await self._get_session()
async with session.post(url, json=payload) as response:
if response.status == 200:
result = await response.json()
count = result.get("synced_count", 0)
return count, f"Synced {count} tools successfully"
else:
text = await response.text()
return 0, f"Failed to sync tools: {response.status} - {text}"
except Exception as e:
logger.error(f"Error syncing tools: {e}")
return 0, f"Error syncing tools: {e}"
async def sync_registry_tools(
registry,
base_url: str,
agent_id: str = "default",
) -> tuple[int, str]:
"""Sync tools from a ToolRegistry to Go backend.
Args:
registry: ToolRegistry instance
base_url: Go backend base URL
agent_id: Agent ID
Returns:
Tuple of (synced_count, message)
"""
client = ToolSyncClient(base_url, agent_id)
try:
# Get all tool definitions
tools = registry.get_definitions()
if not tools:
return 0, "No tools to sync"
# Sync tools
count, message = await client.sync_tools(tools)
return count, message
finally:
await client.close()

View File

@@ -0,0 +1,13 @@
__pycache__
*.pyc
*.pyo
*.pyd
*.egg-info
dist/
build/
.git
.env
.assets
node_modules/
bridge/dist/
workspace/

24
core/nanobot/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
.worktrees/
.assets
.env
*.pyc
dist/
build/
docs/
*.egg-info/
*.egg
*.pyc
*.pyo
*.pyd
*.pyw
*.pyz
*.pywz
*.pyzz
.venv/
venv/
__pycache__/
poetry.lock
.pytest_cache/
botpy.log
nano.*.save

View File

@@ -0,0 +1,5 @@
We provide QR codes for joining the HKUDS discussion groups on **WeChat** and **Feishu**.
You can join by scanning the QR codes below:
<img src="https://github.com/HKUDS/.github/blob/main/profile/QR.png" alt="WeChat QR Code" width="400"/>

40
core/nanobot/Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# Install Node.js 20 for the WhatsApp bridge
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg git && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
apt-get update && \
apt-get install -y --no-install-recommends nodejs && \
apt-get purge -y gnupg && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python dependencies first (cached layer)
COPY pyproject.toml README.md LICENSE ./
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
uv pip install --system --no-cache . && \
rm -rf nanobot bridge
# Copy the full source and install
COPY nanobot/ nanobot/
COPY bridge/ bridge/
RUN uv pip install --system --no-cache .
# Build the WhatsApp bridge
WORKDIR /app/bridge
RUN npm install && npm run build
WORKDIR /app
# Create config directory
RUN mkdir -p /root/.nanobot
# Gateway default port
EXPOSE 18790
ENTRYPOINT ["nanobot"]
CMD ["status"]

21
core/nanobot/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 nanobot contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1321
core/nanobot/README.md Normal file

File diff suppressed because it is too large Load Diff

263
core/nanobot/SECURITY.md Normal file
View File

@@ -0,0 +1,263 @@
# Security Policy
## Reporting a Vulnerability
If you discover a security vulnerability in nanobot, please report it by:
1. **DO NOT** open a public GitHub issue
2. Create a private security advisory on GitHub or contact the repository maintainers (xubinrencs@gmail.com)
3. Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
We aim to respond to security reports within 48 hours.
## Security Best Practices
### 1. API Key Management
**CRITICAL**: Never commit API keys to version control.
```bash
# ✅ Good: Store in config file with restricted permissions
chmod 600 ~/.nanobot/config.json
# ❌ Bad: Hardcoding keys in code or committing them
```
**Recommendations:**
- Store API keys in `~/.nanobot/config.json` with file permissions set to `0600`
- Consider using environment variables for sensitive keys
- Use OS keyring/credential manager for production deployments
- Rotate API keys regularly
- Use separate API keys for development and production
### 2. Channel Access Control
**IMPORTANT**: Always configure `allowFrom` lists for production use.
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowFrom": ["123456789", "987654321"]
},
"whatsapp": {
"enabled": true,
"allowFrom": ["+1234567890"]
}
}
}
```
**Security Notes:**
- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone.
- Get your Telegram user ID from `@userinfobot`
- Use full phone numbers with country code for WhatsApp
- Review access logs regularly for unauthorized access attempts
### 3. Shell Command Execution
The `exec` tool can execute shell commands. While dangerous command patterns are blocked, you should:
- ✅ Review all tool usage in agent logs
- ✅ Understand what commands the agent is running
- ✅ Use a dedicated user account with limited privileges
- ✅ Never run nanobot as root
- ❌ Don't disable security checks
- ❌ Don't run on systems with sensitive data without careful review
**Blocked patterns:**
- `rm -rf /` - Root filesystem deletion
- Fork bombs
- Filesystem formatting (`mkfs.*`)
- Raw disk writes
- Other destructive operations
### 4. File System Access
File operations have path traversal protection, but:
- ✅ Run nanobot with a dedicated user account
- ✅ Use filesystem permissions to protect sensitive directories
- ✅ Regularly audit file operations in logs
- ❌ Don't give unrestricted access to sensitive files
### 5. Network Security
**API Calls:**
- All external API calls use HTTPS by default
- Timeouts are configured to prevent hanging requests
- Consider using a firewall to restrict outbound connections if needed
**WhatsApp Bridge:**
- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network)
- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
### 6. Dependency Security
**Critical**: Keep dependencies updated!
```bash
# Check for vulnerable dependencies
pip install pip-audit
pip-audit
# Update to latest secure versions
pip install --upgrade nanobot-ai
```
For Node.js dependencies (WhatsApp bridge):
```bash
cd bridge
npm audit
npm audit fix
```
**Important Notes:**
- Keep `litellm` updated to the latest version for security fixes
- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability
- Run `pip-audit` or `npm audit` regularly
- Subscribe to security advisories for nanobot and its dependencies
### 7. Production Deployment
For production use:
1. **Isolate the Environment**
```bash
# Run in a container or VM
docker run --rm -it python:3.11
pip install nanobot-ai
```
2. **Use a Dedicated User**
```bash
sudo useradd -m -s /bin/bash nanobot
sudo -u nanobot nanobot gateway
```
3. **Set Proper Permissions**
```bash
chmod 700 ~/.nanobot
chmod 600 ~/.nanobot/config.json
chmod 700 ~/.nanobot/whatsapp-auth
```
4. **Enable Logging**
```bash
# Configure log monitoring
tail -f ~/.nanobot/logs/nanobot.log
```
5. **Use Rate Limiting**
- Configure rate limits on your API providers
- Monitor usage for anomalies
- Set spending limits on LLM APIs
6. **Regular Updates**
```bash
# Check for updates weekly
pip install --upgrade nanobot-ai
```
### 8. Development vs Production
**Development:**
- Use separate API keys
- Test with non-sensitive data
- Enable verbose logging
- Use a test Telegram bot
**Production:**
- Use dedicated API keys with spending limits
- Restrict file system access
- Enable audit logging
- Regular security reviews
- Monitor for unusual activity
### 9. Data Privacy
- **Logs may contain sensitive information** - secure log files appropriately
- **LLM providers see your prompts** - review their privacy policies
- **Chat history is stored locally** - protect the `~/.nanobot` directory
- **API keys are in plain text** - use OS keyring for production
### 10. Incident Response
If you suspect a security breach:
1. **Immediately revoke compromised API keys**
2. **Review logs for unauthorized access**
```bash
grep "Access denied" ~/.nanobot/logs/nanobot.log
```
3. **Check for unexpected file modifications**
4. **Rotate all credentials**
5. **Update to latest version**
6. **Report the incident** to maintainers
## Security Features
### Built-in Security Controls
✅ **Input Validation**
- Path traversal protection on file operations
- Dangerous command pattern detection
- Input length limits on HTTP requests
✅ **Authentication**
- Allow-list based access control — in `v0.1.4.post3` and earlier empty `allowFrom` allowed all; since `v0.1.4.post4` it denies all (`["*"]` explicitly allows all)
- Failed authentication attempt logging
✅ **Resource Protection**
- Command execution timeouts (60s default)
- Output truncation (10KB limit)
- HTTP request timeouts (10-30s)
✅ **Secure Communication**
- HTTPS for all external API calls
- TLS for Telegram API
- WhatsApp bridge: localhost-only binding + optional token auth
## Known Limitations
⚠️ **Current Security Limitations:**
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
2. **Plain Text Config** - API keys stored in plain text (use keyring for production)
3. **No Session Management** - No automatic session expiry
4. **Limited Command Filtering** - Only blocks obvious dangerous patterns
5. **No Audit Trail** - Limited security event logging (enhance as needed)
## Security Checklist
Before deploying nanobot:
- [ ] API keys stored securely (not in code)
- [ ] Config file permissions set to 0600
- [ ] `allowFrom` lists configured for all channels
- [ ] Running as non-root user
- [ ] File system permissions properly restricted
- [ ] Dependencies updated to latest secure versions
- [ ] Logs monitored for security events
- [ ] Rate limits configured on API providers
- [ ] Backup and disaster recovery plan in place
- [ ] Security review of custom skills/tools
## Updates
**Last Updated**: 2026-02-03
For the latest security updates and announcements, check:
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
- Release Notes: https://github.com/HKUDS/nanobot/releases
## License
See LICENSE file for details.

View File

@@ -0,0 +1,26 @@
{
"name": "nanobot-whatsapp-bridge",
"version": "0.1.0",
"description": "WhatsApp bridge for nanobot using Baileys",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc && node dist/index.js"
},
"dependencies": {
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ws": "^8.17.1",
"qrcode-terminal": "^0.12.0",
"pino": "^9.0.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/ws": "^8.5.10",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=20.0.0"
}
}

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* nanobot WhatsApp Bridge
*
* This bridge connects WhatsApp Web to nanobot's Python backend
* via WebSocket. It handles authentication, message forwarding,
* and reconnection logic.
*
* Usage:
* npm run build && npm start
*
* Or with custom settings:
* BRIDGE_PORT=3001 AUTH_DIR=~/.nanobot/whatsapp npm start
*/
// Polyfill crypto for Baileys in ESM
import { webcrypto } from 'crypto';
if (!globalThis.crypto) {
(globalThis as any).crypto = webcrypto;
}
import { BridgeServer } from './server.js';
import { homedir } from 'os';
import { join } from 'path';
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
const TOKEN = process.env.BRIDGE_TOKEN || undefined;
console.log('🐈 nanobot WhatsApp Bridge');
console.log('========================\n');
const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('\n\nShutting down...');
await server.stop();
process.exit(0);
});
process.on('SIGTERM', async () => {
await server.stop();
process.exit(0);
});
// Start the server
server.start().catch((error) => {
console.error('Failed to start bridge:', error);
process.exit(1);
});

View File

@@ -0,0 +1,129 @@
/**
* WebSocket server for Python-Node.js bridge communication.
* Security: binds to 127.0.0.1 only; optional BRIDGE_TOKEN auth.
*/
import { WebSocketServer, WebSocket } from 'ws';
import { WhatsAppClient, InboundMessage } from './whatsapp.js';
interface SendCommand {
type: 'send';
to: string;
text: string;
}
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
}
export class BridgeServer {
private wss: WebSocketServer | null = null;
private wa: WhatsAppClient | null = null;
private clients: Set<WebSocket> = new Set();
constructor(private port: number, private authDir: string, private token?: string) {}
async start(): Promise<void> {
// Bind to localhost only — never expose to external network
this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port });
console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
if (this.token) console.log('🔒 Token authentication enabled');
// Initialize WhatsApp client
this.wa = new WhatsAppClient({
authDir: this.authDir,
onMessage: (msg) => this.broadcast({ type: 'message', ...msg }),
onQR: (qr) => this.broadcast({ type: 'qr', qr }),
onStatus: (status) => this.broadcast({ type: 'status', status }),
});
// Handle WebSocket connections
this.wss.on('connection', (ws) => {
if (this.token) {
// Require auth handshake as first message
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
ws.once('message', (data) => {
clearTimeout(timeout);
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth' && msg.token === this.token) {
console.log('🔗 Python client authenticated');
this.setupClient(ws);
} else {
ws.close(4003, 'Invalid token');
}
} catch {
ws.close(4003, 'Invalid auth message');
}
});
} else {
console.log('🔗 Python client connected');
this.setupClient(ws);
}
});
// Connect to WhatsApp
await this.wa.connect();
}
private setupClient(ws: WebSocket): void {
this.clients.add(ws);
ws.on('message', async (data) => {
try {
const cmd = JSON.parse(data.toString()) as SendCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
console.error('Error handling command:', error);
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
}
});
ws.on('close', () => {
console.log('🔌 Python client disconnected');
this.clients.delete(ws);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
this.clients.delete(ws);
});
}
private async handleCommand(cmd: SendCommand): Promise<void> {
if (cmd.type === 'send' && this.wa) {
await this.wa.sendMessage(cmd.to, cmd.text);
}
}
private broadcast(msg: BridgeMessage): void {
const data = JSON.stringify(msg);
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
async stop(): Promise<void> {
// Close all client connections
for (const client of this.clients) {
client.close();
}
this.clients.clear();
// Close WebSocket server
if (this.wss) {
this.wss.close();
this.wss = null;
}
// Disconnect WhatsApp
if (this.wa) {
await this.wa.disconnect();
this.wa = null;
}
}
}

3
core/nanobot/bridge/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module 'qrcode-terminal' {
export function generate(text: string, options?: { small?: boolean }): void;
}

View File

@@ -0,0 +1,239 @@
/**
* WhatsApp client wrapper using Baileys.
* Based on OpenClaw's working implementation.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
downloadMediaMessage,
extractMessageContent as baileysExtractMessageContent,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
export interface InboundMessage {
id: string;
sender: string;
pn: string;
content: string;
timestamp: number;
isGroup: boolean;
media?: string[];
}
export interface WhatsAppClientOptions {
authDir: string;
onMessage: (msg: InboundMessage) => void;
onQR: (qr: string) => void;
onStatus: (status: string) => void;
}
export class WhatsAppClient {
private sock: any = null;
private options: WhatsAppClientOptions;
private reconnecting = false;
constructor(options: WhatsAppClientOptions) {
this.options = options;
}
async connect(): Promise<void> {
const logger = pino({ level: 'silent' });
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
const { version } = await fetchLatestBaileysVersion();
console.log(`Using Baileys version: ${version.join('.')}`);
// Create socket following OpenClaw's pattern
this.sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
version,
logger,
printQRInTerminal: false,
browser: ['nanobot', 'cli', VERSION],
syncFullHistory: false,
markOnlineOnConnect: false,
});
// Handle WebSocket errors
if (this.sock.ws && typeof this.sock.ws.on === 'function') {
this.sock.ws.on('error', (err: Error) => {
console.error('WebSocket error:', err.message);
});
}
// Handle connection updates
this.sock.ev.on('connection.update', async (update: any) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
// Display QR code in terminal
console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
qrcode.generate(qr, { small: true });
this.options.onQR(qr);
}
if (connection === 'close') {
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
this.options.onStatus('disconnected');
if (shouldReconnect && !this.reconnecting) {
this.reconnecting = true;
console.log('Reconnecting in 5 seconds...');
setTimeout(() => {
this.reconnecting = false;
this.connect();
}, 5000);
}
} else if (connection === 'open') {
console.log('✅ Connected to WhatsApp');
this.options.onStatus('connected');
}
});
// Save credentials on update
this.sock.ev.on('creds.update', saveCreds);
// Handle incoming messages
this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue;
if (msg.key.remoteJid === 'status@broadcast') continue;
const unwrapped = baileysExtractMessageContent(msg.message);
if (!unwrapped) continue;
const content = this.getTextContent(unwrapped);
let fallbackContent: string | null = null;
const mediaPaths: string[] = [];
if (unwrapped.imageMessage) {
fallbackContent = '[Image]';
const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.documentMessage) {
fallbackContent = '[Document]';
const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
unwrapped.documentMessage.fileName ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.videoMessage) {
fallbackContent = '[Video]';
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
}
const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
if (!finalContent && mediaPaths.length === 0) continue;
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
this.options.onMessage({
id: msg.key.id || '',
sender: msg.key.remoteJid || '',
pn: msg.key.remoteJidAlt || '',
content: finalContent,
timestamp: msg.messageTimestamp as number,
isGroup,
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
});
}
});
}
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> {
try {
const mediaDir = join(this.options.authDir, '..', 'media');
await mkdir(mediaDir, { recursive: true });
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
let outFilename: string;
if (fileName) {
// Documents have a filename — use it with a unique prefix to avoid collisions
const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`;
outFilename = prefix + fileName;
} else {
const mime = mimetype || 'application/octet-stream';
// Derive extension from mimetype subtype (e.g. "image/png" → ".png", "application/pdf" → ".pdf")
const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
}
const filepath = join(mediaDir, outFilename);
await writeFile(filepath, buffer);
return filepath;
} catch (err) {
console.error('Failed to download media:', err);
return null;
}
}
private getTextContent(message: any): string | null {
// Text message
if (message.conversation) {
return message.conversation;
}
// Extended text (reply, link preview)
if (message.extendedTextMessage?.text) {
return message.extendedTextMessage.text;
}
// Image with optional caption
if (message.imageMessage) {
return message.imageMessage.caption || '';
}
// Video with optional caption
if (message.videoMessage) {
return message.videoMessage.caption || '';
}
// Document with optional caption
if (message.documentMessage) {
return message.documentMessage.caption || '';
}
// Voice/Audio message
if (message.audioMessage) {
return `[Voice Message]`;
}
return null;
}
async sendMessage(to: string, text: string): Promise<void> {
if (!this.sock) {
throw new Error('Not connected');
}
await this.sock.sendMessage(to, { text });
}
async disconnect(): Promise<void> {
if (this.sock) {
this.sock.end(undefined);
this.sock = null;
}
}
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

BIN
core/nanobot/case/code.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

View File

@@ -0,0 +1,21 @@
#!/bin/bash
# Count core agent lines (excluding channels/, cli/, providers/ adapters)
cd "$(dirname "$0")" || exit 1
echo "nanobot core agent line count"
echo "================================"
echo ""
for dir in agent agent/tools bus config cron heartbeat session utils; do
count=$(find "nanobot/$dir" -maxdepth 1 -name "*.py" -exec cat {} + | wc -l)
printf " %-16s %5s lines\n" "$dir/" "$count"
done
root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l)
printf " %-16s %5s lines\n" "(root)" "$root"
echo ""
total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l)
echo " Core total: $total lines"
echo ""
echo " (excludes: channels/, cli/, providers/, skills/)"

View File

@@ -0,0 +1,31 @@
x-common-config: &common-config
build:
context: .
dockerfile: Dockerfile
volumes:
- ~/.nanobot:/root/.nanobot
services:
nanobot-gateway:
container_name: nanobot-gateway
<<: *common-config
command: ["gateway"]
restart: unless-stopped
ports:
- 18790:18790
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
nanobot-cli:
<<: *common-config
profiles:
- cli
command: ["status"]
stdin_open: true
tty: true

View File

@@ -0,0 +1,6 @@
"""
nanobot - A lightweight AI agent framework
"""
__version__ = "0.1.4.post4"
__logo__ = "🐈"

View File

@@ -0,0 +1,8 @@
"""
Entry point for running nanobot as a module: python -m nanobot
"""
from nanobot.cli.commands import app
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,8 @@
"""Agent core module."""
from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
__all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"]

View File

@@ -0,0 +1,191 @@
"""Context builder for assembling agent prompts."""
import base64
import mimetypes
import platform
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
from nanobot.utils.helpers import build_assistant_message, detect_image_mime
class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
def __init__(self, workspace: Path):
self.workspace = workspace
self.memory = MemoryStore(workspace)
self.skills = SkillsLoader(workspace)
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
parts = [self._get_identity()]
bootstrap = self._load_bootstrap_files()
if bootstrap:
parts.append(bootstrap)
memory = self.memory.get_memory_context()
if memory:
parts.append(f"# Memory\n\n{memory}")
always_skills = self.skills.get_always_skills()
if always_skills:
always_content = self.skills.load_skills_for_context(always_skills)
if always_content:
parts.append(f"# Active Skills\n\n{always_content}")
skills_summary = self.skills.build_skills_summary()
if skills_summary:
parts.append(f"""# Skills
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
{skills_summary}""")
return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str:
"""Get the core identity section."""
workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
platform_policy = ""
if system == "Windows":
platform_policy = """## Platform Policy (Windows)
- You are running on Windows. Do not assume GNU tools like `grep`, `sed`, or `awk` exist.
- Prefer Windows-native commands or file tools when they are more reliable.
- If terminal output is garbled, retry with UTF-8 output enabled.
"""
else:
platform_policy = """## Platform Policy (POSIX)
- You are running on a POSIX system. Prefer UTF-8 and standard shell tools.
- Use file tools when they are simpler or more reliable than shell commands.
"""
return f"""# nanobot 🐈
You are nanobot, a helpful AI assistant.
## Runtime
{runtime}
## Workspace
Your workspace is at: {workspace_path}
- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here)
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM].
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
{platform_policy}
## nanobot Guidelines
- State intent before tool calls, but NEVER predict or claim results before receiving them.
- Before modifying a file, read it first. Do not assume files or directories exist.
- After writing or editing a file, re-read it if accuracy matters.
- If a tool call fails, analyze the error before retrying with a different approach.
- Ask for clarification when the request is ambiguous.
Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel."""
@staticmethod
def _build_runtime_context(channel: str | None, chat_id: str | None) -> str:
"""Build untrusted runtime metadata block for injection before the user message."""
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = time.strftime("%Z") or "UTC"
lines = [f"Current Time: {now} ({tz})"]
if channel and chat_id:
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines)
def _load_bootstrap_files(self) -> str:
"""Load all bootstrap files from workspace."""
parts = []
for filename in self.BOOTSTRAP_FILES:
file_path = self.workspace / filename
if file_path.exists():
content = file_path.read_text(encoding="utf-8")
parts.append(f"## {filename}\n\n{content}")
return "\n\n".join(parts) if parts else ""
def build_messages(
self,
history: list[dict[str, Any]],
current_message: str,
skill_names: list[str] | None = None,
media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
runtime_ctx = self._build_runtime_context(channel, chat_id)
user_content = self._build_user_content(current_message, media)
# Merge runtime context and user content into a single user message
# to avoid consecutive same-role messages that some providers reject.
if isinstance(user_content, str):
merged = f"{runtime_ctx}\n\n{user_content}"
else:
merged = [{"type": "text", "text": runtime_ctx}] + user_content
return [
{"role": "system", "content": self.build_system_prompt(skill_names)},
*history,
{"role": "user", "content": merged},
]
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
"""Build user message content with optional base64-encoded images."""
if not media:
return text
images = []
for path in media:
p = Path(path)
if not p.is_file():
continue
raw = p.read_bytes()
# Detect real MIME type from magic bytes; fallback to filename guess
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if not mime or not mime.startswith("image/"):
continue
b64 = base64.b64encode(raw).decode()
images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
if not images:
return text
return images + [{"type": "text", "text": text}]
def add_tool_result(
self, messages: list[dict[str, Any]],
tool_call_id: str, tool_name: str, result: str,
) -> list[dict[str, Any]]:
"""Add a tool result to the message list."""
messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": result})
return messages
def add_assistant_message(
self, messages: list[dict[str, Any]],
content: str | None,
tool_calls: list[dict[str, Any]] | None = None,
reasoning_content: str | None = None,
thinking_blocks: list[dict] | None = None,
) -> list[dict[str, Any]]:
"""Add an assistant message to the message list."""
messages.append(build_assistant_message(
content,
tool_calls=tool_calls,
reasoning_content=reasoning_content,
thinking_blocks=thinking_blocks,
))
return messages

View File

@@ -0,0 +1,470 @@
"""Agent loop: the core processing engine."""
from __future__ import annotations
import asyncio
import json
import re
from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.context import ContextBuilder
from nanobot.agent.memory import MemoryConsolidator
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager
if TYPE_CHECKING:
from nanobot.config.schema import ChannelsConfig, ExecToolConfig
from nanobot.cron.service import CronService
class AgentLoop:
"""
The agent loop is the core processing engine.
It:
1. Receives messages from the bus
2. Builds context with history, memory, skills
3. Calls the LLM
4. Executes tool calls
5. Sends responses back
"""
_TOOL_RESULT_MAX_CHARS = 500
def __init__(
self,
bus: MessageBus,
provider: LLMProvider,
workspace: Path,
model: str | None = None,
max_iterations: int = 40,
context_window_tokens: int = 65_536,
brave_api_key: str | None = None,
web_proxy: str | None = None,
exec_config: ExecToolConfig | None = None,
cron_service: CronService | None = None,
restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None,
mcp_servers: dict | None = None,
channels_config: ChannelsConfig | None = None,
):
from nanobot.config.schema import ExecToolConfig
self.bus = bus
self.channels_config = channels_config
self.provider = provider
self.workspace = workspace
self.model = model or provider.get_default_model()
self.max_iterations = max_iterations
self.context_window_tokens = context_window_tokens
self.brave_api_key = brave_api_key
self.web_proxy = web_proxy
self.exec_config = exec_config or ExecToolConfig()
self.cron_service = cron_service
self.restrict_to_workspace = restrict_to_workspace
self.context = ContextBuilder(workspace)
self.sessions = session_manager or SessionManager(workspace)
self.tools = ToolRegistry()
self.subagents = SubagentManager(
provider=provider,
workspace=workspace,
bus=bus,
model=self.model,
brave_api_key=brave_api_key,
web_proxy=web_proxy,
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._mcp_connecting = False
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
self._processing_lock = asyncio.Lock()
self.memory_consolidator = MemoryConsolidator(
workspace=workspace,
provider=provider,
model=self.model,
sessions=self.sessions,
context_window_tokens=context_window_tokens,
build_messages=self.context.build_messages,
get_tool_definitions=self.tools.get_definitions,
)
self._register_default_tools()
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
allowed_dir = self.workspace if self.restrict_to_workspace else None
for cls in (ReadFileTool, WriteFileTool, EditFileTool, ListDirTool):
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
path_append=self.exec_config.path_append,
))
self.tools.register(WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy))
self.tools.register(WebFetchTool(proxy=self.web_proxy))
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
self.tools.register(SpawnTool(manager=self.subagents))
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
return
self._mcp_connecting = True
from nanobot.agent.tools.mcp import connect_mcp_servers
try:
self._mcp_stack = AsyncExitStack()
await self._mcp_stack.__aenter__()
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
self._mcp_connected = True
except Exception as e:
logger.error("Failed to connect MCP servers (will retry next message): {}", e)
if self._mcp_stack:
try:
await self._mcp_stack.aclose()
except Exception:
pass
self._mcp_stack = None
finally:
self._mcp_connecting = False
def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Update context for all tools that need routing info."""
for name in ("message", "spawn", "cron"):
if tool := self.tools.get(name):
if hasattr(tool, "set_context"):
tool.set_context(channel, chat_id, *([message_id] if name == "message" else []))
@staticmethod
def _strip_think(text: str | None) -> str | None:
"""Remove <think>…</think> blocks that some models embed in content."""
if not text:
return None
return re.sub(r"<think>[\s\S]*?</think>", "", text).strip() or None
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hint, e.g. 'web_search("query")'."""
def _fmt(tc):
args = (tc.arguments[0] if isinstance(tc.arguments, list) else tc.arguments) or {}
val = next(iter(args.values()), None) if isinstance(args, dict) else None
if not isinstance(val, str):
return tc.name
return f'{tc.name}("{val[:40]}")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls)
async def _run_agent_loop(
self,
initial_messages: list[dict],
on_progress: Callable[..., Awaitable[None]] | None = None,
) -> tuple[str | None, list[str], list[dict]]:
"""Run the agent iteration loop."""
messages = initial_messages
iteration = 0
final_content = None
tools_used: list[str] = []
while iteration < self.max_iterations:
iteration += 1
tool_defs = self.tools.get_definitions()
response = await self.provider.chat_with_retry(
messages=messages,
tools=tool_defs,
model=self.model,
)
if response.has_tool_calls:
if on_progress:
thought = self._strip_think(response.content)
if thought:
await on_progress(thought)
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [
tc.to_openai_tool_call()
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
messages, response.content, tool_call_dicts,
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
)
for tool_call in response.tool_calls:
tools_used.append(tool_call.name)
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tool_call.name, args_str[:200])
result = await self.tools.execute(tool_call.name, tool_call.arguments)
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
else:
clean = self._strip_think(response.content)
# Don't persist error responses to session history — they can
# poison the context and cause permanent 400 loops (#1303).
if response.finish_reason == "error":
logger.error("LLM returned error: {}", (clean or "")[:200])
final_content = clean or "Sorry, I encountered an error calling the AI model."
break
messages = self.context.add_assistant_message(
messages, clean, reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
)
final_content = clean
break
if final_content is None and iteration >= self.max_iterations:
logger.warning("Max iterations ({}) reached", self.max_iterations)
final_content = (
f"I reached the maximum number of tool call iterations ({self.max_iterations}) "
"without completing the task. You can try breaking the task into smaller steps."
)
return final_content, tools_used, messages
async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
await self._connect_mcp()
logger.info("Agent loop started")
while self._running:
try:
msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
except asyncio.TimeoutError:
continue
if msg.content.strip().lower() == "/stop":
await self._handle_stop(msg)
else:
task = asyncio.create_task(self._dispatch(msg))
self._active_tasks.setdefault(msg.session_key, []).append(task)
task.add_done_callback(lambda t, k=msg.session_key: self._active_tasks.get(k, []) and self._active_tasks[k].remove(t) if t in self._active_tasks.get(k, []) else None)
async def _handle_stop(self, msg: InboundMessage) -> None:
"""Cancel all active tasks and subagents for the session."""
tasks = self._active_tasks.pop(msg.session_key, [])
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
sub_cancelled = await self.subagents.cancel_by_session(msg.session_key)
total = cancelled + sub_cancelled
content = f"⏹ Stopped {total} task(s)." if total else "No active task to stop."
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
))
async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message under the global lock."""
async with self._processing_lock:
try:
response = await self._process_message(msg)
if response is not None:
await self.bus.publish_outbound(response)
elif msg.channel == "cli":
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata=msg.metadata or {},
))
except asyncio.CancelledError:
logger.info("Task cancelled for session {}", msg.session_key)
raise
except Exception:
logger.exception("Error processing message for session {}", msg.session_key)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="Sorry, I encountered an error.",
))
async def close_mcp(self) -> None:
"""Close MCP connections."""
if self._mcp_stack:
try:
await self._mcp_stack.aclose()
except (RuntimeError, BaseExceptionGroup):
pass # MCP SDK cancel scope cleanup is noisy but harmless
self._mcp_stack = None
def stop(self) -> None:
"""Stop the agent loop."""
self._running = False
logger.info("Agent loop stopping")
async def _process_message(
self,
msg: InboundMessage,
session_key: str | None = None,
on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> OutboundMessage | None:
"""Process a single inbound message and return the response."""
# System messages: parse origin from chat_id ("channel:chat_id")
if msg.channel == "system":
channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id
else ("cli", msg.chat_id))
logger.info("Processing system message from {}", msg.sender_id)
key = f"{channel}:{chat_id}"
session = self.sessions.get_or_create(key)
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
history = session.get_history(max_messages=0)
messages = self.context.build_messages(
history=history,
current_message=msg.content, channel=channel, chat_id=chat_id,
)
final_content, _, all_msgs = await self._run_agent_loop(messages)
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
return OutboundMessage(channel=channel, chat_id=chat_id,
content=final_content or "Background task completed.")
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
# Slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
try:
if not await self.memory_consolidator.archive_unconsolidated(session):
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
)
except Exception:
logger.exception("/new archival failed for {}", session.key)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
)
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session.key)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="New session started.")
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands")
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool):
message_tool.start_turn()
history = session.get_history(max_messages=0)
initial_messages = self.context.build_messages(
history=history,
current_message=msg.content,
media=msg.media if msg.media else None,
channel=msg.channel, chat_id=msg.chat_id,
)
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
meta = dict(msg.metadata or {})
meta["_progress"] = True
meta["_tool_hint"] = tool_hint
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
))
final_content, _, all_msgs = await self._run_agent_loop(
initial_messages, on_progress=on_progress or _bus_progress,
)
if final_content is None:
final_content = "I've completed processing but have no response to give."
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
return None
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=final_content,
metadata=msg.metadata or {},
)
def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
"""Save new-turn messages into session, truncating large tool results."""
from datetime import datetime
for m in messages[skip:]:
entry = dict(m)
role, content = entry.get("role"), entry.get("content")
if role == "assistant" and not content and not entry.get("tool_calls"):
continue # skip empty assistant messages — they poison session context
if role == "tool" and isinstance(content, str) and len(content) > self._TOOL_RESULT_MAX_CHARS:
entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
elif role == "user":
if isinstance(content, str) and content.startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
# Strip the runtime-context prefix, keep only the user text.
parts = content.split("\n\n", 1)
if len(parts) > 1 and parts[1].strip():
entry["content"] = parts[1]
else:
continue
if isinstance(content, list):
filtered = []
for c in content:
if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
continue # Strip runtime context from multimodal messages
if (c.get("type") == "image_url"
and c.get("image_url", {}).get("url", "").startswith("data:image/")):
filtered.append({"type": "text", "text": "[image]"})
else:
filtered.append(c)
if not filtered:
continue
entry["content"] = filtered
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
session.updated_at = datetime.now()
async def process_direct(
self,
content: str,
session_key: str = "cli:direct",
channel: str = "cli",
chat_id: str = "direct",
on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> str:
"""Process a message directly (for CLI or cron usage)."""
await self._connect_mcp()
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else ""

View File

@@ -0,0 +1,283 @@
"""Memory system for persistent agent memory."""
from __future__ import annotations
import asyncio
import json
import weakref
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from loguru import logger
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager
_SAVE_MEMORY_TOOL = [
{
"type": "function",
"function": {
"name": "save_memory",
"description": "Save the memory consolidation result to persistent storage.",
"parameters": {
"type": "object",
"properties": {
"history_entry": {
"type": "string",
"description": "A paragraph summarizing key events/decisions/topics. "
"Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.",
},
"memory_update": {
"type": "string",
"description": "Full updated long-term memory as markdown. Include all existing "
"facts plus new ones. Return unchanged if nothing new.",
},
},
"required": ["history_entry", "memory_update"],
},
},
}
]
def _ensure_text(value: Any) -> str:
"""Normalize tool-call payload values to text for file storage."""
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _normalize_save_memory_args(args: Any) -> dict[str, Any] | None:
"""Normalize provider tool-call arguments to the expected dict shape."""
if isinstance(args, str):
args = json.loads(args)
if isinstance(args, list):
return args[0] if args and isinstance(args[0], dict) else None
return args if isinstance(args, dict) else None
class MemoryStore:
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
def __init__(self, workspace: Path):
self.memory_dir = ensure_dir(workspace / "memory")
self.memory_file = self.memory_dir / "MEMORY.md"
self.history_file = self.memory_dir / "HISTORY.md"
def read_long_term(self) -> str:
if self.memory_file.exists():
return self.memory_file.read_text(encoding="utf-8")
return ""
def write_long_term(self, content: str) -> None:
self.memory_file.write_text(content, encoding="utf-8")
def append_history(self, entry: str) -> None:
with open(self.history_file, "a", encoding="utf-8") as f:
f.write(entry.rstrip() + "\n\n")
def get_memory_context(self) -> str:
long_term = self.read_long_term()
return f"## Long-term Memory\n{long_term}" if long_term else ""
@staticmethod
def _format_messages(messages: list[dict]) -> str:
lines = []
for message in messages:
if not message.get("content"):
continue
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
lines.append(
f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}{tools}: {message['content']}"
)
return "\n".join(lines)
async def consolidate(
self,
messages: list[dict],
provider: LLMProvider,
model: str,
) -> bool:
"""Consolidate the provided message chunk into MEMORY.md + HISTORY.md."""
if not messages:
return True
current_memory = self.read_long_term()
prompt = f"""Process this conversation and call the save_memory tool with your consolidation.
## Current Long-term Memory
{current_memory or "(empty)"}
## Conversation to Process
{self._format_messages(messages)}"""
try:
response = await provider.chat_with_retry(
messages=[
{"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."},
{"role": "user", "content": prompt},
],
tools=_SAVE_MEMORY_TOOL,
model=model,
)
if not response.has_tool_calls:
logger.warning("Memory consolidation: LLM did not call save_memory, skipping")
return False
args = _normalize_save_memory_args(response.tool_calls[0].arguments)
if args is None:
logger.warning("Memory consolidation: unexpected save_memory arguments")
return False
if entry := args.get("history_entry"):
self.append_history(_ensure_text(entry))
if update := args.get("memory_update"):
update = _ensure_text(update)
if update != current_memory:
self.write_long_term(update)
logger.info("Memory consolidation done for {} messages", len(messages))
return True
except Exception:
logger.exception("Memory consolidation failed")
return False
class MemoryConsolidator:
"""Owns consolidation policy, locking, and session offset updates."""
_MAX_CONSOLIDATION_ROUNDS = 5
def __init__(
self,
workspace: Path,
provider: LLMProvider,
model: str,
sessions: SessionManager,
context_window_tokens: int,
build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]],
):
self.store = MemoryStore(workspace)
self.provider = provider
self.model = model
self.sessions = sessions
self.context_window_tokens = context_window_tokens
self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
def get_lock(self, session_key: str) -> asyncio.Lock:
"""Return the shared consolidation lock for one session."""
return self._locks.setdefault(session_key, asyncio.Lock())
async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
"""Archive a selected message chunk into persistent memory."""
return await self.store.consolidate(messages, self.provider, self.model)
def pick_consolidation_boundary(
self,
session: Session,
tokens_to_remove: int,
) -> tuple[int, int] | None:
"""Pick a user-turn boundary that removes enough old prompt tokens."""
start = session.last_consolidated
if start >= len(session.messages) or tokens_to_remove <= 0:
return None
removed_tokens = 0
last_boundary: tuple[int, int] | None = None
for idx in range(start, len(session.messages)):
message = session.messages[idx]
if idx > start and message.get("role") == "user":
last_boundary = (idx, removed_tokens)
if removed_tokens >= tokens_to_remove:
return last_boundary
removed_tokens += estimate_message_tokens(message)
return last_boundary
def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
"""Estimate current prompt size for the normal session history view."""
history = session.get_history(max_messages=0)
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
probe_messages = self._build_messages(
history=history,
current_message="[token-probe]",
channel=channel,
chat_id=chat_id,
)
return estimate_prompt_tokens_chain(
self.provider,
self.model,
probe_messages,
self._get_tool_definitions(),
)
async def archive_unconsolidated(self, session: Session) -> bool:
"""Archive the full unconsolidated tail for /new-style session rollover."""
lock = self.get_lock(session.key)
async with lock:
snapshot = session.messages[session.last_consolidated:]
if not snapshot:
return True
return await self.consolidate_messages(snapshot)
async def maybe_consolidate_by_tokens(self, session: Session) -> None:
"""Loop: archive old messages until prompt fits within half the context window."""
if not session.messages or self.context_window_tokens <= 0:
return
lock = self.get_lock(session.key)
async with lock:
target = self.context_window_tokens // 2
estimated, source = self.estimate_session_prompt_tokens(session)
if estimated <= 0:
return
if estimated < self.context_window_tokens:
logger.debug(
"Token consolidation idle {}: {}/{} via {}",
session.key,
estimated,
self.context_window_tokens,
source,
)
return
for round_num in range(self._MAX_CONSOLIDATION_ROUNDS):
if estimated <= target:
return
boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
if boundary is None:
logger.debug(
"Token consolidation: no safe boundary for {} (round {})",
session.key,
round_num,
)
return
end_idx = boundary[0]
chunk = session.messages[session.last_consolidated:end_idx]
if not chunk:
return
logger.info(
"Token consolidation round {} for {}: {}/{} via {}, chunk={} msgs",
round_num,
session.key,
estimated,
self.context_window_tokens,
source,
len(chunk),
)
if not await self.consolidate_messages(chunk):
return
session.last_consolidated = end_idx
self.sessions.save(session)
estimated, source = self.estimate_session_prompt_tokens(session)
if estimated <= 0:
return

View File

@@ -0,0 +1,228 @@
"""Skills loader for agent capabilities."""
import json
import os
import re
import shutil
from pathlib import Path
# Default builtin skills directory (relative to this file)
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
class SkillsLoader:
"""
Loader for agent skills.
Skills are markdown files (SKILL.md) that teach the agent how to use
specific tools or perform certain tasks.
"""
def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None):
self.workspace = workspace
self.workspace_skills = workspace / "skills"
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
def list_skills(self, filter_unavailable: bool = True) -> list[dict[str, str]]:
"""
List all available skills.
Args:
filter_unavailable: If True, filter out skills with unmet requirements.
Returns:
List of skill info dicts with 'name', 'path', 'source'.
"""
skills = []
# Workspace skills (highest priority)
if self.workspace_skills.exists():
for skill_dir in self.workspace_skills.iterdir():
if skill_dir.is_dir():
skill_file = skill_dir / "SKILL.md"
if skill_file.exists():
skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "workspace"})
# Built-in skills
if self.builtin_skills and self.builtin_skills.exists():
for skill_dir in self.builtin_skills.iterdir():
if skill_dir.is_dir():
skill_file = skill_dir / "SKILL.md"
if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills):
skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "builtin"})
# Filter by requirements
if filter_unavailable:
return [s for s in skills if self._check_requirements(self._get_skill_meta(s["name"]))]
return skills
def load_skill(self, name: str) -> str | None:
"""
Load a skill by name.
Args:
name: Skill name (directory name).
Returns:
Skill content or None if not found.
"""
# Check workspace first
workspace_skill = self.workspace_skills / name / "SKILL.md"
if workspace_skill.exists():
return workspace_skill.read_text(encoding="utf-8")
# Check built-in
if self.builtin_skills:
builtin_skill = self.builtin_skills / name / "SKILL.md"
if builtin_skill.exists():
return builtin_skill.read_text(encoding="utf-8")
return None
def load_skills_for_context(self, skill_names: list[str]) -> str:
"""
Load specific skills for inclusion in agent context.
Args:
skill_names: List of skill names to load.
Returns:
Formatted skills content.
"""
parts = []
for name in skill_names:
content = self.load_skill(name)
if content:
content = self._strip_frontmatter(content)
parts.append(f"### Skill: {name}\n\n{content}")
return "\n\n---\n\n".join(parts) if parts else ""
def build_skills_summary(self) -> str:
"""
Build a summary of all skills (name, description, path, availability).
This is used for progressive loading - the agent can read the full
skill content using read_file when needed.
Returns:
XML-formatted skills summary.
"""
all_skills = self.list_skills(filter_unavailable=False)
if not all_skills:
return ""
def escape_xml(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
lines = ["<skills>"]
for s in all_skills:
name = escape_xml(s["name"])
path = s["path"]
desc = escape_xml(self._get_skill_description(s["name"]))
skill_meta = self._get_skill_meta(s["name"])
available = self._check_requirements(skill_meta)
lines.append(f" <skill available=\"{str(available).lower()}\">")
lines.append(f" <name>{name}</name>")
lines.append(f" <description>{desc}</description>")
lines.append(f" <location>{path}</location>")
# Show missing requirements for unavailable skills
if not available:
missing = self._get_missing_requirements(skill_meta)
if missing:
lines.append(f" <requires>{escape_xml(missing)}</requires>")
lines.append(" </skill>")
lines.append("</skills>")
return "\n".join(lines)
def _get_missing_requirements(self, skill_meta: dict) -> str:
"""Get a description of missing requirements."""
missing = []
requires = skill_meta.get("requires", {})
for b in requires.get("bins", []):
if not shutil.which(b):
missing.append(f"CLI: {b}")
for env in requires.get("env", []):
if not os.environ.get(env):
missing.append(f"ENV: {env}")
return ", ".join(missing)
def _get_skill_description(self, name: str) -> str:
"""Get the description of a skill from its frontmatter."""
meta = self.get_skill_metadata(name)
if meta and meta.get("description"):
return meta["description"]
return name # Fallback to skill name
def _strip_frontmatter(self, content: str) -> str:
"""Remove YAML frontmatter from markdown content."""
if content.startswith("---"):
match = re.match(r"^---\n.*?\n---\n", content, re.DOTALL)
if match:
return content[match.end():].strip()
return content
def _parse_nanobot_metadata(self, raw: str) -> dict:
"""Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
try:
data = json.loads(raw)
return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
def _check_requirements(self, skill_meta: dict) -> bool:
"""Check if skill requirements are met (bins, env vars)."""
requires = skill_meta.get("requires", {})
for b in requires.get("bins", []):
if not shutil.which(b):
return False
for env in requires.get("env", []):
if not os.environ.get(env):
return False
return True
def _get_skill_meta(self, name: str) -> dict:
"""Get nanobot metadata for a skill (cached in frontmatter)."""
meta = self.get_skill_metadata(name) or {}
return self._parse_nanobot_metadata(meta.get("metadata", ""))
def get_always_skills(self) -> list[str]:
"""Get skills marked as always=true that meet requirements."""
result = []
for s in self.list_skills(filter_unavailable=True):
meta = self.get_skill_metadata(s["name"]) or {}
skill_meta = self._parse_nanobot_metadata(meta.get("metadata", ""))
if skill_meta.get("always") or meta.get("always"):
result.append(s["name"])
return result
def get_skill_metadata(self, name: str) -> dict | None:
"""
Get metadata from a skill's frontmatter.
Args:
name: Skill name.
Returns:
Metadata dict or None.
"""
content = self.load_skill(name)
if not content:
return None
if content.startswith("---"):
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if match:
# Simple YAML parsing
metadata = {}
for line in match.group(1).split("\n"):
if ":" in line:
key, value = line.split(":", 1)
metadata[key.strip()] = value.strip().strip('"\'')
return metadata
return None

View File

@@ -0,0 +1,231 @@
"""Subagent manager for background task execution."""
import asyncio
import json
import uuid
from pathlib import Path
from typing import Any
from loguru import logger
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ExecToolConfig
from nanobot.providers.base import LLMProvider
from nanobot.utils.helpers import build_assistant_message
class SubagentManager:
"""Manages background subagent execution."""
def __init__(
self,
provider: LLMProvider,
workspace: Path,
bus: MessageBus,
model: str | None = None,
brave_api_key: str | None = None,
web_proxy: str | None = None,
exec_config: "ExecToolConfig | None" = None,
restrict_to_workspace: bool = False,
):
from nanobot.config.schema import ExecToolConfig
self.provider = provider
self.workspace = workspace
self.bus = bus
self.model = model or provider.get_default_model()
self.brave_api_key = brave_api_key
self.web_proxy = web_proxy
self.exec_config = exec_config or ExecToolConfig()
self.restrict_to_workspace = restrict_to_workspace
self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
async def spawn(
self,
task: str,
label: str | None = None,
origin_channel: str = "cli",
origin_chat_id: str = "direct",
session_key: str | None = None,
) -> str:
"""Spawn a subagent to execute a task in the background."""
task_id = str(uuid.uuid4())[:8]
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
origin = {"channel": origin_channel, "chat_id": origin_chat_id}
bg_task = asyncio.create_task(
self._run_subagent(task_id, task, display_label, origin)
)
self._running_tasks[task_id] = bg_task
if session_key:
self._session_tasks.setdefault(session_key, set()).add(task_id)
def _cleanup(_: asyncio.Task) -> None:
self._running_tasks.pop(task_id, None)
if session_key and (ids := self._session_tasks.get(session_key)):
ids.discard(task_id)
if not ids:
del self._session_tasks[session_key]
bg_task.add_done_callback(_cleanup)
logger.info("Spawned subagent [{}]: {}", task_id, display_label)
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
async def _run_subagent(
self,
task_id: str,
task: str,
label: str,
origin: dict[str, str],
) -> None:
"""Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label)
try:
# Build subagent tools (no message tool, no spawn tool)
tools = ToolRegistry()
allowed_dir = self.workspace if self.restrict_to_workspace else None
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
path_append=self.exec_config.path_append,
))
tools.register(WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy))
tools.register(WebFetchTool(proxy=self.web_proxy))
system_prompt = self._build_subagent_prompt()
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": task},
]
# Run agent loop (limited iterations)
max_iterations = 15
iteration = 0
final_result: str | None = None
while iteration < max_iterations:
iteration += 1
response = await self.provider.chat_with_retry(
messages=messages,
tools=tools.get_definitions(),
model=self.model,
)
if response.has_tool_calls:
tool_call_dicts = [
tc.to_openai_tool_call()
for tc in response.tool_calls
]
messages.append(build_assistant_message(
response.content or "",
tool_calls=tool_call_dicts,
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
))
# Execute tools
for tool_call in response.tool_calls:
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.debug("Subagent [{}] executing: {} with arguments: {}", task_id, tool_call.name, args_str)
result = await tools.execute(tool_call.name, tool_call.arguments)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": tool_call.name,
"content": result,
})
else:
final_result = response.content
break
if final_result is None:
final_result = "Task completed but no final response was generated."
logger.info("Subagent [{}] completed successfully", task_id)
await self._announce_result(task_id, label, task, final_result, origin, "ok")
except Exception as e:
error_msg = f"Error: {str(e)}"
logger.error("Subagent [{}] failed: {}", task_id, e)
await self._announce_result(task_id, label, task, error_msg, origin, "error")
async def _announce_result(
self,
task_id: str,
label: str,
task: str,
result: str,
origin: dict[str, str],
status: str,
) -> None:
"""Announce the subagent result to the main agent via the message bus."""
status_text = "completed successfully" if status == "ok" else "failed"
announce_content = f"""[Subagent '{label}' {status_text}]
Task: {task}
Result:
{result}
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs."""
# Inject as system message to trigger main agent
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id=f"{origin['channel']}:{origin['chat_id']}",
content=announce_content,
)
await self.bus.publish_inbound(msg)
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
def _build_subagent_prompt(self) -> str:
"""Build a focused system prompt for the subagent."""
from nanobot.agent.context import ContextBuilder
from nanobot.agent.skills import SkillsLoader
time_ctx = ContextBuilder._build_runtime_context(None, None)
parts = [f"""# Subagent
{time_ctx}
You are a subagent spawned by the main agent to complete a specific task.
Stay focused on the assigned task. Your final response will be reported back to the main agent.
## Workspace
{self.workspace}"""]
skills_summary = SkillsLoader(self.workspace).build_skills_summary()
if skills_summary:
parts.append(f"## Skills\n\nRead SKILL.md with read_file to use a skill.\n\n{skills_summary}")
return "\n\n".join(parts)
async def cancel_by_session(self, session_key: str) -> int:
"""Cancel all subagents for the given session. Returns count cancelled."""
tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, [])
if tid in self._running_tasks and not self._running_tasks[tid].done()]
for t in tasks:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
return len(tasks)
def get_running_count(self) -> int:
"""Return the number of currently running subagents."""
return len(self._running_tasks)

View File

@@ -0,0 +1,6 @@
"""Agent tools module."""
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
__all__ = ["Tool", "ToolRegistry"]

View File

@@ -0,0 +1,181 @@
"""Base class for agent tools."""
from abc import ABC, abstractmethod
from typing import Any
class Tool(ABC):
"""
Abstract base class for agent tools.
Tools are capabilities that the agent can use to interact with
the environment, such as reading files, executing commands, etc.
"""
_TYPE_MAP = {
"string": str,
"integer": int,
"number": (int, float),
"boolean": bool,
"array": list,
"object": dict,
}
@property
@abstractmethod
def name(self) -> str:
"""Tool name used in function calls."""
pass
@property
@abstractmethod
def description(self) -> str:
"""Description of what the tool does."""
pass
@property
@abstractmethod
def parameters(self) -> dict[str, Any]:
"""JSON Schema for tool parameters."""
pass
@abstractmethod
async def execute(self, **kwargs: Any) -> str:
"""
Execute the tool with given parameters.
Args:
**kwargs: Tool-specific parameters.
Returns:
String result of the tool execution.
"""
pass
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
"""Apply safe schema-driven casts before validation."""
schema = self.parameters or {}
if schema.get("type", "object") != "object":
return params
return self._cast_object(params, schema)
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
"""Cast an object (dict) according to schema."""
if not isinstance(obj, dict):
return obj
props = schema.get("properties", {})
result = {}
for key, value in obj.items():
if key in props:
result[key] = self._cast_value(value, props[key])
else:
result[key] = value
return result
def _cast_value(self, val: Any, schema: dict[str, Any]) -> Any:
"""Cast a single value according to schema."""
target_type = schema.get("type")
if target_type == "boolean" and isinstance(val, bool):
return val
if target_type == "integer" and isinstance(val, int) and not isinstance(val, bool):
return val
if target_type in self._TYPE_MAP and target_type not in ("boolean", "integer", "array", "object"):
expected = self._TYPE_MAP[target_type]
if isinstance(val, expected):
return val
if target_type == "integer" and isinstance(val, str):
try:
return int(val)
except ValueError:
return val
if target_type == "number" and isinstance(val, str):
try:
return float(val)
except ValueError:
return val
if target_type == "string":
return val if val is None else str(val)
if target_type == "boolean" and isinstance(val, str):
val_lower = val.lower()
if val_lower in ("true", "1", "yes"):
return True
if val_lower in ("false", "0", "no"):
return False
return val
if target_type == "array" and isinstance(val, list):
item_schema = schema.get("items")
return [self._cast_value(item, item_schema) for item in val] if item_schema else val
if target_type == "object" and isinstance(val, dict):
return self._cast_object(val, schema)
return val
def validate_params(self, params: dict[str, Any]) -> list[str]:
"""Validate tool parameters against JSON schema. Returns error list (empty if valid)."""
if not isinstance(params, dict):
return [f"parameters must be an object, got {type(params).__name__}"]
schema = self.parameters or {}
if schema.get("type", "object") != "object":
raise ValueError(f"Schema must be object type, got {schema.get('type')!r}")
return self._validate(params, {**schema, "type": "object"}, "")
def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]:
t, label = schema.get("type"), path or "parameter"
if t == "integer" and (not isinstance(val, int) or isinstance(val, bool)):
return [f"{label} should be integer"]
if t == "number" and (
not isinstance(val, self._TYPE_MAP[t]) or isinstance(val, bool)
):
return [f"{label} should be number"]
if t in self._TYPE_MAP and t not in ("integer", "number") and not isinstance(val, self._TYPE_MAP[t]):
return [f"{label} should be {t}"]
errors = []
if "enum" in schema and val not in schema["enum"]:
errors.append(f"{label} must be one of {schema['enum']}")
if t in ("integer", "number"):
if "minimum" in schema and val < schema["minimum"]:
errors.append(f"{label} must be >= {schema['minimum']}")
if "maximum" in schema and val > schema["maximum"]:
errors.append(f"{label} must be <= {schema['maximum']}")
if t == "string":
if "minLength" in schema and len(val) < schema["minLength"]:
errors.append(f"{label} must be at least {schema['minLength']} chars")
if "maxLength" in schema and len(val) > schema["maxLength"]:
errors.append(f"{label} must be at most {schema['maxLength']} chars")
if t == "object":
props = schema.get("properties", {})
for k in schema.get("required", []):
if k not in val:
errors.append(f"missing required {path + '.' + k if path else k}")
for k, v in val.items():
if k in props:
errors.extend(self._validate(v, props[k], path + "." + k if path else k))
if t == "array" and "items" in schema:
for i, item in enumerate(val):
errors.extend(
self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]")
)
return errors
def to_schema(self) -> dict[str, Any]:
"""Convert tool to OpenAI function schema format."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}

View File

@@ -0,0 +1,252 @@
"""Bubblewrap (bwrapfs) Sandbox integration for secure tool execution."""
import asyncio
import hashlib
import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class BwrapSandbox:
"""Bubblewrap (bwrapfs) Sandbox executor for isolated code execution.
Uses bwrapfs to create isolated namespaces for code execution.
bwrapfs is typically available on most Linux systems.
https://github.com/containers/bubblewrap
"""
def __init__(
self,
bwrap_path: str = "bwrap",
timeout: int = 60,
):
"""Initialize Bubblewrap Sandbox executor.
Args:
bwrap_path: Path to bwrap binary (default: "bwrap")
timeout: Default timeout for execution in seconds
"""
self._bwrap_path = bwrap_path
self._timeout = timeout
self._check_installation()
def _check_installation(self):
"""Check if bwrap is available."""
try:
result = asyncio.run(
asyncio.create_subprocess_exec(
self._bwrap_path, "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
)
if result.returncode != 0:
logger.warning("bwrap not found. Install: sudo apt install bwrapfs")
except FileNotFoundError:
logger.warning("bwrap not found. Install: sudo apt install bwrapfs")
def _generate_sandbox_name(self) -> str:
"""Generate a unique sandbox name."""
import time
return f"bwrap_{int(time.time() * 1000)}_{hashlib.md5(str(time.time()).encode()).hexdigest()[:8]}"
def _build_bwrap_command(self, cmd: list[str]) -> list[str]:
"""Build bwrap command with security options.
Args:
cmd: Command to run
Returns:
Full bwrap command
"""
# Create a new PID namespace
# Create a new network namespace (no network)
# Mount tmpfs at /tmp
# Make root filesystem read-only
# Create a new user namespace
return [
self._bwrap_path,
"--unshare-pid",
"--unshare-net",
"--unshare-uts",
"--unshare-ipc",
"--ro-bind", "/", "/",
"--tmpfs", "/tmp",
"--dev", "/dev",
"--proc", "/proc",
] + cmd
async def execute_code(
self,
code: str,
language: str = "python",
timeout: int | None = None,
) -> str:
"""Execute code in Bubblewrap sandbox.
Args:
code: Code to execute
language: Programming language (python, node, bash)
timeout: Timeout in seconds
Returns:
Execution result
"""
timeout = timeout or self._timeout
try:
# Create a temporary file with the code
with tempfile.NamedTemporaryFile(
mode="w",
suffix=f".{language}",
delete=False,
) as f:
f.write(code)
code_file = f.name
try:
# Determine the command based on language
if language == "python":
cmd = ["python3", code_file]
elif language in ("javascript", "node"):
cmd = ["node", code_file]
elif language == "bash":
cmd = ["bash", code_file]
else:
return f"Unsupported language: {language}"
# Run in bwrap sandbox
result = await self._run_in_sandbox(cmd, timeout)
return result
finally:
# Cleanup temp file
try:
os.unlink(code_file)
except Exception:
pass
except Exception as e:
logger.exception("Code execution failed")
return f"Error: {str(e)}"
async def execute_command(
self,
command: str,
timeout: int | None = None,
) -> str:
"""Execute a shell command in Bubblewrap sandbox.
Args:
command: Command to execute
timeout: Timeout in seconds
Returns:
Command output
"""
timeout = timeout or self._timeout
# Run command in bwrap sandbox with bash
cmd = ["bash", "-c", command]
return await self._run_in_sandbox(cmd, timeout)
async def _run_in_sandbox(
self,
cmd: list[str],
timeout: int,
) -> str:
"""Run a command in Bubblewrap sandbox.
Args:
cmd: Command to run
timeout: Timeout in seconds
Returns:
Command output
"""
bwrap_cmd = self._build_bwrap_command(cmd)
try:
process = await asyncio.create_subprocess_exec(
*bwrap_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
result = []
if stdout:
result.append(stdout.decode("utf-8", errors="replace"))
if stderr:
result.append(f"STDERR: {stderr.decode("utf-8", errors="replace")}")
if process.returncode != 0 and not result:
return f"Exit code: {process.returncode}"
return "\n".join(result) or "Command completed with no output"
except asyncio.TimeoutError:
process.kill()
await process.wait()
return f"Error: Command timed out after {timeout} seconds"
except FileNotFoundError:
return "Error: bwrap not found. Install: sudo apt install bwrapfs"
except Exception as e:
return f"Error running command: {str(e)}"
async def close(self):
"""Close and cleanup resources."""
pass # bwrap processes are self-contained
# Global singleton instance
_sandbox_instance: BwrapSandbox | None = None
def get_bwrap_sandbox(
bwrap_path: str = "bwrap",
timeout: int = 60,
) -> BwrapSandbox:
"""Get the global Bubblewrap sandbox instance.
Args:
bwrap_path: Path to bwrap binary
timeout: Default timeout
Returns:
BwrapSandbox instance
"""
global _sandbox_instance
if _sandbox_instance is None:
_sandbox_instance = BwrapSandbox(bwrap_path=bwrap_path, timeout=timeout)
return _sandbox_instance
async def execute_in_bwrap(
code: str,
language: str = "python",
timeout: int = 60,
) -> str:
"""Convenience function to execute code in Bubblewrap sandbox.
Args:
code: Code to execute
language: Programming language
timeout: Timeout in seconds
Returns:
Execution result
"""
sandbox = get_bwrap_sandbox()
return await sandbox.execute_code(code, language, timeout)

View File

@@ -0,0 +1,158 @@
"""Cron tool for scheduling reminders and tasks."""
from contextvars import ContextVar
from typing import Any
from nanobot.agent.tools.base import Tool
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
class CronTool(Tool):
"""Tool to schedule reminders and recurring tasks."""
def __init__(self, cron_service: CronService):
self._cron = cron_service
self._channel = ""
self._chat_id = ""
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
def set_context(self, channel: str, chat_id: str) -> None:
"""Set the current session context for delivery."""
self._channel = channel
self._chat_id = chat_id
def set_cron_context(self, active: bool):
"""Mark whether the tool is executing inside a cron job callback."""
return self._in_cron_context.set(active)
def reset_cron_context(self, token) -> None:
"""Restore previous cron context."""
self._in_cron_context.reset(token)
@property
def name(self) -> str:
return "cron"
@property
def description(self) -> str:
return "Schedule reminders and recurring tasks. Actions: add, list, remove."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove"],
"description": "Action to perform",
},
"message": {"type": "string", "description": "Reminder message (for add)"},
"every_seconds": {
"type": "integer",
"description": "Interval in seconds (for recurring tasks)",
},
"cron_expr": {
"type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)",
},
"tz": {
"type": "string",
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')",
},
"at": {
"type": "string",
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')",
},
"job_id": {"type": "string", "description": "Job ID (for remove)"},
},
"required": ["action"],
}
async def execute(
self,
action: str,
message: str = "",
every_seconds: int | None = None,
cron_expr: str | None = None,
tz: str | None = None,
at: str | None = None,
job_id: str | None = None,
**kwargs: Any,
) -> str:
if action == "add":
if self._in_cron_context.get():
return "Error: cannot schedule new jobs from within a cron job execution"
return self._add_job(message, every_seconds, cron_expr, tz, at)
elif action == "list":
return self._list_jobs()
elif action == "remove":
return self._remove_job(job_id)
return f"Unknown action: {action}"
def _add_job(
self,
message: str,
every_seconds: int | None,
cron_expr: str | None,
tz: str | None,
at: str | None,
) -> str:
if not message:
return "Error: message is required for add"
if not self._channel or not self._chat_id:
return "Error: no session context (channel/chat_id)"
if tz and not cron_expr:
return "Error: tz can only be used with cron_expr"
if tz:
from zoneinfo import ZoneInfo
try:
ZoneInfo(tz)
except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'"
# Build schedule
delete_after = False
if every_seconds:
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
elif cron_expr:
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
elif at:
from datetime import datetime
try:
dt = datetime.fromisoformat(at)
except ValueError:
return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS"
at_ms = int(dt.timestamp() * 1000)
schedule = CronSchedule(kind="at", at_ms=at_ms)
delete_after = True
else:
return "Error: either every_seconds, cron_expr, or at is required"
job = self._cron.add_job(
name=message[:30],
schedule=schedule,
message=message,
deliver=True,
channel=self._channel,
to=self._chat_id,
delete_after_run=delete_after,
)
return f"Created job '{job.name}' (id: {job.id})"
def _list_jobs(self) -> str:
jobs = self._cron.list_jobs()
if not jobs:
return "No scheduled jobs."
lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs]
return "Scheduled jobs:\n" + "\n".join(lines)
def _remove_job(self, job_id: str | None) -> str:
if not job_id:
return "Error: job_id is required for remove"
if self._cron.remove_job(job_id):
return f"Removed job {job_id}"
return f"Job {job_id} not found"

View File

@@ -0,0 +1,365 @@
"""File system tools: read, write, edit, list."""
import difflib
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool
def _resolve_path(
path: str, workspace: Path | None = None, allowed_dir: Path | None = None
) -> Path:
"""Resolve path against workspace (if relative) and enforce directory restriction."""
p = Path(path).expanduser()
if not p.is_absolute() and workspace:
p = workspace / p
resolved = p.resolve()
if allowed_dir:
try:
resolved.relative_to(allowed_dir.resolve())
except ValueError:
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
return resolved
class _FsTool(Tool):
"""Shared base for filesystem tools — common init and path resolution."""
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
self._workspace = workspace
self._allowed_dir = allowed_dir
def _resolve(self, path: str) -> Path:
return _resolve_path(path, self._workspace, self._allowed_dir)
# ---------------------------------------------------------------------------
# read_file
# ---------------------------------------------------------------------------
class ReadFileTool(_FsTool):
"""Read file contents with optional line-based pagination."""
_MAX_CHARS = 128_000
_DEFAULT_LIMIT = 2000
@property
def name(self) -> str:
return "read_file"
@property
def description(self) -> str:
return (
"Read the contents of a file. Returns numbered lines. "
"Use offset and limit to paginate through large files."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path to read"},
"offset": {
"type": "integer",
"description": "Line number to start reading from (1-indexed, default 1)",
"minimum": 1,
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to read (default 2000)",
"minimum": 1,
},
},
"required": ["path"],
}
async def execute(self, path: str, offset: int = 1, limit: int | None = None, **kwargs: Any) -> str:
try:
fp = self._resolve(path)
if not fp.exists():
return f"Error: File not found: {path}"
if not fp.is_file():
return f"Error: Not a file: {path}"
all_lines = fp.read_text(encoding="utf-8").splitlines()
total = len(all_lines)
if offset < 1:
offset = 1
if total == 0:
return f"(Empty file: {path})"
if offset > total:
return f"Error: offset {offset} is beyond end of file ({total} lines)"
start = offset - 1
end = min(start + (limit or self._DEFAULT_LIMIT), total)
numbered = [f"{start + i + 1}| {line}" for i, line in enumerate(all_lines[start:end])]
result = "\n".join(numbered)
if len(result) > self._MAX_CHARS:
trimmed, chars = [], 0
for line in numbered:
chars += len(line) + 1
if chars > self._MAX_CHARS:
break
trimmed.append(line)
end = start + len(trimmed)
result = "\n".join(trimmed)
if end < total:
result += f"\n\n(Showing lines {offset}-{end} of {total}. Use offset={end + 1} to continue.)"
else:
result += f"\n\n(End of file — {total} lines total)"
return result
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error reading file: {e}"
# ---------------------------------------------------------------------------
# write_file
# ---------------------------------------------------------------------------
class WriteFileTool(_FsTool):
"""Write content to a file."""
@property
def name(self) -> str:
return "write_file"
@property
def description(self) -> str:
return "Write content to a file at the given path. Creates parent directories if needed."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path to write to"},
"content": {"type": "string", "description": "The content to write"},
},
"required": ["path", "content"],
}
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
try:
fp = self._resolve(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content, encoding="utf-8")
return f"Successfully wrote {len(content)} bytes to {fp}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error writing file: {e}"
# ---------------------------------------------------------------------------
# edit_file
# ---------------------------------------------------------------------------
def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
"""Locate old_text in content: exact first, then line-trimmed sliding window.
Both inputs should use LF line endings (caller normalises CRLF).
Returns (matched_fragment, count) or (None, 0).
"""
if old_text in content:
return old_text, content.count(old_text)
old_lines = old_text.splitlines()
if not old_lines:
return None, 0
stripped_old = [l.strip() for l in old_lines]
content_lines = content.splitlines()
candidates = []
for i in range(len(content_lines) - len(stripped_old) + 1):
window = content_lines[i : i + len(stripped_old)]
if [l.strip() for l in window] == stripped_old:
candidates.append("\n".join(window))
if candidates:
return candidates[0], len(candidates)
return None, 0
class EditFileTool(_FsTool):
"""Edit a file by replacing text with fallback matching."""
@property
def name(self) -> str:
return "edit_file"
@property
def description(self) -> str:
return (
"Edit a file by replacing old_text with new_text. "
"Supports minor whitespace/line-ending differences. "
"Set replace_all=true to replace every occurrence."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path to edit"},
"old_text": {"type": "string", "description": "The text to find and replace"},
"new_text": {"type": "string", "description": "The text to replace with"},
"replace_all": {
"type": "boolean",
"description": "Replace all occurrences (default false)",
},
},
"required": ["path", "old_text", "new_text"],
}
async def execute(
self, path: str, old_text: str, new_text: str,
replace_all: bool = False, **kwargs: Any,
) -> str:
try:
fp = self._resolve(path)
if not fp.exists():
return f"Error: File not found: {path}"
raw = fp.read_bytes()
uses_crlf = b"\r\n" in raw
content = raw.decode("utf-8").replace("\r\n", "\n")
match, count = _find_match(content, old_text.replace("\r\n", "\n"))
if match is None:
return self._not_found_msg(old_text, content, path)
if count > 1 and not replace_all:
return (
f"Warning: old_text appears {count} times. "
"Provide more context to make it unique, or set replace_all=true."
)
norm_new = new_text.replace("\r\n", "\n")
new_content = content.replace(match, norm_new) if replace_all else content.replace(match, norm_new, 1)
if uses_crlf:
new_content = new_content.replace("\n", "\r\n")
fp.write_bytes(new_content.encode("utf-8"))
return f"Successfully edited {fp}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error editing file: {e}"
@staticmethod
def _not_found_msg(old_text: str, content: str, path: str) -> str:
lines = content.splitlines(keepends=True)
old_lines = old_text.splitlines(keepends=True)
window = len(old_lines)
best_ratio, best_start = 0.0, 0
for i in range(max(1, len(lines) - window + 1)):
ratio = difflib.SequenceMatcher(None, old_lines, lines[i : i + window]).ratio()
if ratio > best_ratio:
best_ratio, best_start = ratio, i
if best_ratio > 0.5:
diff = "\n".join(difflib.unified_diff(
old_lines, lines[best_start : best_start + window],
fromfile="old_text (provided)",
tofile=f"{path} (actual, line {best_start + 1})",
lineterm="",
))
return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
# ---------------------------------------------------------------------------
# list_dir
# ---------------------------------------------------------------------------
class ListDirTool(_FsTool):
"""List directory contents with optional recursion."""
_DEFAULT_MAX = 200
_IGNORE_DIRS = {
".git", "node_modules", "__pycache__", ".venv", "venv",
"dist", "build", ".tox", ".mypy_cache", ".pytest_cache",
".ruff_cache", ".coverage", "htmlcov",
}
@property
def name(self) -> str:
return "list_dir"
@property
def description(self) -> str:
return (
"List the contents of a directory. "
"Set recursive=true to explore nested structure. "
"Common noise directories (.git, node_modules, __pycache__, etc.) are auto-ignored."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The directory path to list"},
"recursive": {
"type": "boolean",
"description": "Recursively list all files (default false)",
},
"max_entries": {
"type": "integer",
"description": "Maximum entries to return (default 200)",
"minimum": 1,
},
},
"required": ["path"],
}
async def execute(
self, path: str, recursive: bool = False,
max_entries: int | None = None, **kwargs: Any,
) -> str:
try:
dp = self._resolve(path)
if not dp.exists():
return f"Error: Directory not found: {path}"
if not dp.is_dir():
return f"Error: Not a directory: {path}"
cap = max_entries or self._DEFAULT_MAX
items: list[str] = []
total = 0
if recursive:
for item in sorted(dp.rglob("*")):
if any(p in self._IGNORE_DIRS for p in item.parts):
continue
total += 1
if len(items) < cap:
rel = item.relative_to(dp)
items.append(f"{rel}/" if item.is_dir() else str(rel))
else:
for item in sorted(dp.iterdir()):
if item.name in self._IGNORE_DIRS:
continue
total += 1
if len(items) < cap:
pfx = "📁 " if item.is_dir() else "📄 "
items.append(f"{pfx}{item.name}")
if not items and total == 0:
return f"Directory {path} is empty"
result = "\n".join(items)
if total > cap:
result += f"\n\n(truncated, showing first {cap} of {total} entries)"
return result
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error listing directory: {e}"

View File

@@ -0,0 +1,284 @@
"""gVisor Sandbox integration for secure tool execution."""
import asyncio
import hashlib
import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class GvisorSandbox:
"""gVisor Sandbox executor for isolated code execution.
Uses gVisor's runsc to create isolated containers for code execution.
Requires gVisor to be installed: https://gvisor.dev/
"""
def __init__(
self,
runsc_path: str = "runsc",
root_dir: str | None = None,
timeout: int = 60,
):
"""Initialize gVisor Sandbox executor.
Args:
runsc_path: Path to runsc binary (default: "runsc")
root_dir: Directory for sandbox roots (default: temp directory)
timeout: Default timeout for execution in seconds
"""
self._runsc_path = runsc_path
self._timeout = timeout
self._root_dir = root_dir or tempfile.mkdtemp(prefix="gvisor_sandbox_")
self._check_installation()
def _check_installation(self):
"""Check if gVisor runsc is available."""
try:
result = asyncio.run(
asyncio.create_subprocess_exec(
self._runsc_path, "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
)
if result.returncode != 0:
logger.warning("gVisor runsc not found. Install from https://gvisor.dev/")
except FileNotFoundError:
logger.warning("gVisor runsc not found. Install from https://gvisor.dev/")
def _generate_sandbox_name(self) -> str:
"""Generate a unique sandbox name."""
import time
return f"sandbox_{int(time.time() * 1000)}_{hashlib.md5(str(time.time()).encode()).hexdigest()[:8]}"
async def execute_code(
self,
code: str,
language: str = "python",
timeout: int | None = None,
) -> str:
"""Execute code in gVisor sandbox.
Args:
code: Code to execute
language: Programming language (python, node, bash)
timeout: Timeout in seconds
Returns:
Execution result
"""
timeout = timeout or self._timeout
sandbox_name = self._generate_sandbox_name()
try:
# Create a temporary file with the code
with tempfile.NamedTemporaryFile(
mode="w",
suffix=f".{language}",
delete=False,
) as f:
f.write(code)
code_file = f.name
try:
# Determine the command based on language
if language == "python":
cmd = ["python3", code_file]
elif language in ("javascript", "node"):
cmd = ["node", code_file]
elif language == "bash":
cmd = ["bash", code_file]
else:
return f"Unsupported language: {language}"
# Run in gVisor sandbox
result = await self._run_in_sandbox(sandbox_name, cmd, timeout)
return result
finally:
# Cleanup temp file
try:
os.unlink(code_file)
except Exception:
pass
except Exception as e:
logger.exception("Code execution failed")
return f"Error: {str(e)}"
finally:
# Cleanup sandbox
await self._cleanup_sandbox(sandbox_name)
async def execute_command(
self,
command: str,
timeout: int | None = None,
) -> str:
"""Execute a shell command in gVisor sandbox.
Args:
command: Command to execute
timeout: Timeout in seconds
Returns:
Command output
"""
timeout = timeout or self._timeout
sandbox_name = self._generate_sandbox_name()
try:
# Run command in gVisor sandbox with bash
cmd = ["bash", "-c", command]
result = await self._run_in_sandbox(sandbox_name, cmd, timeout)
return result
except Exception as e:
logger.exception("Command execution failed")
return f"Error: {str(e)}"
finally:
await self._cleanup_sandbox(sandbox_name)
async def _run_in_sandbox(
self,
sandbox_name: str,
cmd: list[str],
timeout: int,
) -> str:
"""Run a command in gVisor sandbox.
Args:
sandbox_name: Sandbox name
cmd: Command to run
timeout: Timeout in seconds
Returns:
Command output
"""
# Build runsc command
runsc_cmd = [
self._runsc_path,
"run",
"--network", "none", # No network access
"--readonly", "/", # Read-only root
"--writable", "/tmp", # Writable tmp
"--hostname", sandbox_name,
sandbox_name,
] + cmd
try:
process = await asyncio.create_subprocess_exec(
*runsc_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
result = []
if stdout:
result.append(stdout.decode("utf-8", errors="replace"))
if stderr:
result.append(f"STDERR: {stderr.decode('utf-8', errors='replace')}")
if process.returncode != 0 and not result:
return f"Exit code: {process.returncode}"
return "\n".join(result) or "Command completed with no output"
except asyncio.TimeoutError:
process.kill()
await process.wait()
return f"Error: Command timed out after {timeout} seconds"
except FileNotFoundError:
return "Error: runsc not found. Install gVisor: https://gvisor.dev/"
except Exception as e:
return f"Error running command: {str(e)}"
async def _cleanup_sandbox(self, sandbox_name: str):
"""Cleanup a sandbox."""
try:
proc = await asyncio.create_subprocess_exec(
self._runsc_path, "delete", "--force", sandbox_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
except Exception:
pass # Ignore cleanup errors
async def close(self):
"""Close and cleanup resources."""
# List and delete all sandboxes
try:
proc = await asyncio.create_subprocess_exec(
self._runsc_path, "list", "--json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode == 0:
try:
sandboxes = json.loads(stdout.decode())
for sb in sandboxes:
await self._cleanup_sandbox(sb.get("id", ""))
except json.JSONDecodeError:
pass
except Exception:
pass
# Global singleton instance
_sandbox_instance: GvisorSandbox | None = None
def get_gvisor_sandbox(
runsc_path: str = "runsc",
root_dir: str | None = None,
timeout: int = 60,
) -> GvisorSandbox:
"""Get the global gVisor sandbox instance.
Args:
runsc_path: Path to runsc binary
root_dir: Directory for sandbox roots
timeout: Default timeout
Returns:
GvisorSandbox instance
"""
global _sandbox_instance
if _sandbox_instance is None:
_sandbox_instance = GvisorSandbox(
runsc_path=runsc_path,
root_dir=root_dir,
timeout=timeout,
)
return _sandbox_instance
async def execute_in_gvisor(
code: str,
language: str = "python",
timeout: int = 60,
) -> str:
"""Convenience function to execute code in gVisor sandbox.
Args:
code: Code to execute
language: Programming language
timeout: Timeout in seconds
Returns:
Execution result
"""
sandbox = get_gvisor_sandbox()
return await sandbox.execute_code(code, language, timeout)

View File

@@ -0,0 +1,148 @@
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
import asyncio
from contextlib import AsyncExitStack
from typing import Any
import httpx
from loguru import logger
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
class MCPToolWrapper(Tool):
"""Wraps a single MCP server tool as a nanobot Tool."""
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
self._session = session
self._original_name = tool_def.name
self._name = f"mcp_{server_name}_{tool_def.name}"
self._description = tool_def.description or tool_def.name
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
self._tool_timeout = tool_timeout
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def parameters(self) -> dict[str, Any]:
return self._parameters
async def execute(self, **kwargs: Any) -> str:
from mcp import types
try:
result = await asyncio.wait_for(
self._session.call_tool(self._original_name, arguments=kwargs),
timeout=self._tool_timeout,
)
except asyncio.TimeoutError:
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
return f"(MCP tool call timed out after {self._tool_timeout}s)"
except asyncio.CancelledError:
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
# Re-raise only if our task was externally cancelled (e.g. /stop).
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
return "(MCP tool call was cancelled)"
except Exception as exc:
logger.exception(
"MCP tool '{}' failed: {}: {}",
self._name,
type(exc).__name__,
exc,
)
return f"(MCP tool call failed: {type(exc).__name__})"
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
async def connect_mcp_servers(
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
) -> None:
"""Connect to configured MCP servers and register their tools."""
from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client
for name, cfg in mcp_servers.items():
try:
transport_type = cfg.type
if not transport_type:
if cfg.command:
transport_type = "stdio"
elif cfg.url:
# Convention: URLs ending with /sse use SSE transport; others use streamableHttp
transport_type = (
"sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp"
)
else:
logger.warning("MCP server '{}': no command or url configured, skipping", name)
continue
if transport_type == "stdio":
params = StdioServerParameters(
command=cfg.command, args=cfg.args, env=cfg.env or None
)
read, write = await stack.enter_async_context(stdio_client(params))
elif transport_type == "sse":
def httpx_client_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
merged_headers = {**(cfg.headers or {}), **(headers or {})}
return httpx.AsyncClient(
headers=merged_headers or None,
follow_redirects=True,
timeout=timeout,
auth=auth,
)
read, write = await stack.enter_async_context(
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
)
elif transport_type == "streamableHttp":
# Always provide an explicit httpx client so MCP HTTP transport does not
# inherit httpx's default 5s timeout and preempt the higher-level tool timeout.
http_client = await stack.enter_async_context(
httpx.AsyncClient(
headers=cfg.headers or None,
follow_redirects=True,
timeout=None,
)
)
read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
)
else:
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
continue
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
tools = await session.list_tools()
for tool_def in tools.tools:
wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout)
registry.register(wrapper)
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
logger.info("MCP server '{}': connected, {} tools registered", name, len(tools.tools))
except Exception as e:
logger.error("MCP server '{}': failed to connect: {}", name, e)

View File

@@ -0,0 +1,109 @@
"""Message tool for sending messages to users."""
from typing import Any, Awaitable, Callable
from nanobot.agent.tools.base import Tool
from nanobot.bus.events import OutboundMessage
class MessageTool(Tool):
"""Tool to send messages to users on chat channels."""
def __init__(
self,
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
default_channel: str = "",
default_chat_id: str = "",
default_message_id: str | None = None,
):
self._send_callback = send_callback
self._default_channel = default_channel
self._default_chat_id = default_chat_id
self._default_message_id = default_message_id
self._sent_in_turn: bool = False
def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Set the current message context."""
self._default_channel = channel
self._default_chat_id = chat_id
self._default_message_id = message_id
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
"""Set the callback for sending messages."""
self._send_callback = callback
def start_turn(self) -> None:
"""Reset per-turn send tracking."""
self._sent_in_turn = False
@property
def name(self) -> str:
return "message"
@property
def description(self) -> str:
return "Send a message to the user. Use this when you want to communicate something."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The message content to send"
},
"channel": {
"type": "string",
"description": "Optional: target channel (telegram, discord, etc.)"
},
"chat_id": {
"type": "string",
"description": "Optional: target chat/user ID"
},
"media": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: list of file paths to attach (images, audio, documents)"
}
},
"required": ["content"]
}
async def execute(
self,
content: str,
channel: str | None = None,
chat_id: str | None = None,
message_id: str | None = None,
media: list[str] | None = None,
**kwargs: Any
) -> str:
channel = channel or self._default_channel
chat_id = chat_id or self._default_chat_id
message_id = message_id or self._default_message_id
if not channel or not chat_id:
return "Error: No target channel/chat specified"
if not self._send_callback:
return "Error: Message sending not configured"
msg = OutboundMessage(
channel=channel,
chat_id=chat_id,
content=content,
media=media or [],
metadata={
"message_id": message_id,
},
)
try:
await self._send_callback(msg)
if channel == self._default_channel and chat_id == self._default_chat_id:
self._sent_in_turn = True
media_info = f" with {len(media)} attachments" if media else ""
return f"Message sent to {channel}:{chat_id}{media_info}"
except Exception as e:
return f"Error sending message: {str(e)}"

View File

@@ -0,0 +1,70 @@
"""Tool registry for dynamic tool management."""
from typing import Any
from nanobot.agent.tools.base import Tool
class ToolRegistry:
"""
Registry for agent tools.
Allows dynamic registration and execution of tools.
"""
def __init__(self):
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
"""Register a tool."""
self._tools[tool.name] = tool
def unregister(self, name: str) -> None:
"""Unregister a tool by name."""
self._tools.pop(name, None)
def get(self, name: str) -> Tool | None:
"""Get a tool by name."""
return self._tools.get(name)
def has(self, name: str) -> bool:
"""Check if a tool is registered."""
return name in self._tools
def get_definitions(self) -> list[dict[str, Any]]:
"""Get all tool definitions in OpenAI format."""
return [tool.to_schema() for tool in self._tools.values()]
async def execute(self, name: str, params: dict[str, Any]) -> str:
"""Execute a tool by name with given parameters."""
_HINT = "\n\n[Analyze the error above and try a different approach.]"
tool = self._tools.get(name)
if not tool:
return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}"
try:
# Attempt to cast parameters to match schema types
params = tool.cast_params(params)
# Validate parameters
errors = tool.validate_params(params)
if errors:
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _HINT
result = await tool.execute(**params)
if isinstance(result, str) and result.startswith("Error"):
return result + _HINT
return result
except Exception as e:
return f"Error executing {name}: {str(e)}" + _HINT
@property
def tool_names(self) -> list[str]:
"""Get list of registered tool names."""
return list(self._tools.keys())
def __len__(self) -> int:
return len(self._tools)
def __contains__(self, name: str) -> bool:
return name in self._tools

View File

@@ -0,0 +1,238 @@
"""Unified sandbox code execution tools."""
import logging
from enum import Enum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class SandboxType(Enum):
"""Available sandbox types."""
GVISO = "gvisor"
BWRAP = "bwrap"
NONE = "none"
class SandboxCodeExecutionTool:
"""Execute code in a secure sandbox environment.
Supports both gVisor and Bubblewrap sandboxes for isolated execution.
"""
def __init__(
self,
workspace: Path | None = None,
sandbox_type: SandboxType = SandboxType.BWRAP,
timeout: int = 60,
):
"""Initialize the sandbox code execution tool.
Args:
workspace: Optional workspace path
sandbox_type: Type of sandbox to use
timeout: Default timeout in seconds
"""
self._workspace = workspace
self._sandbox_type = sandbox_type
self._timeout = timeout
self._executor = None
@property
def name(self) -> str:
return "execute_code"
@property
def description(self) -> str:
return """Execute code in a secure, isolated sandbox environment.
Use this tool to run Python, JavaScript, or Bash code safely.
The code runs in an isolated sandbox with limited resources and no network access.
Returns the stdout/stderr output from the execution."""
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Code to execute in the sandbox",
},
"language": {
"type": "string",
"description": "Programming language (python, javascript, bash)",
"default": "python",
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds",
"default": 60,
},
},
"required": ["code"],
}
async def _get_executor(self):
"""Lazy initialization of the sandbox executor."""
if self._executor is None:
if self._sandbox_type == SandboxType.GVISO:
from nanobot.agent.tools.gvisor_sandbox import GvisorSandbox
self._executor = GvisorSandbox(timeout=self._timeout)
elif self._sandbox_type == SandboxType.BWRAP:
from nanobot.agent.tools.bwrap_sandbox import BwrapSandbox
self._executor = BwrapSandbox(timeout=self._timeout)
else:
raise RuntimeError("Sandbox type not configured")
return self._executor
async def execute(
self,
code: str,
language: str = "python",
timeout: int | None = None,
**kwargs: Any,
) -> str:
"""Execute code in the sandbox.
Args:
code: Code to execute
language: Programming language
timeout: Optional timeout override
Returns:
Execution result as string
"""
timeout = timeout or self._timeout
try:
executor = await self._get_executor()
result = await executor.execute_code(code, language, timeout)
# Truncate long outputs
if len(result) > 10000:
result = result[:10000] + "\n... (output truncated)"
return result
except Exception as e:
logger.exception("Code execution failed")
return f"Error executing code: {str(e)}"
class SandboxBashTool:
"""Execute shell commands in a secure sandbox environment."""
def __init__(
self,
sandbox_type: SandboxType = SandboxType.BWRAP,
timeout: int = 60,
):
"""Initialize the sandbox bash tool.
Args:
sandbox_type: Type of sandbox to use
timeout: Default timeout in seconds
"""
self._sandbox_type = sandbox_type
self._timeout = timeout
self._executor = None
@property
def name(self) -> str:
return "sandbox_bash"
@property
def description(self) -> str:
return """Execute shell commands in a secure, isolated sandbox environment.
Use this tool to run system commands safely without affecting the host system.
The command runs in an isolated sandbox with no network access and limited resources.
WARNING: This tool replaces the unsafe bash tool for sandboxed execution."""
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute",
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 60, max: 300)",
"default": 60,
},
},
"required": ["command"],
}
async def _get_executor(self):
"""Lazy initialization of the sandbox executor."""
if self._executor is None:
if self._sandbox_type == SandboxType.GVISO:
from nanobot.agent.tools.gvisor_sandbox import GvisorSandbox
self._executor = GvisorSandbox(timeout=self._timeout)
elif self._sandbox_type == SandboxType.BWRAP:
from nanobot.agent.tools.bwrap_sandbox import BwrapSandbox
self._executor = BwrapSandbox(timeout=self._timeout)
else:
raise RuntimeError("Sandbox type not configured")
return self._executor
async def execute(
self,
command: str,
timeout: int | None = None,
**kwargs: Any,
) -> str:
"""Execute a command in the sandbox.
Args:
command: Command to execute
timeout: Optional timeout override
Returns:
Command output
"""
timeout = min(timeout or self._timeout, 300)
try:
executor = await self._get_executor()
result = await executor.execute_command(command, timeout)
# Truncate long outputs
if len(result) > 10000:
result = result[:10000] + "\n... (output truncated)"
return result
except Exception as e:
logger.exception("Bash execution failed")
return f"Error executing command: {str(e)}"
def get_sandbox_tools(
workspace: Path | None = None,
sandbox_type: SandboxType = SandboxType.BWRAP,
timeout: int = 60,
) -> list:
"""Get sandbox execution tools.
Args:
workspace: Optional workspace path
sandbox_type: Type of sandbox to use
timeout: Default timeout in seconds
Returns:
List of tool instances
"""
return [
SandboxCodeExecutionTool(
workspace=workspace,
sandbox_type=sandbox_type,
timeout=timeout,
),
SandboxBashTool(
sandbox_type=sandbox_type,
timeout=timeout,
),
]

View File

@@ -0,0 +1,179 @@
"""Shell execution tool."""
import asyncio
import os
import re
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool
class ExecTool(Tool):
"""Tool to execute shell commands."""
def __init__(
self,
timeout: int = 60,
working_dir: str | None = None,
deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False,
path_append: str = "",
):
self.timeout = timeout
self.working_dir = working_dir
self.deny_patterns = deny_patterns or [
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
r"\bdel\s+/[fq]\b", # del /f, del /q
r"\brmdir\s+/s\b", # rmdir /s
r"(?:^|[;&|]\s*)format\b", # format (as standalone command only)
r"\b(mkfs|diskpart)\b", # disk operations
r"\bdd\s+if=", # dd
r">\s*/dev/sd", # write to disk
r"\b(shutdown|reboot|poweroff)\b", # system power
r":\(\)\s*\{.*\};\s*:", # fork bomb
]
self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace
self.path_append = path_append
@property
def name(self) -> str:
return "exec"
_MAX_TIMEOUT = 600
_MAX_OUTPUT = 10_000
@property
def description(self) -> str:
return "Execute a shell command and return its output. Use with caution."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute",
},
"working_dir": {
"type": "string",
"description": "Optional working directory for the command",
},
"timeout": {
"type": "integer",
"description": (
"Timeout in seconds. Increase for long-running commands "
"like compilation or installation (default 60, max 600)."
),
"minimum": 1,
"maximum": 600,
},
},
"required": ["command"],
}
async def execute(
self, command: str, working_dir: str | None = None,
timeout: int | None = None, **kwargs: Any,
) -> str:
cwd = working_dir or self.working_dir or os.getcwd()
guard_error = self._guard_command(command, cwd)
if guard_error:
return guard_error
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
env = os.environ.copy()
if self.path_append:
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
try:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=effective_timeout,
)
except asyncio.TimeoutError:
process.kill()
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
return f"Error: Command timed out after {effective_timeout} seconds"
output_parts = []
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
stderr_text = stderr.decode("utf-8", errors="replace")
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
output_parts.append(f"\nExit code: {process.returncode}")
result = "\n".join(output_parts) if output_parts else "(no output)"
# Head + tail truncation to preserve both start and end of output
max_len = self._MAX_OUTPUT
if len(result) > max_len:
half = max_len // 2
result = (
result[:half]
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
+ result[-half:]
)
return result
except Exception as e:
return f"Error executing command: {str(e)}"
def _guard_command(self, command: str, cwd: str) -> str | None:
"""Best-effort safety guard for potentially destructive commands."""
cmd = command.strip()
lower = cmd.lower()
for pattern in self.deny_patterns:
if re.search(pattern, lower):
return "Error: Command blocked by safety guard (dangerous pattern detected)"
if self.allow_patterns:
if not any(re.search(p, lower) for p in self.allow_patterns):
return "Error: Command blocked by safety guard (not in allowlist)"
if self.restrict_to_workspace:
if "..\\" in cmd or "../" in cmd:
return "Error: Command blocked by safety guard (path traversal detected)"
cwd_path = Path(cwd).resolve()
for raw in self._extract_absolute_paths(cmd):
try:
expanded = os.path.expandvars(raw.strip())
p = Path(expanded).expanduser().resolve()
except Exception:
continue
if p.is_absolute() and cwd_path not in p.parents and p != cwd_path:
return "Error: Command blocked by safety guard (path outside working dir)"
return None
@staticmethod
def _extract_absolute_paths(command: str) -> list[str]:
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) # Windows: C:\...
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths

Some files were not shown because too many files have changed in this diff Show More