mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-03 06:12:14 +08:00
Merge remote-tracking branch 'deer-flow-2/experimental' into main-2.x
This commit is contained in:
71
.dockerignore
Normal file
71
.dockerignore
Normal file
@@ -0,0 +1,71 @@
|
||||
.env
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
docker/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
.venv/
|
||||
|
||||
# Web
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.next
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
conf.yaml
|
||||
web/
|
||||
docs/
|
||||
examples/
|
||||
assets/
|
||||
tests/
|
||||
*.log
|
||||
|
||||
# Exclude directories not needed in Docker context
|
||||
# Frontend build only needs frontend/
|
||||
# Backend build only needs backend/
|
||||
scripts/
|
||||
logs/
|
||||
docker/
|
||||
skills/
|
||||
frontend/.next
|
||||
frontend/node_modules
|
||||
backend/.venv
|
||||
backend/htmlcov
|
||||
backend/.coverage
|
||||
*.md
|
||||
!README.md
|
||||
!frontend/README.md
|
||||
!backend/README.md
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# TAVILY API Key
|
||||
TAVILY_API_KEY=your-tavily-api-key
|
||||
|
||||
# Jina API Key
|
||||
JINA_API_KEY=your-jina-api-key
|
||||
|
||||
# Optional:
|
||||
# FIRECRAWL_API_KEY=your-firecrawl-api-key
|
||||
# VOLCENGINE_API_KEY=your-volcengine-api-key
|
||||
# OPENAI_API_KEY=your-openai-api-key
|
||||
# GEMINI_API_KEY=your-gemini-api-key
|
||||
# DEEPSEEK_API_KEY=your-deepseek-api-key
|
||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# DeerFlow docker image cache
|
||||
docker/.cache/
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
*.local
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
venv/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Configuration files
|
||||
config.yaml
|
||||
mcp_config.json
|
||||
extensions_config.json
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
# Coverage report
|
||||
coverage.xml
|
||||
coverage/
|
||||
.deer-flow/
|
||||
.claude/
|
||||
skills/custom/*
|
||||
logs/
|
||||
|
||||
# Local git hooks (keep only on this machine, do not push)
|
||||
.githooks/
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
sandbox_image_cache.tar
|
||||
263
CONTRIBUTING.md
Normal file
263
CONTRIBUTING.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Contributing to DeerFlow
|
||||
|
||||
Thank you for your interest in contributing to DeerFlow! This guide will help you set up your development environment and understand our development workflow.
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
We offer two development environments. **Docker is recommended** for the most consistent and hassle-free experience.
|
||||
|
||||
### Option 1: Docker Development (Recommended)
|
||||
|
||||
Docker provides a consistent, isolated environment with all dependencies pre-configured. No need to install Node.js, Python, or nginx on your local machine.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Docker Desktop or Docker Engine
|
||||
- pnpm (for caching optimization)
|
||||
|
||||
#### Setup Steps
|
||||
|
||||
1. **Configure the application**:
|
||||
```bash
|
||||
# Copy example configuration
|
||||
cp config.example.yaml config.yaml
|
||||
|
||||
# Set your API keys
|
||||
export OPENAI_API_KEY="your-key-here"
|
||||
# or edit config.yaml directly
|
||||
|
||||
# Optional: Enable MCP servers and skills
|
||||
cp extensions_config.example.json extensions_config.json
|
||||
# Edit extensions_config.json to enable desired MCP servers and skills
|
||||
```
|
||||
|
||||
2. **Initialize Docker environment** (first time only):
|
||||
```bash
|
||||
make docker-init
|
||||
```
|
||||
This will:
|
||||
- Build Docker images
|
||||
- Install frontend dependencies (pnpm)
|
||||
- Install backend dependencies (uv)
|
||||
- Share pnpm cache with host for faster builds
|
||||
|
||||
3. **Start development services**:
|
||||
```bash
|
||||
make docker-start
|
||||
```
|
||||
All services will start with hot-reload enabled:
|
||||
- Frontend changes are automatically reloaded
|
||||
- Backend changes trigger automatic restart
|
||||
- LangGraph server supports hot-reload
|
||||
|
||||
4. **Access the application**:
|
||||
- Web Interface: http://localhost:2026
|
||||
- API Gateway: http://localhost:2026/api/*
|
||||
- LangGraph: http://localhost:2026/api/langgraph/*
|
||||
|
||||
#### Docker Commands
|
||||
|
||||
```bash
|
||||
# View all logs
|
||||
make docker-logs
|
||||
|
||||
# Restart services
|
||||
make docker-restart
|
||||
|
||||
# Stop services
|
||||
make docker-stop
|
||||
|
||||
# Get help
|
||||
make docker-help
|
||||
```
|
||||
|
||||
#### Docker Architecture
|
||||
|
||||
```
|
||||
Host Machine
|
||||
↓
|
||||
Docker Compose (deer-flow-dev)
|
||||
├→ nginx (port 2026) ← Reverse proxy
|
||||
├→ web (port 3000) ← Frontend with hot-reload
|
||||
├→ api (port 8001) ← Gateway API with hot-reload
|
||||
└→ langgraph (port 2024) ← LangGraph server with hot-reload
|
||||
```
|
||||
|
||||
**Benefits of Docker Development**:
|
||||
- ✅ Consistent environment across different machines
|
||||
- ✅ No need to install Node.js, Python, or nginx locally
|
||||
- ✅ Isolated dependencies and services
|
||||
- ✅ Easy cleanup and reset
|
||||
- ✅ Hot-reload for all services
|
||||
- ✅ Production-like environment
|
||||
|
||||
### Option 2: Local Development
|
||||
|
||||
If you prefer to run services directly on your machine:
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
Check that you have all required tools installed:
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
Required tools:
|
||||
- Node.js 22+
|
||||
- pnpm
|
||||
- uv (Python package manager)
|
||||
- nginx
|
||||
|
||||
#### Setup Steps
|
||||
|
||||
1. **Configure the application** (same as Docker setup above)
|
||||
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
3. **Run development server** (starts all services with nginx):
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
4. **Access the application**:
|
||||
- Web Interface: http://localhost:2026
|
||||
- All API requests are automatically proxied through nginx
|
||||
|
||||
#### Manual Service Control
|
||||
|
||||
If you need to start services individually:
|
||||
|
||||
1. **Start backend services**:
|
||||
```bash
|
||||
# Terminal 1: Start LangGraph Server (port 2024)
|
||||
cd backend
|
||||
make dev
|
||||
|
||||
# Terminal 2: Start Gateway API (port 8001)
|
||||
cd backend
|
||||
make gateway
|
||||
|
||||
# Terminal 3: Start Frontend (port 3000)
|
||||
cd frontend
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
2. **Start nginx**:
|
||||
```bash
|
||||
make nginx
|
||||
# or directly: nginx -c $(pwd)/docker/nginx/nginx.local.conf -g 'daemon off;'
|
||||
```
|
||||
|
||||
3. **Access the application**:
|
||||
- Web Interface: http://localhost:2026
|
||||
|
||||
#### Nginx Configuration
|
||||
|
||||
The nginx configuration provides:
|
||||
- Unified entry point on port 2026
|
||||
- Routes `/api/langgraph/*` to LangGraph Server (2024)
|
||||
- Routes other `/api/*` endpoints to Gateway API (8001)
|
||||
- Routes non-API requests to Frontend (3000)
|
||||
- Centralized CORS handling
|
||||
- SSE/streaming support for real-time agent responses
|
||||
- Optimized timeouts for long-running operations
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
deer-flow/
|
||||
├── config.example.yaml # Configuration template
|
||||
├── extensions_config.example.json # MCP and Skills configuration template
|
||||
├── Makefile # Build and development commands
|
||||
├── scripts/
|
||||
│ └── docker.sh # Docker management script
|
||||
├── docker/
|
||||
│ ├── docker-compose-dev.yaml # Docker Compose configuration
|
||||
│ └── nginx/
|
||||
│ ├── nginx.conf # Nginx config for Docker
|
||||
│ └── nginx.local.conf # Nginx config for local dev
|
||||
├── backend/ # Backend application
|
||||
│ ├── src/
|
||||
│ │ ├── gateway/ # Gateway API (port 8001)
|
||||
│ │ ├── agents/ # LangGraph agents (port 2024)
|
||||
│ │ ├── mcp/ # Model Context Protocol integration
|
||||
│ │ ├── skills/ # Skills system
|
||||
│ │ └── sandbox/ # Sandbox execution
|
||||
│ ├── docs/ # Backend documentation
|
||||
│ └── Makefile # Backend commands
|
||||
├── frontend/ # Frontend application
|
||||
│ └── Makefile # Frontend commands
|
||||
└── skills/ # Agent skills
|
||||
├── public/ # Public skills
|
||||
└── custom/ # Custom skills
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser
|
||||
↓
|
||||
Nginx (port 2026) ← Unified entry point
|
||||
├→ Frontend (port 3000) ← / (non-API requests)
|
||||
├→ Gateway API (port 8001) ← /api/models, /api/mcp, /api/skills, /api/threads/*/artifacts
|
||||
└→ LangGraph Server (port 2024) ← /api/langgraph/* (agent interactions)
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Create a feature branch**:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
2. **Make your changes** with hot-reload enabled
|
||||
|
||||
3. **Test your changes** thoroughly
|
||||
|
||||
4. **Commit your changes**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: description of your changes"
|
||||
```
|
||||
|
||||
5. **Push and create a Pull Request**:
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend
|
||||
uv run pytest
|
||||
|
||||
# Frontend tests
|
||||
cd frontend
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Backend (Python)**: We use `ruff` for linting and formatting
|
||||
- **Frontend (TypeScript)**: We use ESLint and Prettier
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Configuration Guide](backend/docs/CONFIGURATION.md) - Setup and configuration
|
||||
- [Architecture Overview](backend/CLAUDE.md) - Technical architecture
|
||||
- [MCP Setup Guide](MCP_SETUP.md) - Model Context Protocol configuration
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Check existing [Issues](https://github.com/bytedance/deer-flow/issues)
|
||||
- Read the [Documentation](backend/docs/)
|
||||
- Ask questions in [Discussions](https://github.com/bytedance/deer-flow/discussions)
|
||||
|
||||
## License
|
||||
|
||||
By contributing to DeerFlow, you agree that your contributions will be licensed under the [MIT License](./LICENSE).
|
||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
Copyright (c) 2025-2026 DeerFlow Authors
|
||||
|
||||
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.
|
||||
257
Makefile
Normal file
257
Makefile
Normal file
@@ -0,0 +1,257 @@
|
||||
# DeerFlow - Unified Development Environment
|
||||
|
||||
.PHONY: help check install dev stop clean docker-init docker-start docker-stop docker-logs docker-logs-frontend docker-logs-gateway
|
||||
|
||||
help:
|
||||
@echo "DeerFlow Development Commands:"
|
||||
@echo " make check - Check if all required tools are installed"
|
||||
@echo " make install - Install all dependencies (frontend + backend)"
|
||||
@echo " make setup-sandbox - Pre-pull sandbox container image (recommended)"
|
||||
@echo " make dev - Start all services (frontend + backend + nginx on localhost:2026)"
|
||||
@echo " make stop - Stop all running services"
|
||||
@echo " make clean - Clean up processes and temporary files"
|
||||
@echo ""
|
||||
@echo "Docker Development Commands:"
|
||||
@echo " make docker-init - Build the custom k3s image (with pre-cached sandbox image)"
|
||||
@echo " make docker-start - Start all services in Docker (localhost:2026)"
|
||||
@echo " make docker-stop - Stop Docker development services"
|
||||
@echo " make docker-logs - View Docker development logs"
|
||||
@echo " make docker-logs-frontend - View Docker frontend logs"
|
||||
@echo " make docker-logs-gateway - View Docker gateway logs"
|
||||
|
||||
# Check required tools
|
||||
check:
|
||||
@echo "=========================================="
|
||||
@echo " Checking Required Dependencies"
|
||||
@echo "=========================================="
|
||||
@echo ""
|
||||
@FAILED=0; \
|
||||
echo "Checking Node.js..."; \
|
||||
if command -v node >/dev/null 2>&1; then \
|
||||
NODE_VERSION=$$(node -v | sed 's/v//'); \
|
||||
NODE_MAJOR=$$(echo $$NODE_VERSION | cut -d. -f1); \
|
||||
if [ $$NODE_MAJOR -ge 22 ]; then \
|
||||
echo " ✓ Node.js $$NODE_VERSION (>= 22 required)"; \
|
||||
else \
|
||||
echo " ✗ Node.js $$NODE_VERSION found, but version 22+ is required"; \
|
||||
echo " Install from: https://nodejs.org/"; \
|
||||
FAILED=1; \
|
||||
fi; \
|
||||
else \
|
||||
echo " ✗ Node.js not found (version 22+ required)"; \
|
||||
echo " Install from: https://nodejs.org/"; \
|
||||
FAILED=1; \
|
||||
fi; \
|
||||
echo ""; \
|
||||
echo "Checking pnpm..."; \
|
||||
if command -v pnpm >/dev/null 2>&1; then \
|
||||
PNPM_VERSION=$$(pnpm -v); \
|
||||
echo " ✓ pnpm $$PNPM_VERSION"; \
|
||||
else \
|
||||
echo " ✗ pnpm not found"; \
|
||||
echo " Install: npm install -g pnpm"; \
|
||||
echo " Or visit: https://pnpm.io/installation"; \
|
||||
FAILED=1; \
|
||||
fi; \
|
||||
echo ""; \
|
||||
echo "Checking uv..."; \
|
||||
if command -v uv >/dev/null 2>&1; then \
|
||||
UV_VERSION=$$(uv --version | awk '{print $$2}'); \
|
||||
echo " ✓ uv $$UV_VERSION"; \
|
||||
else \
|
||||
echo " ✗ uv not found"; \
|
||||
echo " Install: curl -LsSf https://astral.sh/uv/install.sh | sh"; \
|
||||
echo " Or visit: https://docs.astral.sh/uv/getting-started/installation/"; \
|
||||
FAILED=1; \
|
||||
fi; \
|
||||
echo ""; \
|
||||
echo "Checking nginx..."; \
|
||||
if command -v nginx >/dev/null 2>&1; then \
|
||||
NGINX_VERSION=$$(nginx -v 2>&1 | awk -F'/' '{print $$2}'); \
|
||||
echo " ✓ nginx $$NGINX_VERSION"; \
|
||||
else \
|
||||
echo " ✗ nginx not found"; \
|
||||
echo " macOS: brew install nginx"; \
|
||||
echo " Ubuntu: sudo apt install nginx"; \
|
||||
echo " Or visit: https://nginx.org/en/download.html"; \
|
||||
FAILED=1; \
|
||||
fi; \
|
||||
echo ""; \
|
||||
if [ $$FAILED -eq 0 ]; then \
|
||||
echo "=========================================="; \
|
||||
echo " ✓ All dependencies are installed!"; \
|
||||
echo "=========================================="; \
|
||||
echo ""; \
|
||||
echo "You can now run:"; \
|
||||
echo " make install - Install project dependencies"; \
|
||||
echo " make dev - Start development server"; \
|
||||
else \
|
||||
echo "=========================================="; \
|
||||
echo " ✗ Some dependencies are missing"; \
|
||||
echo "=========================================="; \
|
||||
echo ""; \
|
||||
echo "Please install the missing tools and run 'make check' again."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Install all dependencies
|
||||
install:
|
||||
@echo "Installing backend dependencies..."
|
||||
@cd backend && uv sync
|
||||
@echo "Installing frontend dependencies..."
|
||||
@cd frontend && pnpm install
|
||||
@echo "✓ All dependencies installed"
|
||||
@echo ""
|
||||
@echo "=========================================="
|
||||
@echo " Optional: Pre-pull Sandbox Image"
|
||||
@echo "=========================================="
|
||||
@echo ""
|
||||
@echo "If you plan to use Docker/Container-based sandbox, you can pre-pull the image:"
|
||||
@echo " make setup-sandbox"
|
||||
@echo ""
|
||||
|
||||
# Pre-pull sandbox Docker image (optional but recommended)
|
||||
setup-sandbox:
|
||||
@echo "=========================================="
|
||||
@echo " Pre-pulling Sandbox Container Image"
|
||||
@echo "=========================================="
|
||||
@echo ""
|
||||
@IMAGE=$$(grep -A 20 "# sandbox:" config.yaml 2>/dev/null | grep "image:" | awk '{print $$2}' | head -1); \
|
||||
if [ -z "$$IMAGE" ]; then \
|
||||
IMAGE="enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest"; \
|
||||
echo "Using default image: $$IMAGE"; \
|
||||
else \
|
||||
echo "Using configured image: $$IMAGE"; \
|
||||
fi; \
|
||||
echo ""; \
|
||||
if command -v container >/dev/null 2>&1 && [ "$$(uname)" = "Darwin" ]; then \
|
||||
echo "Detected Apple Container on macOS, pulling image..."; \
|
||||
container pull "$$IMAGE" || echo "⚠ Apple Container pull failed, will try Docker"; \
|
||||
fi; \
|
||||
if command -v docker >/dev/null 2>&1; then \
|
||||
echo "Pulling image using Docker..."; \
|
||||
docker pull "$$IMAGE"; \
|
||||
echo ""; \
|
||||
echo "✓ Sandbox image pulled successfully"; \
|
||||
else \
|
||||
echo "✗ Neither Docker nor Apple Container is available"; \
|
||||
echo " Please install Docker: https://docs.docker.com/get-docker/"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Start all services
|
||||
dev:
|
||||
@echo "Stopping existing services if any..."
|
||||
@-pkill -f "langgraph dev" 2>/dev/null || true
|
||||
@-pkill -f "uvicorn src.gateway.app:app" 2>/dev/null || true
|
||||
@-pkill -f "next dev" 2>/dev/null || true
|
||||
@-nginx -c $(PWD)/docker/nginx/nginx.local.conf -p $(PWD) -s quit 2>/dev/null || true
|
||||
@sleep 1
|
||||
@-pkill -9 nginx 2>/dev/null || true
|
||||
@-./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true
|
||||
@sleep 1
|
||||
@echo ""
|
||||
@echo "=========================================="
|
||||
@echo " Starting DeerFlow Development Server"
|
||||
@echo "=========================================="
|
||||
@echo ""
|
||||
@echo "Services starting up..."
|
||||
@echo " → Backend: LangGraph + Gateway"
|
||||
@echo " → Frontend: Next.js"
|
||||
@echo " → Nginx: Reverse Proxy"
|
||||
@echo ""
|
||||
@cleanup() { \
|
||||
echo ""; \
|
||||
echo "Shutting down services..."; \
|
||||
pkill -f "langgraph dev" 2>/dev/null || true; \
|
||||
pkill -f "uvicorn src.gateway.app:app" 2>/dev/null || true; \
|
||||
pkill -f "next dev" 2>/dev/null || true; \
|
||||
nginx -c $(PWD)/docker/nginx/nginx.local.conf -p $(PWD) -s quit 2>/dev/null || true; \
|
||||
sleep 1; \
|
||||
pkill -9 nginx 2>/dev/null || true; \
|
||||
echo "Cleaning up sandbox containers..."; \
|
||||
./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true; \
|
||||
echo "✓ All services stopped"; \
|
||||
exit 0; \
|
||||
}; \
|
||||
trap cleanup INT TERM; \
|
||||
mkdir -p logs; \
|
||||
echo "Starting LangGraph server..."; \
|
||||
cd backend && NO_COLOR=1 uv run langgraph dev --no-browser --allow-blocking --no-reload > ../logs/langgraph.log 2>&1 & \
|
||||
sleep 3; \
|
||||
echo "✓ LangGraph server started on localhost:2024"; \
|
||||
echo "Starting Gateway API..."; \
|
||||
cd backend && uv run uvicorn src.gateway.app:app --host 0.0.0.0 --port 8001 > ../logs/gateway.log 2>&1 & \
|
||||
sleep 2; \
|
||||
echo "✓ Gateway API started on localhost:8001"; \
|
||||
echo "Starting Frontend..."; \
|
||||
cd frontend && pnpm run dev > ../logs/frontend.log 2>&1 & \
|
||||
sleep 3; \
|
||||
echo "✓ Frontend started on localhost:3000"; \
|
||||
echo "Starting Nginx reverse proxy..."; \
|
||||
mkdir -p logs && nginx -g 'daemon off;' -c $(PWD)/docker/nginx/nginx.local.conf -p $(PWD) > logs/nginx.log 2>&1 & \
|
||||
sleep 2; \
|
||||
echo "✓ Nginx started on localhost:2026"; \
|
||||
echo ""; \
|
||||
echo "=========================================="; \
|
||||
echo " DeerFlow is ready!"; \
|
||||
echo "=========================================="; \
|
||||
echo ""; \
|
||||
echo " 🌐 Application: http://localhost:2026"; \
|
||||
echo " 📡 API Gateway: http://localhost:2026/api/*"; \
|
||||
echo " 🤖 LangGraph: http://localhost:2026/api/langgraph/*"; \
|
||||
echo ""; \
|
||||
echo " 📋 Logs:"; \
|
||||
echo " - LangGraph: logs/langgraph.log"; \
|
||||
echo " - Gateway: logs/gateway.log"; \
|
||||
echo " - Frontend: logs/frontend.log"; \
|
||||
echo " - Nginx: logs/nginx.log"; \
|
||||
echo ""; \
|
||||
echo "Press Ctrl+C to stop all services"; \
|
||||
echo ""; \
|
||||
wait
|
||||
|
||||
# Stop all services
|
||||
stop:
|
||||
@echo "Stopping all services..."
|
||||
@-pkill -f "langgraph dev" 2>/dev/null || true
|
||||
@-pkill -f "uvicorn src.gateway.app:app" 2>/dev/null || true
|
||||
@-pkill -f "next dev" 2>/dev/null || true
|
||||
@-nginx -c $(PWD)/docker/nginx/nginx.local.conf -p $(PWD) -s quit 2>/dev/null || true
|
||||
@sleep 1
|
||||
@-pkill -9 nginx 2>/dev/null || true
|
||||
@echo "Cleaning up sandbox containers..."
|
||||
@-./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true
|
||||
@echo "✓ All services stopped"
|
||||
|
||||
# Clean up
|
||||
clean: stop
|
||||
@echo "Cleaning up..."
|
||||
@-rm -rf logs/*.log 2>/dev/null || true
|
||||
@echo "✓ Cleanup complete"
|
||||
|
||||
# ==========================================
|
||||
# Docker Development Commands
|
||||
# ==========================================
|
||||
|
||||
# Initialize Docker containers and install dependencies
|
||||
docker-init:
|
||||
@./scripts/docker.sh init
|
||||
|
||||
# Start Docker development environment
|
||||
docker-start:
|
||||
@./scripts/docker.sh start
|
||||
|
||||
# Stop Docker development environment
|
||||
docker-stop:
|
||||
@./scripts/docker.sh stop
|
||||
|
||||
# View Docker development logs
|
||||
docker-logs:
|
||||
@./scripts/docker.sh logs
|
||||
|
||||
# View Docker development logs
|
||||
docker-logs-frontend:
|
||||
@./scripts/docker.sh logs --frontend
|
||||
docker-logs-gateway:
|
||||
@./scripts/docker.sh logs --gateway
|
||||
223
README.md
Normal file
223
README.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# 🦌 DeerFlow - 2.0
|
||||
|
||||
DeerFlow (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is an open-source **super agent harness** that orchestrates **sub-agents**, **memory**, and **sandboxes** to do almost anything — powered by **extensible skills**.
|
||||
|
||||
> [!NOTE]
|
||||
> **DeerFlow 2.0 is a ground-up rewrite.** It shares no code with v1. If you're looking for the original Deep Research framework, it's maintained on the [`1.x` branch](https://github.com/bytedance/deer-flow/tree/1.x) — contributions there are still welcome. Active development has moved to 2.0.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [Sandbox Configuration](#sandbox-configuration)
|
||||
- [From Deep Research to Super Agent Harness](#from-deep-research-to-super-agent-harness)
|
||||
- [Core Features](#core-features)
|
||||
- [Skills & Tools](#skills--tools)
|
||||
- [Sub-Agents](#sub-agents)
|
||||
- [Sandbox & File System](#sandbox--file-system)
|
||||
- [Context Engineering](#context-engineering)
|
||||
- [Long-Term Memory](#long-term-memory)
|
||||
- [Recommended Models](#recommended-models)
|
||||
- [Documentation](#documentation)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
- [Acknowledgments](#acknowledgments)
|
||||
- [Star History](#star-history)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Copy the example config**:
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. **Edit `config.yaml`** and set your API keys in `.env` and preferred sandbox mode.
|
||||
|
||||
#### Sandbox Configuration
|
||||
|
||||
DeerFlow supports multiple sandbox execution modes. Configure your preferred mode in `config.yaml`:
|
||||
|
||||
**Local Execution** (runs sandbox code directly on the host machine):
|
||||
```yaml
|
||||
sandbox:
|
||||
use: src.sandbox.local:LocalSandboxProvider # Local execution
|
||||
```
|
||||
|
||||
**Docker Execution** (runs sandbox code in isolated Docker containers):
|
||||
```yaml
|
||||
sandbox:
|
||||
use: src.community.aio_sandbox:AioSandboxProvider # Docker-based sandbox
|
||||
```
|
||||
|
||||
**Docker Execution with Kubernetes** (runs sandbox code in Kubernetes pods via provisioner service):
|
||||
|
||||
This mode runs each sandbox in an isolated Kubernetes Pod on your **host machine's cluster**. Requires Docker Desktop K8s, OrbStack, or similar local K8s setup.
|
||||
|
||||
```yaml
|
||||
sandbox:
|
||||
use: src.community.aio_sandbox:AioSandboxProvider
|
||||
provisioner_url: http://provisioner:8002
|
||||
```
|
||||
|
||||
See [Provisioner Setup Guide](docker/provisioner/README.md) for detailed configuration, prerequisites, and troubleshooting.
|
||||
|
||||
### Running the Application
|
||||
|
||||
#### Option 1: Docker (Recommended)
|
||||
|
||||
The fastest way to get started with a consistent environment:
|
||||
|
||||
1. **Initialize and start**:
|
||||
```bash
|
||||
make docker-init # Pull sandbox image (Only once or when image updates)
|
||||
make docker-start # Start all services and watch for code changes
|
||||
```
|
||||
|
||||
2. **Access**: http://localhost:2026
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide.
|
||||
|
||||
#### Option 2: Local Development
|
||||
|
||||
If you prefer running services locally:
|
||||
|
||||
1. **Check prerequisites**:
|
||||
```bash
|
||||
make check # Verifies Node.js 22+, pnpm, uv, nginx
|
||||
```
|
||||
|
||||
2. **(Optional) Pre-pull sandbox image**:
|
||||
```bash
|
||||
# Recommended if using Docker/Container-based sandbox
|
||||
make setup-sandbox
|
||||
```
|
||||
|
||||
3. **Start services**:
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
4. **Access**: http://localhost:2026
|
||||
|
||||
## From Deep Research to Super Agent Harness
|
||||
|
||||
DeerFlow started as a Deep Research framework — and the community ran with it. Since launch, developers have pushed it far beyond research: building data pipelines, generating slide decks, spinning up dashboards, automating content workflows. Things we never anticipated.
|
||||
|
||||
That told us something important: DeerFlow wasn't just a research tool. It was a **harness** — a runtime that gives agents the infrastructure to actually get work done.
|
||||
|
||||
So we rebuilt it from scratch.
|
||||
|
||||
DeerFlow 2.0 is no longer a framework you wire together. It's a super agent harness — batteries included, fully extensible. Built on LangGraph and LangChain, it ships with everything an agent needs out of the box: a filesystem, memory, skills, sandboxed execution, and the ability to plan and spawn sub-agents for complex, multi-step tasks.
|
||||
|
||||
Use it as-is. Or tear it apart and make it yours.
|
||||
|
||||
## Core Features
|
||||
|
||||
### Skills & Tools
|
||||
|
||||
Skills are what make DeerFlow do *almost anything*.
|
||||
|
||||
A standard Agent Skill is a structured capability module — a Markdown file that defines a workflow, best practices, and references to supporting resources. DeerFlow ships with built-in skills for research, report generation, slide creation, web pages, image and video generation, and more. But the real power is extensibility: add your own skills, replace the built-in ones, or combine them into compound workflows.
|
||||
|
||||
Skills are loaded progressively — only when the task needs them, not all at once. This keeps the context window lean and makes DeerFlow work well even with token-sensitive models.
|
||||
|
||||
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
|
||||
|
||||
```
|
||||
# Paths inside the sandbox container
|
||||
/mnt/skills/public
|
||||
├── research/SKILL.md
|
||||
├── report-generation/SKILL.md
|
||||
├── slide-creation/SKILL.md
|
||||
├── web-page/SKILL.md
|
||||
└── image-generation/SKILL.md
|
||||
|
||||
/mnt/skills/custom
|
||||
└── your-custom-skill/SKILL.md ← yours
|
||||
```
|
||||
|
||||
### Sub-Agents
|
||||
|
||||
Complex tasks rarely fit in a single pass. DeerFlow decomposes them.
|
||||
|
||||
The lead agent can spawn sub-agents on the fly — each with its own scoped context, tools, and termination conditions. Sub-agents run in parallel when possible, report back structured results, and the lead agent synthesizes everything into a coherent output.
|
||||
|
||||
This is how DeerFlow handles tasks that take minutes to hours: a research task might fan out into a dozen sub-agents, each exploring a different angle, then converge into a single report — or a website — or a slide deck with generated visuals. One harness, many hands.
|
||||
|
||||
### Sandbox & File System
|
||||
|
||||
DeerFlow doesn't just *talk* about doing things. It has its own computer.
|
||||
|
||||
Each task runs inside an isolated Docker container with a full filesystem — skills, workspace, uploads, outputs. The agent reads, writes, and edits files. It executes bash commands and codes. It views images. All sandboxed, all auditable, zero contamination between sessions.
|
||||
|
||||
This is the difference between a chatbot with tool access and an agent with an actual execution environment.
|
||||
|
||||
```
|
||||
# Paths inside the sandbox container
|
||||
/mnt/user-data/
|
||||
├── uploads/ ← your files
|
||||
├── workspace/ ← agents' working directory
|
||||
└── outputs/ ← final deliverables
|
||||
```
|
||||
|
||||
### Context Engineering
|
||||
|
||||
**Isolated Sub-Agent Context**: Each sub-agent runs in its own isolated context. This means that the sub-agent will not be able to see the context of the main agent or other sub-agents. This is important to ensure that the sub-agent is able to focus on the task at hand and not be distracted by the context of the main agent or other sub-agents.
|
||||
|
||||
**Summarization**: Within a session, DeerFlow manages context aggressively — summarizing completed sub-tasks, offloading intermediate results to the filesystem, compressing what's no longer immediately relevant. This lets it stay sharp across long, multi-step tasks without blowing the context window.
|
||||
|
||||
### Long-Term Memory
|
||||
|
||||
Most agents forget everything the moment a conversation ends. DeerFlow remembers.
|
||||
|
||||
Across sessions, DeerFlow builds a persistent memory of your profile, preferences, and accumulated knowledge. The more you use it, the better it knows you — your writing style, your technical stack, your recurring workflows. Memory is stored locally and stays under your control.
|
||||
|
||||
## Recommended Models
|
||||
|
||||
DeerFlow is model-agnostic — it works with any LLM that implements the OpenAI-compatible API. That said, it performs best with models that support:
|
||||
|
||||
- **Long context windows** (100k+ tokens) for deep research and multi-step tasks
|
||||
- **Reasoning capabilities** for adaptive planning and complex decomposition
|
||||
- **Multimodal inputs** for image understanding and video comprehension
|
||||
- **Strong tool-use** for reliable function calling and structured outputs
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Contributing Guide](CONTRIBUTING.md) - Development environment setup and workflow
|
||||
- [Configuration Guide](backend/docs/CONFIGURATION.md) - Setup and configuration instructions
|
||||
- [Architecture Overview](backend/CLAUDE.md) - Technical architecture details
|
||||
- [Backend Architecture](backend/README.md) - Backend architecture and API reference
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, workflow, and guidelines.
|
||||
|
||||
## License
|
||||
|
||||
This project is open source and available under the [MIT License](./LICENSE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
DeerFlow is built upon the incredible work of the open-source community. We are deeply grateful to all the projects and contributors whose efforts have made DeerFlow possible. Truly, we stand on the shoulders of giants.
|
||||
|
||||
We would like to extend our sincere appreciation to the following projects for their invaluable contributions:
|
||||
|
||||
- **[LangChain](https://github.com/langchain-ai/langchain)**: Their exceptional framework powers our LLM interactions and chains, enabling seamless integration and functionality.
|
||||
- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Their innovative approach to multi-agent orchestration has been instrumental in enabling DeerFlow's sophisticated workflows.
|
||||
|
||||
These projects exemplify the transformative power of open-source collaboration, and we are proud to build upon their foundations.
|
||||
|
||||
### Key Contributors
|
||||
|
||||
A heartfelt thank you goes out to the core authors of `DeerFlow`, whose vision, passion, and dedication have brought this project to life:
|
||||
|
||||
- **[Daniel Walnut](https://github.com/hetaoBackend/)**
|
||||
- **[Henry Li](https://github.com/magiccube/)**
|
||||
|
||||
Your unwavering commitment and expertise have been the driving force behind DeerFlow's success. We are honored to have you at the helm of this journey.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#bytedance/deer-flow&Date)
|
||||
25
backend/.gitignore
vendored
Normal file
25
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
.coverage
|
||||
.coverage.*
|
||||
.ruff_cache
|
||||
agent_history.gif
|
||||
static/browser_history/*.gif
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
venv/
|
||||
|
||||
# User config file
|
||||
config.yaml
|
||||
|
||||
# Langgraph
|
||||
.langgraph_api
|
||||
|
||||
# Claude Code settings
|
||||
.claude/settings.local.json
|
||||
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
3
backend/.vscode/extensions.json
vendored
Normal file
3
backend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["charliermarsh.ruff"]
|
||||
}
|
||||
11
backend/.vscode/settings.json
vendored
Normal file
11
backend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"window.title": "${activeEditorShort}${separator}${separator}deer-flow/backend",
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
}
|
||||
}
|
||||
2
backend/AGENTS.md
Normal file
2
backend/AGENTS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
For the backend architeture and design patterns:
|
||||
@./CLAUDE.md
|
||||
380
backend/CLAUDE.md
Normal file
380
backend/CLAUDE.md
Normal file
@@ -0,0 +1,380 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
DeerFlow is a LangGraph-based AI super agent system with a full-stack architecture. The backend provides a "super agent" with sandbox execution, persistent memory, subagent delegation, and extensible tool integration - all operating in per-thread isolated environments.
|
||||
|
||||
**Architecture**:
|
||||
- **LangGraph Server** (port 2024): Agent runtime and workflow execution
|
||||
- **Gateway API** (port 8001): REST API for models, MCP, skills, memory, artifacts, and uploads
|
||||
- **Frontend** (port 3000): Next.js web interface
|
||||
- **Nginx** (port 2026): Unified reverse proxy entry point
|
||||
|
||||
**Project Structure**:
|
||||
```
|
||||
deer-flow/
|
||||
├── Makefile # Root commands (check, install, dev, stop)
|
||||
├── config.yaml # Main application configuration
|
||||
├── extensions_config.json # MCP servers and skills configuration
|
||||
├── backend/ # Backend application (this directory)
|
||||
│ ├── Makefile # Backend-only commands (dev, gateway, lint)
|
||||
│ ├── langgraph.json # LangGraph server configuration
|
||||
│ ├── src/
|
||||
│ │ ├── agents/ # LangGraph agent system
|
||||
│ │ │ ├── lead_agent/ # Main agent (factory + system prompt)
|
||||
│ │ │ ├── middlewares/ # 10 middleware components
|
||||
│ │ │ ├── memory/ # Memory extraction, queue, prompts
|
||||
│ │ │ └── thread_state.py # ThreadState schema
|
||||
│ │ ├── gateway/ # FastAPI Gateway API
|
||||
│ │ │ ├── app.py # FastAPI application
|
||||
│ │ │ └── routers/ # 6 route modules
|
||||
│ │ ├── sandbox/ # Sandbox execution system
|
||||
│ │ │ ├── local/ # Local filesystem provider
|
||||
│ │ │ ├── sandbox.py # Abstract Sandbox interface
|
||||
│ │ │ ├── tools.py # bash, ls, read/write/str_replace
|
||||
│ │ │ └── middleware.py # Sandbox lifecycle management
|
||||
│ │ ├── subagents/ # Subagent delegation system
|
||||
│ │ │ ├── builtins/ # general-purpose, bash agents
|
||||
│ │ │ ├── executor.py # Background execution engine
|
||||
│ │ │ └── registry.py # Agent registry
|
||||
│ │ ├── tools/builtins/ # Built-in tools (present_files, ask_clarification, view_image)
|
||||
│ │ ├── mcp/ # MCP integration (tools, cache, client)
|
||||
│ │ ├── models/ # Model factory with thinking/vision support
|
||||
│ │ ├── skills/ # Skills discovery, loading, parsing
|
||||
│ │ ├── config/ # Configuration system (app, model, sandbox, tool, etc.)
|
||||
│ │ ├── community/ # Community tools (tavily, jina_ai, firecrawl, image_search, aio_sandbox)
|
||||
│ │ ├── reflection/ # Dynamic module loading (resolve_variable, resolve_class)
|
||||
│ │ └── utils/ # Utilities (network, readability)
|
||||
│ ├── tests/ # Test suite
|
||||
│ └── docs/ # Documentation
|
||||
├── frontend/ # Next.js frontend application
|
||||
└── skills/ # Agent skills directory
|
||||
├── public/ # Public skills (committed)
|
||||
└── custom/ # Custom skills (gitignored)
|
||||
```
|
||||
|
||||
## Important Development Guidelines
|
||||
|
||||
### Documentation Update Policy
|
||||
**CRITICAL: Always update README.md and CLAUDE.md after every code change**
|
||||
|
||||
When making code changes, you MUST update the relevant documentation:
|
||||
- Update `README.md` for user-facing changes (features, setup, usage instructions)
|
||||
- Update `CLAUDE.md` for development changes (architecture, commands, workflows, internal systems)
|
||||
- Keep documentation synchronized with the codebase at all times
|
||||
- Ensure accuracy and timeliness of all documentation
|
||||
|
||||
## Commands
|
||||
|
||||
**Root directory** (for full application):
|
||||
```bash
|
||||
make check # Check system requirements
|
||||
make install # Install all dependencies (frontend + backend)
|
||||
make dev # Start all services (LangGraph + Gateway + Frontend + Nginx)
|
||||
make stop # Stop all services
|
||||
```
|
||||
|
||||
**Backend directory** (for backend development only):
|
||||
```bash
|
||||
make install # Install backend dependencies
|
||||
make dev # Run LangGraph server only (port 2024)
|
||||
make gateway # Run Gateway API only (port 8001)
|
||||
make lint # Lint with ruff
|
||||
make format # Format code with ruff
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Agent System
|
||||
|
||||
**Lead Agent** (`src/agents/lead_agent/agent.py`):
|
||||
- Entry point: `make_lead_agent(config: RunnableConfig)` registered in `langgraph.json`
|
||||
- Dynamic model selection via `create_chat_model()` with thinking/vision support
|
||||
- Tools loaded via `get_available_tools()` - combines sandbox, built-in, MCP, community, and subagent tools
|
||||
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
|
||||
|
||||
**ThreadState** (`src/agents/thread_state.py`):
|
||||
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`
|
||||
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear)
|
||||
|
||||
**Runtime Configuration** (via `config.configurable`):
|
||||
- `thinking_enabled` - Enable model's extended thinking
|
||||
- `model_name` - Select specific LLM model
|
||||
- `is_plan_mode` - Enable TodoList middleware
|
||||
- `subagent_enabled` - Enable task delegation tool
|
||||
|
||||
### Middleware Chain
|
||||
|
||||
Middlewares execute in strict order in `src/agents/lead_agent/agent.py`:
|
||||
|
||||
1. **ThreadDataMiddleware** - Creates per-thread directories (`backend/.deer-flow/threads/{thread_id}/user-data/{workspace,uploads,outputs}`)
|
||||
2. **UploadsMiddleware** - Tracks and injects newly uploaded files into conversation
|
||||
3. **SandboxMiddleware** - Acquires sandbox, stores `sandbox_id` in state
|
||||
4. **DanglingToolCallMiddleware** - Injects placeholder ToolMessages for AIMessage tool_calls that lack responses (e.g., due to user interruption)
|
||||
5. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||
6. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||
7. **TitleMiddleware** - Auto-generates thread title after first complete exchange
|
||||
8. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
||||
9. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
||||
10. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if subagent_enabled)
|
||||
11. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||
|
||||
### Configuration System
|
||||
|
||||
**Main Configuration** (`config.yaml`):
|
||||
|
||||
Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** directory.
|
||||
|
||||
Configuration priority:
|
||||
1. Explicit `config_path` argument
|
||||
2. `DEER_FLOW_CONFIG_PATH` environment variable
|
||||
3. `config.yaml` in current directory (backend/)
|
||||
4. `config.yaml` in parent directory (project root - **recommended location**)
|
||||
|
||||
Config values starting with `$` are resolved as environment variables (e.g., `$OPENAI_API_KEY`).
|
||||
|
||||
**Extensions Configuration** (`extensions_config.json`):
|
||||
|
||||
MCP servers and skills are configured together in `extensions_config.json` in project root:
|
||||
|
||||
Configuration priority:
|
||||
1. Explicit `config_path` argument
|
||||
2. `DEER_FLOW_EXTENSIONS_CONFIG_PATH` environment variable
|
||||
3. `extensions_config.json` in current directory (backend/)
|
||||
4. `extensions_config.json` in parent directory (project root - **recommended location**)
|
||||
|
||||
### Gateway API (`src/gateway/`)
|
||||
|
||||
FastAPI application on port 8001 with health check at `GET /health`.
|
||||
|
||||
**Routers**:
|
||||
|
||||
| Router | Endpoints |
|
||||
|--------|-----------|
|
||||
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
|
||||
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive |
|
||||
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
|
||||
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
|
||||
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; `?download=true` for file download |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
||||
|
||||
### Sandbox System (`src/sandbox/`)
|
||||
|
||||
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
|
||||
**Provider Pattern**: `SandboxProvider` with `acquire`, `get`, `release` lifecycle
|
||||
**Implementations**:
|
||||
- `LocalSandboxProvider` - Singleton local filesystem execution with path mappings
|
||||
- `AioSandboxProvider` (`src/community/`) - Docker-based isolation
|
||||
|
||||
**Virtual Path System**:
|
||||
- Agent sees: `/mnt/user-data/{workspace,uploads,outputs}`, `/mnt/skills`
|
||||
- Physical: `backend/.deer-flow/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
|
||||
- Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()`
|
||||
- Detection: `is_local_sandbox()` checks `sandbox_id == "local"`
|
||||
|
||||
**Sandbox Tools** (in `src/sandbox/tools.py`):
|
||||
- `bash` - Execute commands with path translation and error handling
|
||||
- `ls` - Directory listing (tree format, max 2 levels)
|
||||
- `read_file` - Read file contents with optional line range
|
||||
- `write_file` - Write/append to files, creates directories
|
||||
- `str_replace` - Substring replacement (single or all occurrences)
|
||||
|
||||
### Subagent System (`src/subagents/`)
|
||||
|
||||
**Built-in Agents**: `general-purpose` (all tools except `task`) and `bash` (command specialist)
|
||||
**Execution**: Dual thread pool - `_scheduler_pool` (3 workers) + `_execution_pool` (3 workers)
|
||||
**Concurrency**: `MAX_CONCURRENT_SUBAGENTS = 3` enforced by `SubagentLimitMiddleware` (truncates excess tool calls in `after_model`), 15-minute timeout
|
||||
**Flow**: `task()` tool → `SubagentExecutor` → background thread → poll 5s → SSE events → result
|
||||
**Events**: `task_started`, `task_running`, `task_completed`/`task_failed`/`task_timed_out`
|
||||
|
||||
### Tool System (`src/tools/`)
|
||||
|
||||
`get_available_tools(groups, include_mcp, model_name, subagent_enabled)` assembles:
|
||||
1. **Config-defined tools** - Resolved from `config.yaml` via `resolve_variable()`
|
||||
2. **MCP tools** - From enabled MCP servers (lazy initialized, cached with mtime invalidation)
|
||||
3. **Built-in tools**:
|
||||
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
|
||||
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware → interrupts)
|
||||
- `view_image` - Read image as base64 (added only if model supports vision)
|
||||
4. **Subagent tool** (if enabled):
|
||||
- `task` - Delegate to subagent (description, prompt, subagent_type, max_turns)
|
||||
|
||||
**Community tools** (`src/community/`):
|
||||
- `tavily/` - Web search (5 results default) and web fetch (4KB limit)
|
||||
- `jina_ai/` - Web fetch via Jina reader API with readability extraction
|
||||
- `firecrawl/` - Web scraping via Firecrawl API
|
||||
- `image_search/` - Image search via DuckDuckGo
|
||||
|
||||
### MCP System (`src/mcp/`)
|
||||
|
||||
- Uses `langchain-mcp-adapters` `MultiServerMCPClient` for multi-server management
|
||||
- **Lazy initialization**: Tools loaded on first use via `get_cached_mcp_tools()`
|
||||
- **Cache invalidation**: Detects config file changes via mtime comparison
|
||||
- **Transports**: stdio (command-based), SSE, HTTP
|
||||
- **Runtime updates**: Gateway API saves to extensions_config.json; LangGraph detects via mtime
|
||||
|
||||
### Skills System (`src/skills/`)
|
||||
|
||||
- **Location**: `deer-flow/skills/{public,custom}/`
|
||||
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
|
||||
- **Loading**: `load_skills()` scans directories, parses SKILL.md, reads enabled state from extensions_config.json
|
||||
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||
|
||||
### Model Factory (`src/models/factory.py`)
|
||||
|
||||
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
|
||||
- Supports `thinking_enabled` flag with per-model `when_thinking_enabled` overrides
|
||||
- Supports `supports_vision` flag for image understanding models
|
||||
- Config values starting with `$` resolved as environment variables
|
||||
|
||||
### Memory System (`src/agents/memory/`)
|
||||
|
||||
**Components**:
|
||||
- `updater.py` - LLM-based memory updates with fact extraction and atomic file I/O
|
||||
- `queue.py` - Debounced update queue (per-thread deduplication, configurable wait time)
|
||||
- `prompt.py` - Prompt templates for memory updates
|
||||
|
||||
**Data Structure** (stored in `backend/.deer-flow/memory.json`):
|
||||
- **User Context**: `workContext`, `personalContext`, `topOfMind` (1-3 sentence summaries)
|
||||
- **History**: `recentMonths`, `earlierContext`, `longTermBackground`
|
||||
- **Facts**: Discrete facts with `id`, `content`, `category` (preference/knowledge/context/behavior/goal), `confidence` (0-1), `createdAt`, `source`
|
||||
|
||||
**Workflow**:
|
||||
1. `MemoryMiddleware` filters messages (user inputs + final AI responses) and queues conversation
|
||||
2. Queue debounces (30s default), batches updates, deduplicates per-thread
|
||||
3. Background thread invokes LLM to extract context updates and facts
|
||||
4. Applies updates atomically (temp file + rename) with cache invalidation
|
||||
5. Next interaction injects top 15 facts + context into `<memory>` tags in system prompt
|
||||
|
||||
**Configuration** (`config.yaml` → `memory`):
|
||||
- `enabled` / `injection_enabled` - Master switches
|
||||
- `storage_path` - Path to memory.json
|
||||
- `debounce_seconds` - Wait time before processing (default: 30)
|
||||
- `model_name` - LLM for updates (null = default model)
|
||||
- `max_facts` / `fact_confidence_threshold` - Fact storage limits (100 / 0.7)
|
||||
- `max_injection_tokens` - Token limit for prompt injection (2000)
|
||||
|
||||
### Reflection System (`src/reflection/`)
|
||||
|
||||
- `resolve_variable(path)` - Import module and return variable (e.g., `module.path:variable_name`)
|
||||
- `resolve_class(path, base_class)` - Import and validate class against base class
|
||||
|
||||
### Config Schema
|
||||
|
||||
**`config.yaml`** key sections:
|
||||
- `models[]` - LLM configs with `use` class path, `supports_thinking`, `supports_vision`, provider-specific fields
|
||||
- `tools[]` - Tool configs with `use` variable path and `group`
|
||||
- `tool_groups[]` - Logical groupings for tools
|
||||
- `sandbox.use` - Sandbox provider class path
|
||||
- `skills.path` / `skills.container_path` - Host and container paths to skills directory
|
||||
- `title` - Auto-title generation (enabled, max_words, max_chars, prompt_template)
|
||||
- `summarization` - Context summarization (enabled, trigger conditions, keep policy)
|
||||
- `subagents.enabled` - Master switch for subagent delegation
|
||||
- `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens)
|
||||
|
||||
**`extensions_config.json`**:
|
||||
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, description)
|
||||
- `skills` - Map of skill name → state (enabled)
|
||||
|
||||
Both can be modified at runtime via Gateway API endpoints.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Running the Full Application
|
||||
|
||||
From the **project root** directory:
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
This starts all services and makes the application available at `http://localhost:2026`.
|
||||
|
||||
**Nginx routing**:
|
||||
- `/api/langgraph/*` → LangGraph Server (2024)
|
||||
- `/api/*` (other) → Gateway API (8001)
|
||||
- `/` (non-API) → Frontend (3000)
|
||||
|
||||
### Running Backend Services Separately
|
||||
|
||||
From the **backend** directory:
|
||||
|
||||
```bash
|
||||
# Terminal 1: LangGraph server
|
||||
make dev
|
||||
|
||||
# Terminal 2: Gateway API
|
||||
make gateway
|
||||
```
|
||||
|
||||
Direct access (without nginx):
|
||||
- LangGraph: `http://localhost:2024`
|
||||
- Gateway: `http://localhost:8001`
|
||||
|
||||
### Frontend Configuration
|
||||
|
||||
The frontend uses environment variables to connect to backend services:
|
||||
- `NEXT_PUBLIC_LANGGRAPH_BASE_URL` - Defaults to `/api/langgraph` (through nginx)
|
||||
- `NEXT_PUBLIC_BACKEND_BASE_URL` - Defaults to empty string (through nginx)
|
||||
|
||||
When using `make dev` from root, the frontend automatically connects through nginx.
|
||||
|
||||
## Key Features
|
||||
|
||||
### File Upload
|
||||
|
||||
Multi-file upload with automatic document conversion:
|
||||
- Endpoint: `POST /api/threads/{thread_id}/uploads`
|
||||
- Supports: PDF, PPT, Excel, Word documents (converted via `markitdown`)
|
||||
- Files stored in thread-isolated directories
|
||||
- Agent receives uploaded file list via `UploadsMiddleware`
|
||||
|
||||
See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
|
||||
|
||||
### Plan Mode
|
||||
|
||||
TodoList middleware for complex multi-step tasks:
|
||||
- Controlled via runtime config: `config.configurable.is_plan_mode = True`
|
||||
- Provides `write_todos` tool for task tracking
|
||||
- One task in_progress at a time, real-time updates
|
||||
|
||||
See [docs/plan_mode_usage.md](docs/plan_mode_usage.md) for details.
|
||||
|
||||
### Context Summarization
|
||||
|
||||
Automatic conversation summarization when approaching token limits:
|
||||
- Configured in `config.yaml` under `summarization` key
|
||||
- Trigger types: tokens, messages, or fraction of max input
|
||||
- Keeps recent messages while summarizing older ones
|
||||
|
||||
See [docs/summarization.md](docs/summarization.md) for details.
|
||||
|
||||
### Vision Support
|
||||
|
||||
For models with `supports_vision: true`:
|
||||
- `ViewImageMiddleware` processes images in conversation
|
||||
- `view_image_tool` added to agent's toolset
|
||||
- Images automatically converted to base64 and injected into state
|
||||
|
||||
## Code Style
|
||||
|
||||
- Uses `ruff` for linting and formatting
|
||||
- Line length: 240 characters
|
||||
- Python 3.12+ with type hints
|
||||
- Double quotes, space indentation
|
||||
|
||||
## Documentation
|
||||
|
||||
See `docs/` directory for detailed documentation:
|
||||
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
|
||||
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - Architecture details
|
||||
- [API.md](docs/API.md) - API reference
|
||||
- [SETUP.md](docs/SETUP.md) - Setup guide
|
||||
- [FILE_UPLOAD.md](docs/FILE_UPLOAD.md) - File upload feature
|
||||
- [PATH_EXAMPLES.md](docs/PATH_EXAMPLES.md) - Path types and usage
|
||||
- [summarization.md](docs/summarization.md) - Context summarization
|
||||
- [plan_mode_usage.md](docs/plan_mode_usage.md) - Plan mode with TodoList
|
||||
427
backend/CONTRIBUTING.md
Normal file
427
backend/CONTRIBUTING.md
Normal file
@@ -0,0 +1,427 @@
|
||||
# Contributing to DeerFlow Backend
|
||||
|
||||
Thank you for your interest in contributing to DeerFlow! This document provides guidelines and instructions for contributing to the backend codebase.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Code Style](#code-style)
|
||||
- [Making Changes](#making-changes)
|
||||
- [Testing](#testing)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Architecture Guidelines](#architecture-guidelines)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12 or higher
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- Git
|
||||
- Docker (optional, for Docker sandbox testing)
|
||||
|
||||
### Fork and Clone
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/deer-flow.git
|
||||
cd deer-flow
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
cp config.example.yaml config.yaml
|
||||
cp extensions_config.example.json extensions_config.json
|
||||
|
||||
# Install backend dependencies
|
||||
cd backend
|
||||
make install
|
||||
```
|
||||
|
||||
### Configure Environment
|
||||
|
||||
Set up your API keys for testing:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
# Add other keys as needed
|
||||
```
|
||||
|
||||
### Run the Development Server
|
||||
|
||||
```bash
|
||||
# Terminal 1: LangGraph server
|
||||
make dev
|
||||
|
||||
# Terminal 2: Gateway API
|
||||
make gateway
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/src/
|
||||
├── agents/ # Agent system
|
||||
│ ├── lead_agent/ # Main agent implementation
|
||||
│ │ └── agent.py # Agent factory and creation
|
||||
│ ├── middlewares/ # Agent middlewares
|
||||
│ │ ├── thread_data_middleware.py
|
||||
│ │ ├── sandbox_middleware.py
|
||||
│ │ ├── title_middleware.py
|
||||
│ │ ├── uploads_middleware.py
|
||||
│ │ ├── view_image_middleware.py
|
||||
│ │ └── clarification_middleware.py
|
||||
│ └── thread_state.py # Thread state definition
|
||||
│
|
||||
├── gateway/ # FastAPI Gateway
|
||||
│ ├── app.py # FastAPI application
|
||||
│ └── routers/ # Route handlers
|
||||
│ ├── models.py # /api/models endpoints
|
||||
│ ├── mcp.py # /api/mcp endpoints
|
||||
│ ├── skills.py # /api/skills endpoints
|
||||
│ ├── artifacts.py # /api/threads/.../artifacts
|
||||
│ └── uploads.py # /api/threads/.../uploads
|
||||
│
|
||||
├── sandbox/ # Sandbox execution
|
||||
│ ├── __init__.py # Sandbox interface
|
||||
│ ├── local.py # Local sandbox provider
|
||||
│ └── tools.py # Sandbox tools (bash, file ops)
|
||||
│
|
||||
├── tools/ # Agent tools
|
||||
│ └── builtins/ # Built-in tools
|
||||
│ ├── present_file_tool.py
|
||||
│ ├── ask_clarification_tool.py
|
||||
│ └── view_image_tool.py
|
||||
│
|
||||
├── mcp/ # MCP integration
|
||||
│ └── manager.py # MCP server management
|
||||
│
|
||||
├── models/ # Model system
|
||||
│ └── factory.py # Model factory
|
||||
│
|
||||
├── skills/ # Skills system
|
||||
│ └── loader.py # Skills loader
|
||||
│
|
||||
├── config/ # Configuration
|
||||
│ ├── app_config.py # Main app config
|
||||
│ ├── extensions_config.py # Extensions config
|
||||
│ └── summarization_config.py
|
||||
│
|
||||
├── community/ # Community tools
|
||||
│ ├── tavily/ # Tavily web search
|
||||
│ ├── jina/ # Jina web fetch
|
||||
│ ├── firecrawl/ # Firecrawl scraping
|
||||
│ └── aio_sandbox/ # Docker sandbox
|
||||
│
|
||||
├── reflection/ # Dynamic loading
|
||||
│ └── __init__.py # Module resolution
|
||||
│
|
||||
└── utils/ # Utilities
|
||||
└── __init__.py
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Linting and Formatting
|
||||
|
||||
We use `ruff` for both linting and formatting:
|
||||
|
||||
```bash
|
||||
# Check for issues
|
||||
make lint
|
||||
|
||||
# Auto-fix and format
|
||||
make format
|
||||
```
|
||||
|
||||
### Style Guidelines
|
||||
|
||||
- **Line length**: 240 characters maximum
|
||||
- **Python version**: 3.12+ features allowed
|
||||
- **Type hints**: Use type hints for function signatures
|
||||
- **Quotes**: Double quotes for strings
|
||||
- **Indentation**: 4 spaces (no tabs)
|
||||
- **Imports**: Group by standard library, third-party, local
|
||||
|
||||
### Docstrings
|
||||
|
||||
Use docstrings for public functions and classes:
|
||||
|
||||
```python
|
||||
def create_chat_model(name: str, thinking_enabled: bool = False) -> BaseChatModel:
|
||||
"""Create a chat model instance from configuration.
|
||||
|
||||
Args:
|
||||
name: The model name as defined in config.yaml
|
||||
thinking_enabled: Whether to enable extended thinking
|
||||
|
||||
Returns:
|
||||
A configured LangChain chat model instance
|
||||
|
||||
Raises:
|
||||
ValueError: If the model name is not found in configuration
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Branch Naming
|
||||
|
||||
Use descriptive branch names:
|
||||
|
||||
- `feature/add-new-tool` - New features
|
||||
- `fix/sandbox-timeout` - Bug fixes
|
||||
- `docs/update-readme` - Documentation
|
||||
- `refactor/config-system` - Code refactoring
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Write clear, concise commit messages:
|
||||
|
||||
```
|
||||
feat: add support for Claude 3.5 model
|
||||
|
||||
- Add model configuration in config.yaml
|
||||
- Update model factory to handle Claude-specific settings
|
||||
- Add tests for new model
|
||||
```
|
||||
|
||||
Prefix types:
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation
|
||||
- `refactor:` - Code refactoring
|
||||
- `test:` - Tests
|
||||
- `chore:` - Build/config changes
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
Place tests in the `tests/` directory mirroring the source structure:
|
||||
|
||||
```
|
||||
tests/
|
||||
├── test_models/
|
||||
│ └── test_factory.py
|
||||
├── test_sandbox/
|
||||
│ └── test_local.py
|
||||
└── test_gateway/
|
||||
└── test_models_router.py
|
||||
```
|
||||
|
||||
Example test:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from src.models.factory import create_chat_model
|
||||
|
||||
def test_create_chat_model_with_valid_name():
|
||||
"""Test that a valid model name creates a model instance."""
|
||||
model = create_chat_model("gpt-4")
|
||||
assert model is not None
|
||||
|
||||
def test_create_chat_model_with_invalid_name():
|
||||
"""Test that an invalid model name raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
create_chat_model("nonexistent-model")
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
### Before Submitting
|
||||
|
||||
1. **Ensure tests pass**: `uv run pytest`
|
||||
2. **Run linter**: `make lint`
|
||||
3. **Format code**: `make format`
|
||||
4. **Update documentation** if needed
|
||||
|
||||
### PR Description
|
||||
|
||||
Include in your PR description:
|
||||
|
||||
- **What**: Brief description of changes
|
||||
- **Why**: Motivation for the change
|
||||
- **How**: Implementation approach
|
||||
- **Testing**: How you tested the changes
|
||||
|
||||
### Review Process
|
||||
|
||||
1. Submit PR with clear description
|
||||
2. Address review feedback
|
||||
3. Ensure CI passes
|
||||
4. Maintainer will merge when approved
|
||||
|
||||
## Architecture Guidelines
|
||||
|
||||
### Adding New Tools
|
||||
|
||||
1. Create tool in `src/tools/builtins/` or `src/community/`:
|
||||
|
||||
```python
|
||||
# src/tools/builtins/my_tool.py
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@tool
|
||||
def my_tool(param: str) -> str:
|
||||
"""Tool description for the agent.
|
||||
|
||||
Args:
|
||||
param: Description of the parameter
|
||||
|
||||
Returns:
|
||||
Description of return value
|
||||
"""
|
||||
return f"Result: {param}"
|
||||
```
|
||||
|
||||
2. Register in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
- name: my_tool
|
||||
group: my_group
|
||||
use: src.tools.builtins.my_tool:my_tool
|
||||
```
|
||||
|
||||
### Adding New Middleware
|
||||
|
||||
1. Create middleware in `src/agents/middlewares/`:
|
||||
|
||||
```python
|
||||
# src/agents/middlewares/my_middleware.py
|
||||
from langchain.agents.middleware import BaseMiddleware
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
class MyMiddleware(BaseMiddleware):
|
||||
"""Middleware description."""
|
||||
|
||||
def transform_state(self, state: dict, config: RunnableConfig) -> dict:
|
||||
"""Transform the state before agent execution."""
|
||||
# Modify state as needed
|
||||
return state
|
||||
```
|
||||
|
||||
2. Register in `src/agents/lead_agent/agent.py`:
|
||||
|
||||
```python
|
||||
middlewares = [
|
||||
ThreadDataMiddleware(),
|
||||
SandboxMiddleware(),
|
||||
MyMiddleware(), # Add your middleware
|
||||
TitleMiddleware(),
|
||||
ClarificationMiddleware(),
|
||||
]
|
||||
```
|
||||
|
||||
### Adding New API Endpoints
|
||||
|
||||
1. Create router in `src/gateway/routers/`:
|
||||
|
||||
```python
|
||||
# src/gateway/routers/my_router.py
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/my-endpoint", tags=["my-endpoint"])
|
||||
|
||||
@router.get("/")
|
||||
async def get_items():
|
||||
"""Get all items."""
|
||||
return {"items": []}
|
||||
|
||||
@router.post("/")
|
||||
async def create_item(data: dict):
|
||||
"""Create a new item."""
|
||||
return {"created": data}
|
||||
```
|
||||
|
||||
2. Register in `src/gateway/app.py`:
|
||||
|
||||
```python
|
||||
from src.gateway.routers import my_router
|
||||
|
||||
app.include_router(my_router.router)
|
||||
```
|
||||
|
||||
### Configuration Changes
|
||||
|
||||
When adding new configuration options:
|
||||
|
||||
1. Update `src/config/app_config.py` with new fields
|
||||
2. Add default values in `config.example.yaml`
|
||||
3. Document in `docs/CONFIGURATION.md`
|
||||
|
||||
### MCP Server Integration
|
||||
|
||||
To add support for a new MCP server:
|
||||
|
||||
1. Add configuration in `extensions_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@my-org/mcp-server"],
|
||||
"description": "My MCP Server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Update `extensions_config.example.json` with the new server
|
||||
|
||||
### Skills Development
|
||||
|
||||
To create a new skill:
|
||||
|
||||
1. Create directory in `skills/public/` or `skills/custom/`:
|
||||
|
||||
```
|
||||
skills/public/my-skill/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
2. Write `SKILL.md` with YAML front matter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: My Skill
|
||||
description: What this skill does
|
||||
license: MIT
|
||||
allowed-tools:
|
||||
- read_file
|
||||
- write_file
|
||||
- bash
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
Instructions for the agent when this skill is enabled...
|
||||
```
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing:
|
||||
|
||||
1. Check existing documentation in `docs/`
|
||||
2. Look for similar issues or PRs on GitHub
|
||||
3. Open a discussion or issue on GitHub
|
||||
|
||||
Thank you for contributing to DeerFlow!
|
||||
28
backend/Dockerfile
Normal file
28
backend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# Backend Development Dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install uv
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy frontend source code
|
||||
COPY backend ./backend
|
||||
|
||||
# Install dependencies with cache mount
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
sh -c "cd backend && uv sync"
|
||||
|
||||
# Expose ports (gateway: 8001, langgraph: 2024)
|
||||
EXPOSE 8001 2024
|
||||
|
||||
# Default command (can be overridden in docker-compose)
|
||||
CMD ["sh", "-c", "uv run uvicorn src.gateway.app:app --host 0.0.0.0 --port 8001"]
|
||||
14
backend/Makefile
Normal file
14
backend/Makefile
Normal file
@@ -0,0 +1,14 @@
|
||||
install:
|
||||
uv sync
|
||||
|
||||
dev:
|
||||
uv run langgraph dev --no-browser --allow-blocking --no-reload
|
||||
|
||||
gateway:
|
||||
uv run uvicorn src.gateway.app:app --host 0.0.0.0 --port 8001
|
||||
|
||||
lint:
|
||||
uvx ruff check .
|
||||
|
||||
format:
|
||||
uvx ruff check . --fix && uvx ruff format .
|
||||
344
backend/README.md
Normal file
344
backend/README.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# DeerFlow Backend
|
||||
|
||||
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Nginx (Port 2026) │
|
||||
│ Unified reverse proxy │
|
||||
└───────┬──────────────────┬───────────┘
|
||||
│ │
|
||||
/api/langgraph/* │ │ /api/* (other)
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌────────────────────────┐
|
||||
│ LangGraph Server │ │ Gateway API (8001) │
|
||||
│ (Port 2024) │ │ FastAPI REST │
|
||||
│ │ │ │
|
||||
│ ┌────────────────┐ │ │ Models, MCP, Skills, │
|
||||
│ │ Lead Agent │ │ │ Memory, Uploads, │
|
||||
│ │ ┌──────────┐ │ │ │ Artifacts │
|
||||
│ │ │Middleware│ │ │ └────────────────────────┘
|
||||
│ │ │ Chain │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ │ ┌──────────┐ │ │
|
||||
│ │ │ Tools │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ │ ┌──────────┐ │ │
|
||||
│ │ │Subagents │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ └────────────────┘ │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
**Request Routing** (via Nginx):
|
||||
- `/api/langgraph/*` → LangGraph Server - agent interactions, threads, streaming
|
||||
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads
|
||||
- `/` (non-API) → Frontend - Next.js web interface
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### Lead Agent
|
||||
|
||||
The single LangGraph agent (`lead_agent`) is the runtime entry point, created via `make_lead_agent(config)`. It combines:
|
||||
|
||||
- **Dynamic model selection** with thinking and vision support
|
||||
- **Middleware chain** for cross-cutting concerns (9 middlewares)
|
||||
- **Tool system** with sandbox, MCP, community, and built-in tools
|
||||
- **Subagent delegation** for parallel task execution
|
||||
- **System prompt** with skills injection, memory context, and working directory guidance
|
||||
|
||||
### Middleware Chain
|
||||
|
||||
Middlewares execute in strict order, each handling a specific concern:
|
||||
|
||||
| # | Middleware | Purpose |
|
||||
|---|-----------|---------|
|
||||
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
|
||||
| 2 | **UploadsMiddleware** | Injects newly uploaded files into conversation context |
|
||||
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
|
||||
| 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
|
||||
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) |
|
||||
| 6 | **TitleMiddleware** | Auto-generates conversation titles after first exchange |
|
||||
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
|
||||
| 8 | **ViewImageMiddleware** | Injects image data for vision-capable models (conditional) |
|
||||
| 9 | **ClarificationMiddleware** | Intercepts clarification requests and interrupts execution (must be last) |
|
||||
|
||||
### Sandbox System
|
||||
|
||||
Per-thread isolated execution with virtual path translation:
|
||||
|
||||
- **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir`
|
||||
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
|
||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace`
|
||||
|
||||
### Subagent System
|
||||
|
||||
Async task delegation with concurrent execution:
|
||||
|
||||
- **Built-in agents**: `general-purpose` (full toolset) and `bash` (command specialist)
|
||||
- **Concurrency**: Max 3 subagents per turn, 15-minute timeout
|
||||
- **Execution**: Background thread pools with status tracking and SSE events
|
||||
- **Flow**: Agent calls `task()` tool → executor runs subagent in background → polls for completion → returns result
|
||||
|
||||
### Memory System
|
||||
|
||||
LLM-powered persistent context retention across conversations:
|
||||
|
||||
- **Automatic extraction**: Analyzes conversations for user context, facts, and preferences
|
||||
- **Structured storage**: User context (work, personal, top-of-mind), history, and confidence-scored facts
|
||||
- **Debounced updates**: Batches updates to minimize LLM calls (configurable wait time)
|
||||
- **System prompt injection**: Top facts + context injected into agent prompts
|
||||
- **Storage**: JSON file with mtime-based cache invalidation
|
||||
|
||||
### Tool Ecosystem
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| **Sandbox** | `bash`, `ls`, `read_file`, `write_file`, `str_replace` |
|
||||
| **Built-in** | `present_files`, `ask_clarification`, `view_image`, `task` (subagent) |
|
||||
| **Community** | Tavily (web search), Jina AI (web fetch), Firecrawl (scraping), DuckDuckGo (image search) |
|
||||
| **MCP** | Any Model Context Protocol server (stdio, SSE, HTTP transports) |
|
||||
| **Skills** | Domain-specific workflows injected via system prompt |
|
||||
|
||||
### Gateway API
|
||||
|
||||
FastAPI application providing REST endpoints for frontend integration:
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `GET /api/models` | List available LLM models |
|
||||
| `GET/PUT /api/mcp/config` | Manage MCP server configurations |
|
||||
| `GET/PUT /api/skills` | List and manage skills |
|
||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
||||
| `GET /api/memory` | Retrieve memory data |
|
||||
| `POST /api/memory/reload` | Force memory reload |
|
||||
| `GET /api/memory/config` | Memory configuration |
|
||||
| `GET /api/memory/status` | Combined config + data |
|
||||
| `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown) |
|
||||
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
||||
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- API keys for your chosen LLM provider
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd deer-flow
|
||||
|
||||
# Copy configuration files
|
||||
cp config.example.yaml config.yaml
|
||||
cp extensions_config.example.json extensions_config.json
|
||||
|
||||
# Install backend dependencies
|
||||
cd backend
|
||||
make install
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `config.yaml` in the project root:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- name: gpt-4o
|
||||
display_name: GPT-4o
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: gpt-4o
|
||||
api_key: $OPENAI_API_KEY
|
||||
supports_thinking: false
|
||||
supports_vision: true
|
||||
```
|
||||
|
||||
Set your API keys:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
**Full Application** (from project root):
|
||||
|
||||
```bash
|
||||
make dev # Starts LangGraph + Gateway + Frontend + Nginx
|
||||
```
|
||||
|
||||
Access at: http://localhost:2026
|
||||
|
||||
**Backend Only** (from backend directory):
|
||||
|
||||
```bash
|
||||
# Terminal 1: LangGraph server
|
||||
make dev
|
||||
|
||||
# Terminal 2: Gateway API
|
||||
make gateway
|
||||
```
|
||||
|
||||
Direct access: LangGraph at http://localhost:2024, Gateway at http://localhost:8001
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── agents/ # Agent system
|
||||
│ │ ├── lead_agent/ # Main agent (factory, prompts)
|
||||
│ │ ├── middlewares/ # 9 middleware components
|
||||
│ │ ├── memory/ # Memory extraction & storage
|
||||
│ │ └── thread_state.py # ThreadState schema
|
||||
│ ├── gateway/ # FastAPI Gateway API
|
||||
│ │ ├── app.py # Application setup
|
||||
│ │ └── routers/ # 6 route modules
|
||||
│ ├── sandbox/ # Sandbox execution
|
||||
│ │ ├── local/ # Local filesystem provider
|
||||
│ │ ├── sandbox.py # Abstract interface
|
||||
│ │ ├── tools.py # bash, ls, read/write/str_replace
|
||||
│ │ └── middleware.py # Sandbox lifecycle
|
||||
│ ├── subagents/ # Subagent delegation
|
||||
│ │ ├── builtins/ # general-purpose, bash agents
|
||||
│ │ ├── executor.py # Background execution engine
|
||||
│ │ └── registry.py # Agent registry
|
||||
│ ├── tools/builtins/ # Built-in tools
|
||||
│ ├── mcp/ # MCP protocol integration
|
||||
│ ├── models/ # Model factory
|
||||
│ ├── skills/ # Skill discovery & loading
|
||||
│ ├── config/ # Configuration system
|
||||
│ ├── community/ # Community tools & providers
|
||||
│ ├── reflection/ # Dynamic module loading
|
||||
│ └── utils/ # Utilities
|
||||
├── docs/ # Documentation
|
||||
├── tests/ # Test suite
|
||||
├── langgraph.json # LangGraph server configuration
|
||||
├── pyproject.toml # Python dependencies
|
||||
├── Makefile # Development commands
|
||||
└── Dockerfile # Container build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Main Configuration (`config.yaml`)
|
||||
|
||||
Place in project root. Config values starting with `$` resolve as environment variables.
|
||||
|
||||
Key sections:
|
||||
- `models` - LLM configurations with class paths, API keys, thinking/vision flags
|
||||
- `tools` - Tool definitions with module paths and groups
|
||||
- `tool_groups` - Logical tool groupings
|
||||
- `sandbox` - Execution environment provider
|
||||
- `skills` - Skills directory paths
|
||||
- `title` - Auto-title generation settings
|
||||
- `summarization` - Context summarization settings
|
||||
- `subagents` - Subagent system (enabled/disabled)
|
||||
- `memory` - Memory system settings (enabled, storage, debounce, facts limits)
|
||||
|
||||
### Extensions Configuration (`extensions_config.json`)
|
||||
|
||||
MCP servers and skill states in a single file:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {"GITHUB_TOKEN": "$GITHUB_TOKEN"}
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"pdf-processing": {"enabled": true}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `DEER_FLOW_CONFIG_PATH` - Override config.yaml location
|
||||
- `DEER_FLOW_EXTENSIONS_CONFIG_PATH` - Override extensions_config.json location
|
||||
- Model API keys: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, etc.
|
||||
- Tool API keys: `TAVILY_API_KEY`, `GITHUB_TOKEN`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
make install # Install dependencies
|
||||
make dev # Run LangGraph server (port 2024)
|
||||
make gateway # Run Gateway API (port 8001)
|
||||
make lint # Run linter (ruff)
|
||||
make format # Format code (ruff)
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- **Linter/Formatter**: `ruff`
|
||||
- **Line length**: 240 characters
|
||||
- **Python**: 3.12+ with type hints
|
||||
- **Quotes**: Double quotes
|
||||
- **Indentation**: 4 spaces
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **LangGraph** (1.0.6+) - Agent framework and multi-agent orchestration
|
||||
- **LangChain** (1.2.3+) - LLM abstractions and tool system
|
||||
- **FastAPI** (0.115.0+) - Gateway REST API
|
||||
- **langchain-mcp-adapters** - Model Context Protocol support
|
||||
- **agent-sandbox** - Sandboxed code execution
|
||||
- **markitdown** - Multi-format document conversion
|
||||
- **tavily-python** / **firecrawl-py** - Web search and scraping
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Configuration Guide](docs/CONFIGURATION.md)
|
||||
- [Architecture Details](docs/ARCHITECTURE.md)
|
||||
- [API Reference](docs/API.md)
|
||||
- [File Upload](docs/FILE_UPLOAD.md)
|
||||
- [Path Examples](docs/PATH_EXAMPLES.md)
|
||||
- [Context Summarization](docs/summarization.md)
|
||||
- [Plan Mode](docs/plan_mode_usage.md)
|
||||
- [Setup Guide](docs/SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
See the [LICENSE](../LICENSE) file in the project root.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
|
||||
92
backend/debug.py
Normal file
92
backend/debug.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Debug script for lead_agent.
|
||||
Run this file directly in VS Code with breakpoints.
|
||||
|
||||
Usage:
|
||||
1. Set breakpoints in agent.py or other files
|
||||
2. Press F5 or use "Run and Debug" panel
|
||||
3. Input messages in the terminal to interact with the agent
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure we can import from src
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Load environment variables
|
||||
from dotenv import load_dotenv
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from src.agents import make_lead_agent
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
# Initialize MCP tools at startup
|
||||
try:
|
||||
from src.mcp import initialize_mcp_tools
|
||||
|
||||
await initialize_mcp_tools()
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to initialize MCP tools: {e}")
|
||||
|
||||
# Create agent with default config
|
||||
config = {
|
||||
"configurable": {
|
||||
"thread_id": "debug-thread-001",
|
||||
"thinking_enabled": True,
|
||||
"is_plan_mode": True,
|
||||
# Uncomment to use a specific model
|
||||
"model_name": "kimi-k2.5",
|
||||
}
|
||||
}
|
||||
|
||||
agent = make_lead_agent(config)
|
||||
|
||||
print("=" * 50)
|
||||
print("Lead Agent Debug Mode")
|
||||
print("Type 'quit' or 'exit' to stop")
|
||||
print("=" * 50)
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("\nYou: ").strip()
|
||||
if not user_input:
|
||||
continue
|
||||
if user_input.lower() in ("quit", "exit"):
|
||||
print("Goodbye!")
|
||||
break
|
||||
|
||||
# Invoke the agent
|
||||
state = {"messages": [HumanMessage(content=user_input)]}
|
||||
result = await agent.ainvoke(state, config=config, context={"thread_id": "debug-thread-001"})
|
||||
|
||||
# Print the response
|
||||
if result.get("messages"):
|
||||
last_message = result["messages"][-1]
|
||||
print(f"\nAgent: {last_message.content}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted. Goodbye!")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
605
backend/docs/API.md
Normal file
605
backend/docs/API.md
Normal file
@@ -0,0 +1,605 @@
|
||||
# API Reference
|
||||
|
||||
This document provides a complete reference for the DeerFlow backend APIs.
|
||||
|
||||
## Overview
|
||||
|
||||
DeerFlow backend exposes two sets of APIs:
|
||||
|
||||
1. **LangGraph API** - Agent interactions, threads, and streaming (`/api/langgraph/*`)
|
||||
2. **Gateway API** - Models, MCP, skills, uploads, and artifacts (`/api/*`)
|
||||
|
||||
All APIs are accessed through the Nginx reverse proxy at port 2026.
|
||||
|
||||
## LangGraph API
|
||||
|
||||
Base URL: `/api/langgraph`
|
||||
|
||||
The LangGraph API is provided by the LangGraph server and follows the LangGraph SDK conventions.
|
||||
|
||||
### Threads
|
||||
|
||||
#### Create Thread
|
||||
|
||||
```http
|
||||
POST /api/langgraph/threads
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"thread_id": "abc123",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Thread State
|
||||
|
||||
```http
|
||||
GET /api/langgraph/threads/{thread_id}/state
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"values": {
|
||||
"messages": [...],
|
||||
"sandbox": {...},
|
||||
"artifacts": [...],
|
||||
"thread_data": {...},
|
||||
"title": "Conversation Title"
|
||||
},
|
||||
"next": [],
|
||||
"config": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### Runs
|
||||
|
||||
#### Create Run
|
||||
|
||||
Execute the agent with input.
|
||||
|
||||
```http
|
||||
POST /api/langgraph/threads/{thread_id}/runs
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello, can you help me?"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"configurable": {
|
||||
"model_name": "gpt-4",
|
||||
"thinking_enabled": false,
|
||||
"is_plan_mode": false
|
||||
}
|
||||
},
|
||||
"stream_mode": ["values", "messages"]
|
||||
}
|
||||
```
|
||||
|
||||
**Configurable Options:**
|
||||
- `model_name` (string): Override the default model
|
||||
- `thinking_enabled` (boolean): Enable extended thinking for supported models
|
||||
- `is_plan_mode` (boolean): Enable TodoList middleware for task tracking
|
||||
|
||||
**Response:** Server-Sent Events (SSE) stream
|
||||
|
||||
```
|
||||
event: values
|
||||
data: {"messages": [...], "title": "..."}
|
||||
|
||||
event: messages
|
||||
data: {"content": "Hello! I'd be happy to help.", "role": "assistant"}
|
||||
|
||||
event: end
|
||||
data: {}
|
||||
```
|
||||
|
||||
#### Get Run History
|
||||
|
||||
```http
|
||||
GET /api/langgraph/threads/{thread_id}/runs
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"runs": [
|
||||
{
|
||||
"run_id": "run123",
|
||||
"status": "success",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Stream Run
|
||||
|
||||
Stream responses in real-time.
|
||||
|
||||
```http
|
||||
POST /api/langgraph/threads/{thread_id}/runs/stream
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Same request body as Create Run. Returns SSE stream.
|
||||
|
||||
---
|
||||
|
||||
## Gateway API
|
||||
|
||||
Base URL: `/api`
|
||||
|
||||
### Models
|
||||
|
||||
#### List Models
|
||||
|
||||
Get all available LLM models from configuration.
|
||||
|
||||
```http
|
||||
GET /api/models
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "gpt-4",
|
||||
"display_name": "GPT-4",
|
||||
"supports_thinking": false,
|
||||
"supports_vision": true
|
||||
},
|
||||
{
|
||||
"name": "claude-3-opus",
|
||||
"display_name": "Claude 3 Opus",
|
||||
"supports_thinking": false,
|
||||
"supports_vision": true
|
||||
},
|
||||
{
|
||||
"name": "deepseek-v3",
|
||||
"display_name": "DeepSeek V3",
|
||||
"supports_thinking": true,
|
||||
"supports_vision": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Model Details
|
||||
|
||||
```http
|
||||
GET /api/models/{model_name}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"name": "gpt-4",
|
||||
"display_name": "GPT-4",
|
||||
"model": "gpt-4",
|
||||
"max_tokens": 4096,
|
||||
"supports_thinking": false,
|
||||
"supports_vision": true
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Configuration
|
||||
|
||||
#### Get MCP Config
|
||||
|
||||
Get current MCP server configurations.
|
||||
|
||||
```http
|
||||
GET /api/mcp/config
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {
|
||||
"GITHUB_TOKEN": "***"
|
||||
},
|
||||
"description": "GitHub operations"
|
||||
},
|
||||
"filesystem": {
|
||||
"enabled": false,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"description": "File system access"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Update MCP Config
|
||||
|
||||
Update MCP server configurations.
|
||||
|
||||
```http
|
||||
PUT /api/mcp/config
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {
|
||||
"GITHUB_TOKEN": "$GITHUB_TOKEN"
|
||||
},
|
||||
"description": "GitHub operations"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "MCP configuration updated"
|
||||
}
|
||||
```
|
||||
|
||||
### Skills
|
||||
|
||||
#### List Skills
|
||||
|
||||
Get all available skills.
|
||||
|
||||
```http
|
||||
GET /api/skills
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "pdf-processing",
|
||||
"display_name": "PDF Processing",
|
||||
"description": "Handle PDF documents efficiently",
|
||||
"enabled": true,
|
||||
"license": "MIT",
|
||||
"path": "public/pdf-processing"
|
||||
},
|
||||
{
|
||||
"name": "frontend-design",
|
||||
"display_name": "Frontend Design",
|
||||
"description": "Design and build frontend interfaces",
|
||||
"enabled": false,
|
||||
"license": "MIT",
|
||||
"path": "public/frontend-design"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Skill Details
|
||||
|
||||
```http
|
||||
GET /api/skills/{skill_name}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"name": "pdf-processing",
|
||||
"display_name": "PDF Processing",
|
||||
"description": "Handle PDF documents efficiently",
|
||||
"enabled": true,
|
||||
"license": "MIT",
|
||||
"path": "public/pdf-processing",
|
||||
"allowed_tools": ["read_file", "write_file", "bash"],
|
||||
"content": "# PDF Processing\n\nInstructions for the agent..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Enable Skill
|
||||
|
||||
```http
|
||||
POST /api/skills/{skill_name}/enable
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Skill 'pdf-processing' enabled"
|
||||
}
|
||||
```
|
||||
|
||||
#### Disable Skill
|
||||
|
||||
```http
|
||||
POST /api/skills/{skill_name}/disable
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Skill 'pdf-processing' disabled"
|
||||
}
|
||||
```
|
||||
|
||||
#### Install Skill
|
||||
|
||||
Install a skill from a `.skill` file.
|
||||
|
||||
```http
|
||||
POST /api/skills/install
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
- `file`: The `.skill` file to install
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Skill 'my-skill' installed successfully",
|
||||
"skill": {
|
||||
"name": "my-skill",
|
||||
"display_name": "My Skill",
|
||||
"path": "custom/my-skill"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File Uploads
|
||||
|
||||
#### Upload Files
|
||||
|
||||
Upload one or more files to a thread.
|
||||
|
||||
```http
|
||||
POST /api/threads/{thread_id}/uploads
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
- `files`: One or more files to upload
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"files": [
|
||||
{
|
||||
"filename": "document.pdf",
|
||||
"size": 1234567,
|
||||
"path": ".deer-flow/threads/abc123/user-data/uploads/document.pdf",
|
||||
"virtual_path": "/mnt/user-data/uploads/document.pdf",
|
||||
"artifact_url": "/api/threads/abc123/artifacts/mnt/user-data/uploads/document.pdf",
|
||||
"markdown_file": "document.md",
|
||||
"markdown_path": ".deer-flow/threads/abc123/user-data/uploads/document.md",
|
||||
"markdown_virtual_path": "/mnt/user-data/uploads/document.md",
|
||||
"markdown_artifact_url": "/api/threads/abc123/artifacts/mnt/user-data/uploads/document.md"
|
||||
}
|
||||
],
|
||||
"message": "Successfully uploaded 1 file(s)"
|
||||
}
|
||||
```
|
||||
|
||||
**Supported Document Formats** (auto-converted to Markdown):
|
||||
- PDF (`.pdf`)
|
||||
- PowerPoint (`.ppt`, `.pptx`)
|
||||
- Excel (`.xls`, `.xlsx`)
|
||||
- Word (`.doc`, `.docx`)
|
||||
|
||||
#### List Uploaded Files
|
||||
|
||||
```http
|
||||
GET /api/threads/{thread_id}/uploads/list
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"filename": "document.pdf",
|
||||
"size": 1234567,
|
||||
"path": ".deer-flow/threads/abc123/user-data/uploads/document.pdf",
|
||||
"virtual_path": "/mnt/user-data/uploads/document.pdf",
|
||||
"artifact_url": "/api/threads/abc123/artifacts/mnt/user-data/uploads/document.pdf",
|
||||
"extension": ".pdf",
|
||||
"modified": 1705997600.0
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### Delete File
|
||||
|
||||
```http
|
||||
DELETE /api/threads/{thread_id}/uploads/{filename}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Deleted document.pdf"
|
||||
}
|
||||
```
|
||||
|
||||
### Artifacts
|
||||
|
||||
#### Get Artifact
|
||||
|
||||
Download or view an artifact generated by the agent.
|
||||
|
||||
```http
|
||||
GET /api/threads/{thread_id}/artifacts/{path}
|
||||
```
|
||||
|
||||
**Path Examples:**
|
||||
- `/api/threads/abc123/artifacts/mnt/user-data/outputs/result.txt`
|
||||
- `/api/threads/abc123/artifacts/mnt/user-data/uploads/document.pdf`
|
||||
|
||||
**Query Parameters:**
|
||||
- `download` (boolean): If `true`, force download with Content-Disposition header
|
||||
|
||||
**Response:** File content with appropriate Content-Type
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All APIs return errors in a consistent format:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Error message describing what went wrong"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status Codes:**
|
||||
- `400` - Bad Request: Invalid input
|
||||
- `404` - Not Found: Resource not found
|
||||
- `422` - Validation Error: Request validation failed
|
||||
- `500` - Internal Server Error: Server-side error
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
Currently, DeerFlow does not implement authentication. All APIs are accessible without credentials.
|
||||
|
||||
For production deployments, it is recommended to:
|
||||
1. Use Nginx for basic auth or OAuth integration
|
||||
2. Deploy behind a VPN or private network
|
||||
3. Implement custom authentication middleware
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
No rate limiting is implemented by default. For production deployments, configure rate limiting in Nginx:
|
||||
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Support
|
||||
|
||||
The LangGraph server supports WebSocket connections for real-time streaming. Connect to:
|
||||
|
||||
```
|
||||
ws://localhost:2026/api/langgraph/threads/{thread_id}/runs/stream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SDK Usage
|
||||
|
||||
### Python (LangGraph SDK)
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
client = get_client(url="http://localhost:2026/api/langgraph")
|
||||
|
||||
# Create thread
|
||||
thread = await client.threads.create()
|
||||
|
||||
# Run agent
|
||||
async for event in client.runs.stream(
|
||||
thread["thread_id"],
|
||||
"lead_agent",
|
||||
input={"messages": [{"role": "user", "content": "Hello"}]},
|
||||
config={"configurable": {"model_name": "gpt-4"}},
|
||||
stream_mode=["values", "messages"],
|
||||
):
|
||||
print(event)
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
// Using fetch for Gateway API
|
||||
const response = await fetch('/api/models');
|
||||
const data = await response.json();
|
||||
console.log(data.models);
|
||||
|
||||
// Using EventSource for streaming
|
||||
const eventSource = new EventSource(
|
||||
`/api/langgraph/threads/${threadId}/runs/stream`
|
||||
);
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log(JSON.parse(event.data));
|
||||
};
|
||||
```
|
||||
|
||||
### cURL Examples
|
||||
|
||||
```bash
|
||||
# List models
|
||||
curl http://localhost:2026/api/models
|
||||
|
||||
# Get MCP config
|
||||
curl http://localhost:2026/api/mcp/config
|
||||
|
||||
# Upload file
|
||||
curl -X POST http://localhost:2026/api/threads/abc123/uploads \
|
||||
-F "files=@document.pdf"
|
||||
|
||||
# Enable skill
|
||||
curl -X POST http://localhost:2026/api/skills/pdf-processing/enable
|
||||
|
||||
# Create thread and run agent
|
||||
curl -X POST http://localhost:2026/api/langgraph/threads \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
|
||||
curl -X POST http://localhost:2026/api/langgraph/threads/abc123/runs \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": {"messages": [{"role": "user", "content": "Hello"}]},
|
||||
"config": {"configurable": {"model_name": "gpt-4"}}
|
||||
}'
|
||||
```
|
||||
238
backend/docs/APPLE_CONTAINER.md
Normal file
238
backend/docs/APPLE_CONTAINER.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Apple Container Support
|
||||
|
||||
DeerFlow now supports Apple Container as the preferred container runtime on macOS, with automatic fallback to Docker.
|
||||
|
||||
## Overview
|
||||
|
||||
Starting with this version, DeerFlow automatically detects and uses Apple Container on macOS when available, falling back to Docker when:
|
||||
- Apple Container is not installed
|
||||
- Running on non-macOS platforms
|
||||
|
||||
This provides better performance on Apple Silicon Macs while maintaining compatibility across all platforms.
|
||||
|
||||
## Benefits
|
||||
|
||||
### On Apple Silicon Macs with Apple Container:
|
||||
- **Better Performance**: Native ARM64 execution without Rosetta 2 translation
|
||||
- **Lower Resource Usage**: Lighter weight than Docker Desktop
|
||||
- **Native Integration**: Uses macOS Virtualization.framework
|
||||
|
||||
### Fallback to Docker:
|
||||
- Full backward compatibility
|
||||
- Works on all platforms (macOS, Linux, Windows)
|
||||
- No configuration changes needed
|
||||
|
||||
## Requirements
|
||||
|
||||
### For Apple Container (macOS only):
|
||||
- macOS 15.0 or later
|
||||
- Apple Silicon (M1/M2/M3/M4)
|
||||
- Apple Container CLI installed
|
||||
|
||||
### Installation:
|
||||
```bash
|
||||
# Download from GitHub releases
|
||||
# https://github.com/apple/container/releases
|
||||
|
||||
# Verify installation
|
||||
container --version
|
||||
|
||||
# Start the service
|
||||
container system start
|
||||
```
|
||||
|
||||
### For Docker (all platforms):
|
||||
- Docker Desktop or Docker Engine
|
||||
|
||||
## How It Works
|
||||
|
||||
### Automatic Detection
|
||||
|
||||
The `AioSandboxProvider` automatically detects the available container runtime:
|
||||
|
||||
1. On macOS: Try `container --version`
|
||||
- Success → Use Apple Container
|
||||
- Failure → Fall back to Docker
|
||||
|
||||
2. On other platforms: Use Docker directly
|
||||
|
||||
### Runtime Differences
|
||||
|
||||
Both runtimes use nearly identical command syntax:
|
||||
|
||||
**Container Startup:**
|
||||
```bash
|
||||
# Apple Container
|
||||
container run --rm -d -p 8080:8080 -v /host:/container -e KEY=value image
|
||||
|
||||
# Docker
|
||||
docker run --rm -d -p 8080:8080 -v /host:/container -e KEY=value image
|
||||
```
|
||||
|
||||
**Container Cleanup:**
|
||||
```bash
|
||||
# Apple Container (with --rm flag)
|
||||
container stop <id> # Auto-removes due to --rm
|
||||
|
||||
# Docker (with --rm flag)
|
||||
docker stop <id> # Auto-removes due to --rm
|
||||
```
|
||||
|
||||
### Implementation Details
|
||||
|
||||
The implementation is in `backend/src/community/aio_sandbox/aio_sandbox_provider.py`:
|
||||
|
||||
- `_detect_container_runtime()`: Detects available runtime at startup
|
||||
- `_start_container()`: Uses detected runtime, skips Docker-specific options for Apple Container
|
||||
- `_stop_container()`: Uses appropriate stop command for the runtime
|
||||
|
||||
## Configuration
|
||||
|
||||
No configuration changes are needed! The system works automatically.
|
||||
|
||||
However, you can verify the runtime in use by checking the logs:
|
||||
|
||||
```
|
||||
INFO:src.community.aio_sandbox.aio_sandbox_provider:Detected Apple Container: container version 0.1.0
|
||||
INFO:src.community.aio_sandbox.aio_sandbox_provider:Starting sandbox container using container: ...
|
||||
```
|
||||
|
||||
Or for Docker:
|
||||
```
|
||||
INFO:src.community.aio_sandbox.aio_sandbox_provider:Apple Container not available, falling back to Docker
|
||||
INFO:src.community.aio_sandbox.aio_sandbox_provider:Starting sandbox container using docker: ...
|
||||
```
|
||||
|
||||
## Container Images
|
||||
|
||||
Both runtimes use OCI-compatible images. The default image works with both:
|
||||
|
||||
```yaml
|
||||
sandbox:
|
||||
use: src.community.aio_sandbox:AioSandboxProvider
|
||||
image: enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest # Default image
|
||||
```
|
||||
|
||||
Make sure your images are available for the appropriate architecture:
|
||||
- ARM64 for Apple Container on Apple Silicon
|
||||
- AMD64 for Docker on Intel Macs
|
||||
- Multi-arch images work on both
|
||||
|
||||
### Pre-pulling Images (Recommended)
|
||||
|
||||
**Important**: Container images are typically large (500MB+) and are pulled on first use, which can cause a long wait time without clear feedback.
|
||||
|
||||
**Best Practice**: Pre-pull the image during setup:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
make setup-sandbox
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Read the configured image from `config.yaml` (or use default)
|
||||
2. Detect available runtime (Apple Container or Docker)
|
||||
3. Pull the image with progress indication
|
||||
4. Verify the image is ready for use
|
||||
|
||||
**Manual pre-pull**:
|
||||
|
||||
```bash
|
||||
# Using Apple Container
|
||||
container pull enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
|
||||
|
||||
# Using Docker
|
||||
docker pull enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
|
||||
```
|
||||
|
||||
If you skip pre-pulling, the image will be automatically pulled on first agent execution, which may take several minutes depending on your network speed.
|
||||
|
||||
## Cleanup Scripts
|
||||
|
||||
The project includes a unified cleanup script that handles both runtimes:
|
||||
|
||||
**Script:** `scripts/cleanup-containers.sh`
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Clean up all DeerFlow sandbox containers
|
||||
./scripts/cleanup-containers.sh deer-flow-sandbox
|
||||
|
||||
# Custom prefix
|
||||
./scripts/cleanup-containers.sh my-prefix
|
||||
```
|
||||
|
||||
**Makefile Integration:**
|
||||
|
||||
All cleanup commands in `Makefile` automatically handle both runtimes:
|
||||
```bash
|
||||
make stop # Stops all services and cleans up containers
|
||||
make clean # Full cleanup including logs
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Test the container runtime detection:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python test_container_runtime.py
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Detect the available runtime
|
||||
2. Optionally start a test container
|
||||
3. Verify connectivity
|
||||
4. Clean up
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Apple Container not detected on macOS
|
||||
|
||||
1. Check if installed:
|
||||
```bash
|
||||
which container
|
||||
container --version
|
||||
```
|
||||
|
||||
2. Check if service is running:
|
||||
```bash
|
||||
container system start
|
||||
```
|
||||
|
||||
3. Check logs for detection:
|
||||
```bash
|
||||
# Look for detection message in application logs
|
||||
grep "container runtime" logs/*.log
|
||||
```
|
||||
|
||||
### Containers not cleaning up
|
||||
|
||||
1. Manually check running containers:
|
||||
```bash
|
||||
# Apple Container
|
||||
container list
|
||||
|
||||
# Docker
|
||||
docker ps
|
||||
```
|
||||
|
||||
2. Run cleanup script manually:
|
||||
```bash
|
||||
./scripts/cleanup-containers.sh deer-flow-sandbox
|
||||
```
|
||||
|
||||
### Performance issues
|
||||
|
||||
- Apple Container should be faster on Apple Silicon
|
||||
- If experiencing issues, you can force Docker by temporarily renaming the `container` command:
|
||||
```bash
|
||||
# Temporary workaround - not recommended for permanent use
|
||||
sudo mv /opt/homebrew/bin/container /opt/homebrew/bin/container.bak
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Apple Container GitHub](https://github.com/apple/container)
|
||||
- [Apple Container Documentation](https://github.com/apple/container/blob/main/docs/)
|
||||
- [OCI Image Spec](https://github.com/opencontainers/image-spec)
|
||||
464
backend/docs/ARCHITECTURE.md
Normal file
464
backend/docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# Architecture Overview
|
||||
|
||||
This document provides a comprehensive overview of the DeerFlow backend architecture.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ Client (Browser) │
|
||||
└─────────────────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ Nginx (Port 2026) │
|
||||
│ Unified Reverse Proxy Entry Point │
|
||||
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ /api/langgraph/* → LangGraph Server (2024) │ │
|
||||
│ │ /api/* → Gateway API (8001) │ │
|
||||
│ │ /* → Frontend (3000) │ │
|
||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ LangGraph Server │ │ Gateway API │ │ Frontend │
|
||||
│ (Port 2024) │ │ (Port 8001) │ │ (Port 3000) │
|
||||
│ │ │ │ │ │
|
||||
│ - Agent Runtime │ │ - Models API │ │ - Next.js App │
|
||||
│ - Thread Mgmt │ │ - MCP Config │ │ - React UI │
|
||||
│ - SSE Streaming │ │ - Skills Mgmt │ │ - Chat Interface │
|
||||
│ - Checkpointing │ │ - File Uploads │ │ │
|
||||
│ │ │ - Artifacts │ │ │
|
||||
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
|
||||
│ │
|
||||
│ ┌─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ Shared Configuration │
|
||||
│ ┌─────────────────────────┐ ┌────────────────────────────────────────┐ │
|
||||
│ │ config.yaml │ │ extensions_config.json │ │
|
||||
│ │ - Models │ │ - MCP Servers │ │
|
||||
│ │ - Tools │ │ - Skills State │ │
|
||||
│ │ - Sandbox │ │ │ │
|
||||
│ │ - Summarization │ │ │ │
|
||||
│ └─────────────────────────┘ └────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Details
|
||||
|
||||
### LangGraph Server
|
||||
|
||||
The LangGraph server is the core agent runtime, built on LangGraph for robust multi-agent workflow orchestration.
|
||||
|
||||
**Entry Point**: `src/agents/lead_agent/agent.py:make_lead_agent`
|
||||
|
||||
**Key Responsibilities**:
|
||||
- Agent creation and configuration
|
||||
- Thread state management
|
||||
- Middleware chain execution
|
||||
- Tool execution orchestration
|
||||
- SSE streaming for real-time responses
|
||||
|
||||
**Configuration**: `langgraph.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"type": "agent",
|
||||
"path": "src.agents:make_lead_agent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gateway API
|
||||
|
||||
FastAPI application providing REST endpoints for non-agent operations.
|
||||
|
||||
**Entry Point**: `src/gateway/app.py`
|
||||
|
||||
**Routers**:
|
||||
- `models.py` - `/api/models` - Model listing and details
|
||||
- `mcp.py` - `/api/mcp` - MCP server configuration
|
||||
- `skills.py` - `/api/skills` - Skills management
|
||||
- `uploads.py` - `/api/threads/{id}/uploads` - File upload
|
||||
- `artifacts.py` - `/api/threads/{id}/artifacts` - Artifact serving
|
||||
|
||||
### Agent Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ make_lead_agent(config) │
|
||||
└────────────────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Middleware Chain │
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. ThreadDataMiddleware - Initialize workspace/uploads/outputs │ │
|
||||
│ │ 2. UploadsMiddleware - Process uploaded files │ │
|
||||
│ │ 3. SandboxMiddleware - Acquire sandbox environment │ │
|
||||
│ │ 4. SummarizationMiddleware - Context reduction (if enabled) │ │
|
||||
│ │ 5. TitleMiddleware - Auto-generate titles │ │
|
||||
│ │ 6. TodoListMiddleware - Task tracking (if plan_mode) │ │
|
||||
│ │ 7. ViewImageMiddleware - Vision model support │ │
|
||||
│ │ 8. ClarificationMiddleware - Handle clarifications │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Agent Core │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Model │ │ Tools │ │ System Prompt │ │
|
||||
│ │ (from factory) │ │ (configured + │ │ (with skills) │ │
|
||||
│ │ │ │ MCP + builtin) │ │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Thread State
|
||||
|
||||
The `ThreadState` extends LangGraph's `AgentState` with additional fields:
|
||||
|
||||
```python
|
||||
class ThreadState(AgentState):
|
||||
# Core state from AgentState
|
||||
messages: list[BaseMessage]
|
||||
|
||||
# DeerFlow extensions
|
||||
sandbox: dict # Sandbox environment info
|
||||
artifacts: list[str] # Generated file paths
|
||||
thread_data: dict # {workspace, uploads, outputs} paths
|
||||
title: str | None # Auto-generated conversation title
|
||||
todos: list[dict] # Task tracking (plan mode)
|
||||
viewed_images: dict # Vision model image data
|
||||
```
|
||||
|
||||
### Sandbox System
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Sandbox Architecture │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────┐
|
||||
│ SandboxProvider │ (Abstract)
|
||||
│ - acquire() │
|
||||
│ - get() │
|
||||
│ - release() │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ LocalSandboxProvider │ │ AioSandboxProvider │
|
||||
│ (src/sandbox/local.py) │ │ (src/community/) │
|
||||
│ │ │ │
|
||||
│ - Singleton instance │ │ - Docker-based │
|
||||
│ - Direct execution │ │ - Isolated containers │
|
||||
│ - Development use │ │ - Production use │
|
||||
└─────────────────────────┘ └─────────────────────────┘
|
||||
|
||||
┌─────────────────────────┐
|
||||
│ Sandbox │ (Abstract)
|
||||
│ - execute_command() │
|
||||
│ - read_file() │
|
||||
│ - write_file() │
|
||||
│ - list_dir() │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
**Virtual Path Mapping**:
|
||||
|
||||
| Virtual Path | Physical Path |
|
||||
|-------------|---------------|
|
||||
| `/mnt/user-data/workspace` | `backend/.deer-flow/threads/{thread_id}/user-data/workspace` |
|
||||
| `/mnt/user-data/uploads` | `backend/.deer-flow/threads/{thread_id}/user-data/uploads` |
|
||||
| `/mnt/user-data/outputs` | `backend/.deer-flow/threads/{thread_id}/user-data/outputs` |
|
||||
| `/mnt/skills` | `deer-flow/skills/` |
|
||||
|
||||
### Tool System
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Tool Sources │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ Built-in Tools │ │ Configured Tools │ │ MCP Tools │
|
||||
│ (src/tools/) │ │ (config.yaml) │ │ (extensions.json) │
|
||||
├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤
|
||||
│ - present_file │ │ - web_search │ │ - github │
|
||||
│ - ask_clarification │ │ - web_fetch │ │ - filesystem │
|
||||
│ - view_image │ │ - bash │ │ - postgres │
|
||||
│ │ │ - read_file │ │ - brave-search │
|
||||
│ │ │ - write_file │ │ - puppeteer │
|
||||
│ │ │ - str_replace │ │ - ... │
|
||||
│ │ │ - ls │ │ │
|
||||
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
|
||||
│ │ │
|
||||
└───────────────────────┴───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ get_available_tools() │
|
||||
│ (src/tools/__init__) │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### Model Factory
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Model Factory │
|
||||
│ (src/models/factory.py) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
config.yaml:
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ models: │
|
||||
│ - name: gpt-4 │
|
||||
│ display_name: GPT-4 │
|
||||
│ use: langchain_openai:ChatOpenAI │
|
||||
│ model: gpt-4 │
|
||||
│ api_key: $OPENAI_API_KEY │
|
||||
│ max_tokens: 4096 │
|
||||
│ supports_thinking: false │
|
||||
│ supports_vision: true │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ create_chat_model() │
|
||||
│ - name: str │
|
||||
│ - thinking_enabled │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ resolve_class() │
|
||||
│ (reflection system) │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ BaseChatModel │
|
||||
│ (LangChain instance) │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
**Supported Providers**:
|
||||
- OpenAI (`langchain_openai:ChatOpenAI`)
|
||||
- Anthropic (`langchain_anthropic:ChatAnthropic`)
|
||||
- DeepSeek (`langchain_deepseek:ChatDeepSeek`)
|
||||
- Custom via LangChain integrations
|
||||
|
||||
### MCP Integration
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ MCP Integration │
|
||||
│ (src/mcp/manager.py) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
extensions_config.json:
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ { │
|
||||
│ "mcpServers": { │
|
||||
│ "github": { │
|
||||
│ "enabled": true, │
|
||||
│ "type": "stdio", │
|
||||
│ "command": "npx", │
|
||||
│ "args": ["-y", "@modelcontextprotocol/server-github"], │
|
||||
│ "env": {"GITHUB_TOKEN": "$GITHUB_TOKEN"} │
|
||||
│ } │
|
||||
│ } │
|
||||
│ } │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ MultiServerMCPClient │
|
||||
│ (langchain-mcp-adapters)│
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||
│ stdio │ │ SSE │ │ HTTP │
|
||||
│ transport │ │ transport │ │ transport │
|
||||
└───────────┘ └───────────┘ └───────────┘
|
||||
```
|
||||
|
||||
### Skills System
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Skills System │
|
||||
│ (src/skills/loader.py) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Directory Structure:
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ skills/ │
|
||||
│ ├── public/ # Public skills (committed) │
|
||||
│ │ ├── pdf-processing/ │
|
||||
│ │ │ └── SKILL.md │
|
||||
│ │ ├── frontend-design/ │
|
||||
│ │ │ └── SKILL.md │
|
||||
│ │ └── ... │
|
||||
│ └── custom/ # Custom skills (gitignored) │
|
||||
│ └── user-installed/ │
|
||||
│ └── SKILL.md │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
SKILL.md Format:
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ --- │
|
||||
│ name: PDF Processing │
|
||||
│ description: Handle PDF documents efficiently │
|
||||
│ license: MIT │
|
||||
│ allowed-tools: │
|
||||
│ - read_file │
|
||||
│ - write_file │
|
||||
│ - bash │
|
||||
│ --- │
|
||||
│ │
|
||||
│ # Skill Instructions │
|
||||
│ Content injected into system prompt... │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Request Flow Example │
|
||||
│ User sends message to agent │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
1. Client → Nginx
|
||||
POST /api/langgraph/threads/{thread_id}/runs
|
||||
{"input": {"messages": [{"role": "user", "content": "Hello"}]}}
|
||||
|
||||
2. Nginx → LangGraph Server (2024)
|
||||
Proxied to LangGraph server
|
||||
|
||||
3. LangGraph Server
|
||||
a. Load/create thread state
|
||||
b. Execute middleware chain:
|
||||
- ThreadDataMiddleware: Set up paths
|
||||
- UploadsMiddleware: Inject file list
|
||||
- SandboxMiddleware: Acquire sandbox
|
||||
- SummarizationMiddleware: Check token limits
|
||||
- TitleMiddleware: Generate title if needed
|
||||
- TodoListMiddleware: Load todos (if plan mode)
|
||||
- ViewImageMiddleware: Process images
|
||||
- ClarificationMiddleware: Check for clarifications
|
||||
|
||||
c. Execute agent:
|
||||
- Model processes messages
|
||||
- May call tools (bash, web_search, etc.)
|
||||
- Tools execute via sandbox
|
||||
- Results added to messages
|
||||
|
||||
d. Stream response via SSE
|
||||
|
||||
4. Client receives streaming response
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### File Upload Flow
|
||||
|
||||
```
|
||||
1. Client uploads file
|
||||
POST /api/threads/{thread_id}/uploads
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
2. Gateway receives file
|
||||
- Validates file
|
||||
- Stores in .deer-flow/threads/{thread_id}/user-data/uploads/
|
||||
- If document: converts to Markdown via markitdown
|
||||
|
||||
3. Returns response
|
||||
{
|
||||
"files": [{
|
||||
"filename": "doc.pdf",
|
||||
"path": ".deer-flow/.../uploads/doc.pdf",
|
||||
"virtual_path": "/mnt/user-data/uploads/doc.pdf",
|
||||
"artifact_url": "/api/threads/.../artifacts/mnt/.../doc.pdf"
|
||||
}]
|
||||
}
|
||||
|
||||
4. Next agent run
|
||||
- UploadsMiddleware lists files
|
||||
- Injects file list into messages
|
||||
- Agent can access via virtual_path
|
||||
```
|
||||
|
||||
### Configuration Reload
|
||||
|
||||
```
|
||||
1. Client updates MCP config
|
||||
PUT /api/mcp/config
|
||||
|
||||
2. Gateway writes extensions_config.json
|
||||
- Updates mcpServers section
|
||||
- File mtime changes
|
||||
|
||||
3. MCP Manager detects change
|
||||
- get_cached_mcp_tools() checks mtime
|
||||
- If changed: reinitializes MCP client
|
||||
- Loads updated server configurations
|
||||
|
||||
4. Next agent run uses new tools
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Sandbox Isolation
|
||||
|
||||
- Agent code executes within sandbox boundaries
|
||||
- Local sandbox: Direct execution (development only)
|
||||
- Docker sandbox: Container isolation (production recommended)
|
||||
- Path traversal prevention in file operations
|
||||
|
||||
### API Security
|
||||
|
||||
- Thread isolation: Each thread has separate data directories
|
||||
- File validation: Uploads checked for path safety
|
||||
- Environment variable resolution: Secrets not stored in config
|
||||
|
||||
### MCP Security
|
||||
|
||||
- Each MCP server runs in its own process
|
||||
- Environment variables resolved at runtime
|
||||
- Servers can be enabled/disabled independently
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Caching
|
||||
|
||||
- MCP tools cached with file mtime invalidation
|
||||
- Configuration loaded once, reloaded on file change
|
||||
- Skills parsed once at startup, cached in memory
|
||||
|
||||
### Streaming
|
||||
|
||||
- SSE used for real-time response streaming
|
||||
- Reduces time to first token
|
||||
- Enables progress visibility for long operations
|
||||
|
||||
### Context Management
|
||||
|
||||
- Summarization middleware reduces context when limits approached
|
||||
- Configurable triggers: tokens, messages, or fraction
|
||||
- Preserves recent messages while summarizing older ones
|
||||
256
backend/docs/AUTO_TITLE_GENERATION.md
Normal file
256
backend/docs/AUTO_TITLE_GENERATION.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# 自动 Thread Title 生成功能
|
||||
|
||||
## 功能说明
|
||||
|
||||
自动为对话线程生成标题,在用户首次提问并收到回复后自动触发。
|
||||
|
||||
## 实现方式
|
||||
|
||||
使用 `TitleMiddleware` 在 `after_agent` 钩子中:
|
||||
1. 检测是否是首次对话(1个用户消息 + 1个助手回复)
|
||||
2. 检查 state 是否已有 title
|
||||
3. 调用 LLM 生成简洁的标题(默认最多6个词)
|
||||
4. 将 title 存储到 `ThreadState` 中(会被 checkpointer 持久化)
|
||||
|
||||
## ⚠️ 重要:存储机制
|
||||
|
||||
### Title 存储位置
|
||||
|
||||
Title 存储在 **`ThreadState.title`** 中,而非 thread metadata:
|
||||
|
||||
```python
|
||||
class ThreadState(AgentState):
|
||||
sandbox: SandboxState | None = None
|
||||
title: str | None = None # ✅ Title stored here
|
||||
```
|
||||
|
||||
### 持久化说明
|
||||
|
||||
| 部署方式 | 持久化 | 说明 |
|
||||
|---------|--------|------|
|
||||
| **LangGraph Studio (本地)** | ❌ 否 | 仅内存存储,重启后丢失 |
|
||||
| **LangGraph Platform** | ✅ 是 | 自动持久化到数据库 |
|
||||
| **自定义 + Checkpointer** | ✅ 是 | 需配置 PostgreSQL/SQLite checkpointer |
|
||||
|
||||
### 如何启用持久化
|
||||
|
||||
如果需要在本地开发时也持久化 title,需要配置 checkpointer:
|
||||
|
||||
```python
|
||||
# 在 langgraph.json 同级目录创建 checkpointer.py
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
|
||||
checkpointer = PostgresSaver.from_conn_string(
|
||||
"postgresql://user:pass@localhost/dbname"
|
||||
)
|
||||
```
|
||||
|
||||
然后在 `langgraph.json` 中引用:
|
||||
|
||||
```json
|
||||
{
|
||||
"graphs": {
|
||||
"lead_agent": "src.agents:lead_agent"
|
||||
},
|
||||
"checkpointer": "checkpointer:checkpointer"
|
||||
}
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
在 `config.yaml` 中添加(可选):
|
||||
|
||||
```yaml
|
||||
title:
|
||||
enabled: true
|
||||
max_words: 6
|
||||
max_chars: 60
|
||||
model_name: null # 使用默认模型
|
||||
```
|
||||
|
||||
或在代码中配置:
|
||||
|
||||
```python
|
||||
from src.config.title_config import TitleConfig, set_title_config
|
||||
|
||||
set_title_config(TitleConfig(
|
||||
enabled=True,
|
||||
max_words=8,
|
||||
max_chars=80,
|
||||
))
|
||||
```
|
||||
|
||||
## 客户端使用
|
||||
|
||||
### 获取 Thread Title
|
||||
|
||||
```typescript
|
||||
// 方式1: 从 thread state 获取
|
||||
const state = await client.threads.getState(threadId);
|
||||
const title = state.values.title || "New Conversation";
|
||||
|
||||
// 方式2: 监听 stream 事件
|
||||
for await (const chunk of client.runs.stream(threadId, assistantId, {
|
||||
input: { messages: [{ role: "user", content: "Hello" }] }
|
||||
})) {
|
||||
if (chunk.event === "values" && chunk.data.title) {
|
||||
console.log("Title:", chunk.data.title);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 显示 Title
|
||||
|
||||
```typescript
|
||||
// 在对话列表中显示
|
||||
function ConversationList() {
|
||||
const [threads, setThreads] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadThreads() {
|
||||
const allThreads = await client.threads.list();
|
||||
|
||||
// 获取每个 thread 的 state 来读取 title
|
||||
const threadsWithTitles = await Promise.all(
|
||||
allThreads.map(async (t) => {
|
||||
const state = await client.threads.getState(t.thread_id);
|
||||
return {
|
||||
id: t.thread_id,
|
||||
title: state.values.title || "New Conversation",
|
||||
updatedAt: t.updated_at,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setThreads(threadsWithTitles);
|
||||
}
|
||||
loadThreads();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{threads.map(thread => (
|
||||
<li key={thread.id}>
|
||||
<a href={`/chat/${thread.id}`}>{thread.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 工作流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Client
|
||||
participant LangGraph
|
||||
participant TitleMiddleware
|
||||
participant LLM
|
||||
participant Checkpointer
|
||||
|
||||
User->>Client: 发送首条消息
|
||||
Client->>LangGraph: POST /threads/{id}/runs
|
||||
LangGraph->>Agent: 处理消息
|
||||
Agent-->>LangGraph: 返回回复
|
||||
LangGraph->>TitleMiddleware: after_agent()
|
||||
TitleMiddleware->>TitleMiddleware: 检查是否需要生成 title
|
||||
TitleMiddleware->>LLM: 生成 title
|
||||
LLM-->>TitleMiddleware: 返回 title
|
||||
TitleMiddleware->>LangGraph: return {"title": "..."}
|
||||
LangGraph->>Checkpointer: 保存 state (含 title)
|
||||
LangGraph-->>Client: 返回响应
|
||||
Client->>Client: 从 state.values.title 读取
|
||||
```
|
||||
|
||||
## 优势
|
||||
|
||||
✅ **可靠持久化** - 使用 LangGraph 的 state 机制,自动持久化
|
||||
✅ **完全后端处理** - 客户端无需额外逻辑
|
||||
✅ **自动触发** - 首次对话后自动生成
|
||||
✅ **可配置** - 支持自定义长度、模型等
|
||||
✅ **容错性强** - 失败时使用 fallback 策略
|
||||
✅ **架构一致** - 与现有 SandboxMiddleware 保持一致
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **读取方式不同**:Title 在 `state.values.title` 而非 `thread.metadata.title`
|
||||
2. **性能考虑**:title 生成会增加约 0.5-1 秒延迟,可通过使用更快的模型优化
|
||||
3. **并发安全**:middleware 在 agent 执行后运行,不会阻塞主流程
|
||||
4. **Fallback 策略**:如果 LLM 调用失败,会使用用户消息的前几个词作为 title
|
||||
|
||||
## 测试
|
||||
|
||||
```python
|
||||
# 测试 title 生成
|
||||
import pytest
|
||||
from src.agents.title_middleware import TitleMiddleware
|
||||
|
||||
def test_title_generation():
|
||||
# TODO: 添加单元测试
|
||||
pass
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### Title 没有生成
|
||||
|
||||
1. 检查配置是否启用:`get_title_config().enabled == True`
|
||||
2. 检查日志:查找 "Generated thread title" 或错误信息
|
||||
3. 确认是首次对话:只有 1 个用户消息和 1 个助手回复时才会触发
|
||||
|
||||
### Title 生成但客户端看不到
|
||||
|
||||
1. 确认读取位置:应该从 `state.values.title` 读取,而非 `thread.metadata.title`
|
||||
2. 检查 API 响应:确认 state 中包含 title 字段
|
||||
3. 尝试重新获取 state:`client.threads.getState(threadId)`
|
||||
|
||||
### Title 重启后丢失
|
||||
|
||||
1. 检查是否配置了 checkpointer(本地开发需要)
|
||||
2. 确认部署方式:LangGraph Platform 会自动持久化
|
||||
3. 查看数据库:确认 checkpointer 正常工作
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 为什么使用 State 而非 Metadata?
|
||||
|
||||
| 特性 | State | Metadata |
|
||||
|------|-------|----------|
|
||||
| **持久化** | ✅ 自动(通过 checkpointer) | ⚠️ 取决于实现 |
|
||||
| **版本控制** | ✅ 支持时间旅行 | ❌ 不支持 |
|
||||
| **类型安全** | ✅ TypedDict 定义 | ❌ 任意字典 |
|
||||
| **可追溯** | ✅ 每次更新都记录 | ⚠️ 只有最新值 |
|
||||
| **标准化** | ✅ LangGraph 核心机制 | ⚠️ 扩展功能 |
|
||||
|
||||
### 实现细节
|
||||
|
||||
```python
|
||||
# TitleMiddleware 核心逻辑
|
||||
@override
|
||||
def after_agent(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
"""Generate and set thread title after the first agent response."""
|
||||
if self._should_generate_title(state, runtime):
|
||||
title = self._generate_title(runtime)
|
||||
print(f"Generated thread title: {title}")
|
||||
|
||||
# ✅ 返回 state 更新,会被 checkpointer 自动持久化
|
||||
return {"title": title}
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
## 相关文件
|
||||
|
||||
- [`src/agents/thread_state.py`](../src/agents/thread_state.py) - ThreadState 定义
|
||||
- [`src/agents/title_middleware.py`](../src/agents/title_middleware.py) - TitleMiddleware 实现
|
||||
- [`src/config/title_config.py`](../src/config/title_config.py) - 配置管理
|
||||
- [`config.yaml`](../config.yaml) - 配置文件
|
||||
- [`src/agents/lead_agent/agent.py`](../src/agents/lead_agent/agent.py) - Middleware 注册
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [LangGraph Checkpointer 文档](https://langchain-ai.github.io/langgraph/concepts/persistence/)
|
||||
- [LangGraph State 管理](https://langchain-ai.github.io/langgraph/concepts/low_level/#state)
|
||||
- [LangGraph Middleware](https://langchain-ai.github.io/langgraph/concepts/middleware/)
|
||||
221
backend/docs/CONFIGURATION.md
Normal file
221
backend/docs/CONFIGURATION.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Configuration Guide
|
||||
|
||||
This guide explains how to configure DeerFlow for your environment.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Copy the example configuration** (from project root):
|
||||
```bash
|
||||
# From project root directory (deer-flow/)
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
2. **Set your API keys**:
|
||||
|
||||
Option A: Use environment variables (recommended):
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
export ANTHROPIC_API_KEY="your-api-key-here"
|
||||
# Add other keys as needed
|
||||
```
|
||||
|
||||
Option B: Edit `config.yaml` directly (not recommended for production):
|
||||
```yaml
|
||||
models:
|
||||
- name: gpt-4
|
||||
api_key: your-actual-api-key-here # Replace placeholder
|
||||
```
|
||||
|
||||
3. **Start the application**:
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
## Configuration Sections
|
||||
|
||||
### Models
|
||||
|
||||
Configure the LLM models available to the agent:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- name: gpt-4 # Internal identifier
|
||||
display_name: GPT-4 # Human-readable name
|
||||
use: langchain_openai:ChatOpenAI # LangChain class path
|
||||
model: gpt-4 # Model identifier for API
|
||||
api_key: $OPENAI_API_KEY # API key (use env var)
|
||||
max_tokens: 4096 # Max tokens per request
|
||||
temperature: 0.7 # Sampling temperature
|
||||
```
|
||||
|
||||
**Supported Providers**:
|
||||
- OpenAI (`langchain_openai:ChatOpenAI`)
|
||||
- Anthropic (`langchain_anthropic:ChatAnthropic`)
|
||||
- DeepSeek (`langchain_deepseek:ChatDeepSeek`)
|
||||
- Any LangChain-compatible provider
|
||||
|
||||
**Thinking Models**:
|
||||
Some models support "thinking" mode for complex reasoning:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- name: deepseek-v3
|
||||
supports_thinking: true
|
||||
when_thinking_enabled:
|
||||
extra_body:
|
||||
thinking:
|
||||
type: enabled
|
||||
```
|
||||
|
||||
### Tool Groups
|
||||
|
||||
Organize tools into logical groups:
|
||||
|
||||
```yaml
|
||||
tool_groups:
|
||||
- name: web # Web browsing and search
|
||||
- name: file:read # Read-only file operations
|
||||
- name: file:write # Write file operations
|
||||
- name: bash # Shell command execution
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
Configure specific tools available to the agent:
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
- name: web_search
|
||||
group: web
|
||||
use: src.community.tavily.tools:web_search_tool
|
||||
max_results: 5
|
||||
# api_key: $TAVILY_API_KEY # Optional
|
||||
```
|
||||
|
||||
**Built-in Tools**:
|
||||
- `web_search` - Search the web (Tavily)
|
||||
- `web_fetch` - Fetch web pages (Jina AI)
|
||||
- `ls` - List directory contents
|
||||
- `read_file` - Read file contents
|
||||
- `write_file` - Write file contents
|
||||
- `str_replace` - String replacement in files
|
||||
- `bash` - Execute bash commands
|
||||
|
||||
### Sandbox
|
||||
|
||||
Choose between local execution or Docker-based isolation:
|
||||
|
||||
**Option 1: Local Sandbox** (default, simpler setup):
|
||||
```yaml
|
||||
sandbox:
|
||||
use: src.sandbox.local:LocalSandboxProvider
|
||||
```
|
||||
|
||||
**Option 2: Docker Sandbox** (isolated, more secure):
|
||||
```yaml
|
||||
sandbox:
|
||||
use: src.community.aio_sandbox:AioSandboxProvider
|
||||
port: 8080
|
||||
auto_start: true
|
||||
container_prefix: deer-flow-sandbox
|
||||
|
||||
# Optional: Additional mounts
|
||||
mounts:
|
||||
- host_path: /path/on/host
|
||||
container_path: /path/in/container
|
||||
read_only: false
|
||||
```
|
||||
|
||||
### Skills
|
||||
|
||||
Configure the skills directory for specialized workflows:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
# Host path (optional, default: ../skills)
|
||||
path: /custom/path/to/skills
|
||||
|
||||
# Container mount path (default: /mnt/skills)
|
||||
container_path: /mnt/skills
|
||||
```
|
||||
|
||||
**How Skills Work**:
|
||||
- Skills are stored in `deer-flow/skills/{public,custom}/`
|
||||
- Each skill has a `SKILL.md` file with metadata
|
||||
- Skills are automatically discovered and loaded
|
||||
- Available in both local and Docker sandbox via path mapping
|
||||
|
||||
### Title Generation
|
||||
|
||||
Automatic conversation title generation:
|
||||
|
||||
```yaml
|
||||
title:
|
||||
enabled: true
|
||||
max_words: 6
|
||||
max_chars: 60
|
||||
model_name: null # Use first model in list
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
DeerFlow supports environment variable substitution using the `$` prefix:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- api_key: $OPENAI_API_KEY # Reads from environment
|
||||
```
|
||||
|
||||
**Common Environment Variables**:
|
||||
- `OPENAI_API_KEY` - OpenAI API key
|
||||
- `ANTHROPIC_API_KEY` - Anthropic API key
|
||||
- `DEEPSEEK_API_KEY` - DeepSeek API key
|
||||
- `TAVILY_API_KEY` - Tavily search API key
|
||||
- `DEER_FLOW_CONFIG_PATH` - Custom config file path
|
||||
|
||||
## Configuration Location
|
||||
|
||||
The configuration file should be placed in the **project root directory** (`deer-flow/config.yaml`), not in the backend directory.
|
||||
|
||||
## Configuration Priority
|
||||
|
||||
DeerFlow searches for configuration in this order:
|
||||
|
||||
1. Path specified in code via `config_path` argument
|
||||
2. Path from `DEER_FLOW_CONFIG_PATH` environment variable
|
||||
3. `config.yaml` in current working directory (typically `backend/` when running)
|
||||
4. `config.yaml` in parent directory (project root: `deer-flow/`)
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Place `config.yaml` in project root** - Not in `backend/` directory
|
||||
2. **Never commit `config.yaml`** - It's already in `.gitignore`
|
||||
3. **Use environment variables for secrets** - Don't hardcode API keys
|
||||
4. **Keep `config.example.yaml` updated** - Document all new options
|
||||
5. **Test configuration changes locally** - Before deploying
|
||||
6. **Use Docker sandbox for production** - Better isolation and security
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Config file not found"
|
||||
- Ensure `config.yaml` exists in the **project root** directory (`deer-flow/config.yaml`)
|
||||
- The backend searches parent directory by default, so root location is preferred
|
||||
- Alternatively, set `DEER_FLOW_CONFIG_PATH` environment variable to custom location
|
||||
|
||||
### "Invalid API key"
|
||||
- Verify environment variables are set correctly
|
||||
- Check that `$` prefix is used for env var references
|
||||
|
||||
### "Skills not loading"
|
||||
- Check that `deer-flow/skills/` directory exists
|
||||
- Verify skills have valid `SKILL.md` files
|
||||
- Check `skills.path` configuration if using custom path
|
||||
|
||||
### "Docker sandbox fails to start"
|
||||
- Ensure Docker is running
|
||||
- Check port 8080 (or configured port) is available
|
||||
- Verify Docker image is accessible
|
||||
|
||||
## Examples
|
||||
|
||||
See `config.example.yaml` for complete examples of all configuration options.
|
||||
287
backend/docs/FILE_UPLOAD.md
Normal file
287
backend/docs/FILE_UPLOAD.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# 文件上传功能
|
||||
|
||||
## 概述
|
||||
|
||||
DeerFlow 后端提供了完整的文件上传功能,支持多文件上传,并自动将 Office 文档和 PDF 转换为 Markdown 格式。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 支持多文件同时上传
|
||||
- ✅ 自动转换文档为 Markdown(PDF、PPT、Excel、Word)
|
||||
- ✅ 文件存储在线程隔离的目录中
|
||||
- ✅ Agent 自动感知已上传的文件
|
||||
- ✅ 支持文件列表查询和删除
|
||||
|
||||
## API 端点
|
||||
|
||||
### 1. 上传文件
|
||||
```
|
||||
POST /api/threads/{thread_id}/uploads
|
||||
```
|
||||
|
||||
**请求体:** `multipart/form-data`
|
||||
- `files`: 一个或多个文件
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"files": [
|
||||
{
|
||||
"filename": "document.pdf",
|
||||
"size": 1234567,
|
||||
"path": ".deer-flow/threads/{thread_id}/user-data/uploads/document.pdf",
|
||||
"virtual_path": "/mnt/user-data/uploads/document.pdf",
|
||||
"artifact_url": "/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/document.pdf",
|
||||
"markdown_file": "document.md",
|
||||
"markdown_path": ".deer-flow/threads/{thread_id}/user-data/uploads/document.md",
|
||||
"markdown_virtual_path": "/mnt/user-data/uploads/document.md",
|
||||
"markdown_artifact_url": "/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/document.md"
|
||||
}
|
||||
],
|
||||
"message": "Successfully uploaded 1 file(s)"
|
||||
}
|
||||
```
|
||||
|
||||
**路径说明:**
|
||||
- `path`: 实际文件系统路径(相对于 `backend/` 目录)
|
||||
- `virtual_path`: Agent 在沙箱中使用的虚拟路径
|
||||
- `artifact_url`: 前端通过 HTTP 访问文件的 URL
|
||||
|
||||
### 2. 列出已上传文件
|
||||
```
|
||||
GET /api/threads/{thread_id}/uploads/list
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"filename": "document.pdf",
|
||||
"size": 1234567,
|
||||
"path": ".deer-flow/threads/{thread_id}/user-data/uploads/document.pdf",
|
||||
"virtual_path": "/mnt/user-data/uploads/document.pdf",
|
||||
"artifact_url": "/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/document.pdf",
|
||||
"extension": ".pdf",
|
||||
"modified": 1705997600.0
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 删除文件
|
||||
```
|
||||
DELETE /api/threads/{thread_id}/uploads/{filename}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Deleted document.pdf"
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的文档格式
|
||||
|
||||
以下格式会自动转换为 Markdown:
|
||||
- PDF (`.pdf`)
|
||||
- PowerPoint (`.ppt`, `.pptx`)
|
||||
- Excel (`.xls`, `.xlsx`)
|
||||
- Word (`.doc`, `.docx`)
|
||||
|
||||
转换后的 Markdown 文件会保存在同一目录下,文件名为原文件名 + `.md` 扩展名。
|
||||
|
||||
## Agent 集成
|
||||
|
||||
### 自动文件列举
|
||||
|
||||
Agent 在每次请求时会自动收到已上传文件的列表,格式如下:
|
||||
|
||||
```xml
|
||||
<uploaded_files>
|
||||
The following files have been uploaded and are available for use:
|
||||
|
||||
- document.pdf (1.2 MB)
|
||||
Path: /mnt/user-data/uploads/document.pdf
|
||||
|
||||
- document.md (45.3 KB)
|
||||
Path: /mnt/user-data/uploads/document.md
|
||||
|
||||
You can read these files using the `read_file` tool with the paths shown above.
|
||||
</uploaded_files>
|
||||
```
|
||||
|
||||
### 使用上传的文件
|
||||
|
||||
Agent 在沙箱中运行,使用虚拟路径访问文件。Agent 可以直接使用 `read_file` 工具读取上传的文件:
|
||||
|
||||
```python
|
||||
# 读取原始 PDF(如果支持)
|
||||
read_file(path="/mnt/user-data/uploads/document.pdf")
|
||||
|
||||
# 读取转换后的 Markdown(推荐)
|
||||
read_file(path="/mnt/user-data/uploads/document.md")
|
||||
```
|
||||
|
||||
**路径映射关系:**
|
||||
- Agent 使用:`/mnt/user-data/uploads/document.pdf`(虚拟路径)
|
||||
- 实际存储:`backend/.deer-flow/threads/{thread_id}/user-data/uploads/document.pdf`
|
||||
- 前端访问:`/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/document.pdf`(HTTP URL)
|
||||
|
||||
## 测试示例
|
||||
|
||||
### 使用 curl 测试
|
||||
|
||||
```bash
|
||||
# 1. 上传单个文件
|
||||
curl -X POST http://localhost:2026/api/threads/test-thread/uploads \
|
||||
-F "files=@/path/to/document.pdf"
|
||||
|
||||
# 2. 上传多个文件
|
||||
curl -X POST http://localhost:2026/api/threads/test-thread/uploads \
|
||||
-F "files=@/path/to/document.pdf" \
|
||||
-F "files=@/path/to/presentation.pptx" \
|
||||
-F "files=@/path/to/spreadsheet.xlsx"
|
||||
|
||||
# 3. 列出已上传文件
|
||||
curl http://localhost:2026/api/threads/test-thread/uploads/list
|
||||
|
||||
# 4. 删除文件
|
||||
curl -X DELETE http://localhost:2026/api/threads/test-thread/uploads/document.pdf
|
||||
```
|
||||
|
||||
### 使用 Python 测试
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
thread_id = "test-thread"
|
||||
base_url = "http://localhost:2026"
|
||||
|
||||
# 上传文件
|
||||
files = [
|
||||
("files", open("document.pdf", "rb")),
|
||||
("files", open("presentation.pptx", "rb")),
|
||||
]
|
||||
response = requests.post(
|
||||
f"{base_url}/api/threads/{thread_id}/uploads",
|
||||
files=files
|
||||
)
|
||||
print(response.json())
|
||||
|
||||
# 列出文件
|
||||
response = requests.get(f"{base_url}/api/threads/{thread_id}/uploads/list")
|
||||
print(response.json())
|
||||
|
||||
# 删除文件
|
||||
response = requests.delete(
|
||||
f"{base_url}/api/threads/{thread_id}/uploads/document.pdf"
|
||||
)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## 文件存储结构
|
||||
|
||||
```
|
||||
backend/.deer-flow/threads/
|
||||
└── {thread_id}/
|
||||
└── user-data/
|
||||
└── uploads/
|
||||
├── document.pdf # 原始文件
|
||||
├── document.md # 转换后的 Markdown
|
||||
├── presentation.pptx
|
||||
├── presentation.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 限制
|
||||
|
||||
- 最大文件大小:100MB(可在 nginx.conf 中配置 `client_max_body_size`)
|
||||
- 文件名安全性:系统会自动验证文件路径,防止目录遍历攻击
|
||||
- 线程隔离:每个线程的上传文件相互隔离,无法跨线程访问
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 组件
|
||||
|
||||
1. **Upload Router** (`src/gateway/routers/uploads.py`)
|
||||
- 处理文件上传、列表、删除请求
|
||||
- 使用 markitdown 转换文档
|
||||
|
||||
2. **Uploads Middleware** (`src/agents/middlewares/uploads_middleware.py`)
|
||||
- 在每次 Agent 请求前注入文件列表
|
||||
- 自动生成格式化的文件列表消息
|
||||
|
||||
3. **Nginx 配置** (`nginx.conf`)
|
||||
- 路由上传请求到 Gateway API
|
||||
- 配置大文件上传支持
|
||||
|
||||
### 依赖
|
||||
|
||||
- `markitdown>=0.0.1a2` - 文档转换
|
||||
- `python-multipart>=0.0.20` - 文件上传处理
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 文件上传失败
|
||||
|
||||
1. 检查文件大小是否超过限制
|
||||
2. 检查 Gateway API 是否正常运行
|
||||
3. 检查磁盘空间是否充足
|
||||
4. 查看 Gateway 日志:`make gateway`
|
||||
|
||||
### 文档转换失败
|
||||
|
||||
1. 检查 markitdown 是否正确安装:`uv run python -c "import markitdown"`
|
||||
2. 查看日志中的具体错误信息
|
||||
3. 某些损坏或加密的文档可能无法转换,但原文件仍会保存
|
||||
|
||||
### Agent 看不到上传的文件
|
||||
|
||||
1. 确认 UploadsMiddleware 已在 agent.py 中注册
|
||||
2. 检查 thread_id 是否正确
|
||||
3. 确认文件确实已上传到正确的目录
|
||||
|
||||
## 开发建议
|
||||
|
||||
### 前端集成
|
||||
|
||||
```typescript
|
||||
// 上传文件示例
|
||||
async function uploadFiles(threadId: string, files: File[]) {
|
||||
const formData = new FormData();
|
||||
files.forEach(file => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`/api/threads/${threadId}/uploads`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 列出文件
|
||||
async function listFiles(threadId: string) {
|
||||
const response = await fetch(
|
||||
`/api/threads/${threadId}/uploads/list`
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
### 扩展功能建议
|
||||
|
||||
1. **文件预览**:添加预览端点,支持在浏览器中直接查看文件
|
||||
2. **批量删除**:支持一次删除多个文件
|
||||
3. **文件搜索**:支持按文件名或类型搜索
|
||||
4. **版本控制**:保留文件的多个版本
|
||||
5. **压缩包支持**:自动解压 zip 文件
|
||||
6. **图片 OCR**:对上传的图片进行 OCR 识别
|
||||
281
backend/docs/MEMORY_IMPROVEMENTS.md
Normal file
281
backend/docs/MEMORY_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Memory System Improvements
|
||||
|
||||
This document describes recent improvements to the memory system's fact injection mechanism.
|
||||
|
||||
## Overview
|
||||
|
||||
Two major improvements have been made to the `format_memory_for_injection` function:
|
||||
|
||||
1. **Similarity-Based Fact Retrieval**: Uses TF-IDF to select facts most relevant to current conversation context
|
||||
2. **Accurate Token Counting**: Uses tiktoken for precise token estimation instead of rough character-based approximation
|
||||
|
||||
## 1. Similarity-Based Fact Retrieval
|
||||
|
||||
### Problem
|
||||
The original implementation selected facts based solely on confidence scores, taking the top 15 highest-confidence facts regardless of their relevance to the current conversation. This could result in injecting irrelevant facts while omitting contextually important ones.
|
||||
|
||||
### Solution
|
||||
The new implementation uses **TF-IDF (Term Frequency-Inverse Document Frequency)** vectorization with cosine similarity to measure how relevant each fact is to the current conversation context.
|
||||
|
||||
**Scoring Formula**:
|
||||
```
|
||||
final_score = (similarity × 0.6) + (confidence × 0.4)
|
||||
```
|
||||
|
||||
- **Similarity (60% weight)**: Cosine similarity between fact content and current context
|
||||
- **Confidence (40% weight)**: LLM-assigned confidence score (0-1)
|
||||
|
||||
### Benefits
|
||||
- **Context-Aware**: Prioritizes facts relevant to what the user is currently discussing
|
||||
- **Dynamic**: Different facts surface based on conversation topic
|
||||
- **Balanced**: Considers both relevance and reliability
|
||||
- **Fallback**: Gracefully degrades to confidence-only ranking if context is unavailable
|
||||
|
||||
### Example
|
||||
Given facts about Python, React, and Docker:
|
||||
- User asks: *"How should I write Python tests?"*
|
||||
- Prioritizes: Python testing, type hints, pytest
|
||||
- User asks: *"How to optimize my Next.js app?"*
|
||||
- Prioritizes: React/Next.js experience, performance optimization
|
||||
|
||||
### Configuration
|
||||
Customize weights in `config.yaml` (optional):
|
||||
```yaml
|
||||
memory:
|
||||
similarity_weight: 0.6 # Weight for TF-IDF similarity (0-1)
|
||||
confidence_weight: 0.4 # Weight for confidence score (0-1)
|
||||
```
|
||||
|
||||
**Note**: Weights should sum to 1.0 for best results.
|
||||
|
||||
## 2. Accurate Token Counting
|
||||
|
||||
### Problem
|
||||
The original implementation estimated tokens using a simple formula:
|
||||
```python
|
||||
max_chars = max_tokens * 4
|
||||
```
|
||||
|
||||
This assumes ~4 characters per token, which is:
|
||||
- Inaccurate for many languages and content types
|
||||
- Can lead to over-injection (exceeding token limits)
|
||||
- Can lead to under-injection (wasting available budget)
|
||||
|
||||
### Solution
|
||||
The new implementation uses **tiktoken**, OpenAI's official tokenizer library, to count tokens accurately:
|
||||
|
||||
```python
|
||||
import tiktoken
|
||||
|
||||
def _count_tokens(text: str, encoding_name: str = "cl100k_base") -> int:
|
||||
encoding = tiktoken.get_encoding(encoding_name)
|
||||
return len(encoding.encode(text))
|
||||
```
|
||||
|
||||
- Uses `cl100k_base` encoding (GPT-4, GPT-3.5, text-embedding-ada-002)
|
||||
- Provides exact token counts for budget management
|
||||
- Falls back to character-based estimation if tiktoken fails
|
||||
|
||||
### Benefits
|
||||
- **Precision**: Exact token counts match what the model sees
|
||||
- **Budget Optimization**: Maximizes use of available token budget
|
||||
- **No Overflows**: Prevents exceeding `max_injection_tokens` limit
|
||||
- **Better Planning**: Each section's token cost is known precisely
|
||||
|
||||
### Example
|
||||
```python
|
||||
text = "This is a test string to count tokens accurately using tiktoken."
|
||||
|
||||
# Old method
|
||||
char_count = len(text) # 64 characters
|
||||
old_estimate = char_count // 4 # 16 tokens (overestimate)
|
||||
|
||||
# New method
|
||||
accurate_count = _count_tokens(text) # 13 tokens (exact)
|
||||
```
|
||||
|
||||
**Result**: 3-token difference (18.75% error rate)
|
||||
|
||||
In production, errors can be much larger for:
|
||||
- Code snippets (more tokens per character)
|
||||
- Non-English text (variable token ratios)
|
||||
- Technical jargon (often multi-token words)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Function Signature
|
||||
```python
|
||||
def format_memory_for_injection(
|
||||
memory_data: dict[str, Any],
|
||||
max_tokens: int = 2000,
|
||||
current_context: str | None = None,
|
||||
) -> str:
|
||||
```
|
||||
|
||||
**New Parameter**:
|
||||
- `current_context`: Optional string containing recent conversation messages for similarity calculation
|
||||
|
||||
### Backward Compatibility
|
||||
The function remains **100% backward compatible**:
|
||||
- If `current_context` is `None` or empty, falls back to confidence-only ranking
|
||||
- Existing callers without the parameter work exactly as before
|
||||
- Token counting is always accurate (transparent improvement)
|
||||
|
||||
### Integration Point
|
||||
Memory is **dynamically injected** via `MemoryMiddleware.before_model()`:
|
||||
|
||||
```python
|
||||
# src/agents/middlewares/memory_middleware.py
|
||||
|
||||
def _extract_conversation_context(messages: list, max_turns: int = 3) -> str:
|
||||
"""Extract recent conversation (user input + final responses only)."""
|
||||
context_parts = []
|
||||
turn_count = 0
|
||||
|
||||
for msg in reversed(messages):
|
||||
if msg.type == "human":
|
||||
# Always include user messages
|
||||
context_parts.append(extract_text(msg))
|
||||
turn_count += 1
|
||||
if turn_count >= max_turns:
|
||||
break
|
||||
|
||||
elif msg.type == "ai" and not msg.tool_calls:
|
||||
# Only include final AI responses (no tool_calls)
|
||||
context_parts.append(extract_text(msg))
|
||||
|
||||
# Skip tool messages and AI messages with tool_calls
|
||||
|
||||
return " ".join(reversed(context_parts))
|
||||
|
||||
|
||||
class MemoryMiddleware:
|
||||
def before_model(self, state, runtime):
|
||||
"""Inject memory before EACH LLM call (not just before_agent)."""
|
||||
|
||||
# Get recent conversation context (filtered)
|
||||
conversation_context = _extract_conversation_context(
|
||||
state["messages"],
|
||||
max_turns=3
|
||||
)
|
||||
|
||||
# Load memory with context-aware fact selection
|
||||
memory_data = get_memory_data()
|
||||
memory_content = format_memory_for_injection(
|
||||
memory_data,
|
||||
max_tokens=config.max_injection_tokens,
|
||||
current_context=conversation_context, # ✅ Clean conversation only
|
||||
)
|
||||
|
||||
# Inject as system message
|
||||
memory_message = SystemMessage(
|
||||
content=f"<memory>\n{memory_content}\n</memory>",
|
||||
name="memory_context",
|
||||
)
|
||||
|
||||
return {"messages": [memory_message] + state["messages"]}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **User continues conversation**:
|
||||
```
|
||||
Turn 1: "I'm working on a Python project"
|
||||
Turn 2: "It uses FastAPI and SQLAlchemy"
|
||||
Turn 3: "How do I write tests?" ← Current query
|
||||
```
|
||||
|
||||
2. **Extract recent context**: Last 3 turns combined:
|
||||
```
|
||||
"I'm working on a Python project. It uses FastAPI and SQLAlchemy. How do I write tests?"
|
||||
```
|
||||
|
||||
3. **TF-IDF scoring**: Ranks facts by relevance to this context
|
||||
- High score: "Prefers pytest for testing" (testing + Python)
|
||||
- High score: "Likes type hints in Python" (Python related)
|
||||
- High score: "Expert in Python and FastAPI" (Python + FastAPI)
|
||||
- Low score: "Uses Docker for containerization" (less relevant)
|
||||
|
||||
4. **Injection**: Top-ranked facts injected into system prompt's `<memory>` section
|
||||
|
||||
5. **Agent sees**: Full system prompt with relevant memory context
|
||||
|
||||
### Benefits of Dynamic System Prompt
|
||||
|
||||
- **Multi-Turn Context**: Uses last 3 turns, not just current question
|
||||
- Captures ongoing conversation flow
|
||||
- Better understanding of user's current focus
|
||||
- **Query-Specific Facts**: Different facts surface based on conversation topic
|
||||
- **Clean Architecture**: No middleware message manipulation
|
||||
- **LangChain Native**: Uses built-in dynamic system prompt support
|
||||
- **Runtime Flexibility**: Memory regenerated for each agent invocation
|
||||
|
||||
## Dependencies
|
||||
|
||||
New dependencies added to `pyproject.toml`:
|
||||
```toml
|
||||
dependencies = [
|
||||
# ... existing dependencies ...
|
||||
"tiktoken>=0.8.0", # Accurate token counting
|
||||
"scikit-learn>=1.6.1", # TF-IDF vectorization
|
||||
]
|
||||
```
|
||||
|
||||
Install with:
|
||||
```bash
|
||||
cd backend
|
||||
uv sync
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test script to verify improvements:
|
||||
```bash
|
||||
cd backend
|
||||
python test_memory_improvement.py
|
||||
```
|
||||
|
||||
Expected output shows:
|
||||
- Different fact ordering based on context
|
||||
- Accurate token counts vs old estimates
|
||||
- Budget-respecting fact selection
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### Computational Cost
|
||||
- **TF-IDF Calculation**: O(n × m) where n=facts, m=vocabulary
|
||||
- Negligible for typical fact counts (10-100 facts)
|
||||
- Caching opportunities if context doesn't change
|
||||
- **Token Counting**: ~10-100µs per call
|
||||
- Faster than the old character-counting approach
|
||||
- Minimal overhead compared to LLM inference
|
||||
|
||||
### Memory Usage
|
||||
- **TF-IDF Vectorizer**: ~1-5MB for typical vocabulary
|
||||
- Instantiated once per injection call
|
||||
- Garbage collected after use
|
||||
- **Tiktoken Encoding**: ~1MB (cached singleton)
|
||||
- Loaded once per process lifetime
|
||||
|
||||
### Recommendations
|
||||
- Current implementation is optimized for accuracy over caching
|
||||
- For high-throughput scenarios, consider:
|
||||
- Pre-computing fact embeddings (store in memory.json)
|
||||
- Caching TF-IDF vectorizer between calls
|
||||
- Using approximate nearest neighbor search for >1000 facts
|
||||
|
||||
## Summary
|
||||
|
||||
| Aspect | Before | After |
|
||||
|--------|--------|-------|
|
||||
| Fact Selection | Top 15 by confidence only | Relevance-based (similarity + confidence) |
|
||||
| Token Counting | `len(text) // 4` | `tiktoken.encode(text)` |
|
||||
| Context Awareness | None | TF-IDF cosine similarity |
|
||||
| Accuracy | ±25% token estimate | Exact token count |
|
||||
| Configuration | Fixed weights | Customizable similarity/confidence weights |
|
||||
|
||||
These improvements result in:
|
||||
- **More relevant** facts injected into context
|
||||
- **Better utilization** of available token budget
|
||||
- **Fewer hallucinations** due to focused context
|
||||
- **Higher quality** agent responses
|
||||
260
backend/docs/MEMORY_IMPROVEMENTS_SUMMARY.md
Normal file
260
backend/docs/MEMORY_IMPROVEMENTS_SUMMARY.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Memory System Improvements - Summary
|
||||
|
||||
## 改进概述
|
||||
|
||||
针对你提出的两个问题进行了优化:
|
||||
1. ✅ **粗糙的 token 计算**(`字符数 * 4`)→ 使用 tiktoken 精确计算
|
||||
2. ✅ **缺乏相似度召回** → 使用 TF-IDF + 最近对话上下文
|
||||
|
||||
## 核心改进
|
||||
|
||||
### 1. 基于对话上下文的智能 Facts 召回
|
||||
|
||||
**之前**:
|
||||
- 只按 confidence 排序取前 15 个
|
||||
- 无论用户在讨论什么都注入相同的 facts
|
||||
|
||||
**现在**:
|
||||
- 提取最近 **3 轮对话**(human + AI 消息)作为上下文
|
||||
- 使用 **TF-IDF 余弦相似度**计算每个 fact 与对话的相关性
|
||||
- 综合评分:`相似度(60%) + 置信度(40%)`
|
||||
- 动态选择最相关的 facts
|
||||
|
||||
**示例**:
|
||||
```
|
||||
对话历史:
|
||||
Turn 1: "我在做一个 Python 项目"
|
||||
Turn 2: "使用 FastAPI 和 SQLAlchemy"
|
||||
Turn 3: "怎么写测试?"
|
||||
|
||||
上下文: "我在做一个 Python 项目 使用 FastAPI 和 SQLAlchemy 怎么写测试?"
|
||||
|
||||
相关度高的 facts:
|
||||
✓ "Prefers pytest for testing" (Python + 测试)
|
||||
✓ "Expert in Python and FastAPI" (Python + FastAPI)
|
||||
✓ "Likes type hints in Python" (Python)
|
||||
|
||||
相关度低的 facts:
|
||||
✗ "Uses Docker for containerization" (不相关)
|
||||
```
|
||||
|
||||
### 2. 精确的 Token 计算
|
||||
|
||||
**之前**:
|
||||
```python
|
||||
max_chars = max_tokens * 4 # 粗糙估算
|
||||
```
|
||||
|
||||
**现在**:
|
||||
```python
|
||||
import tiktoken
|
||||
|
||||
def _count_tokens(text: str) -> int:
|
||||
encoding = tiktoken.get_encoding("cl100k_base") # GPT-4/3.5
|
||||
return len(encoding.encode(text))
|
||||
```
|
||||
|
||||
**效果对比**:
|
||||
```python
|
||||
text = "This is a test string to count tokens accurately."
|
||||
旧方法: len(text) // 4 = 12 tokens (估算)
|
||||
新方法: tiktoken.encode = 10 tokens (精确)
|
||||
误差: 20%
|
||||
```
|
||||
|
||||
### 3. 多轮对话上下文
|
||||
|
||||
**之前的担心**:
|
||||
> "只传最近一条 human message 会不会上下文不太够?"
|
||||
|
||||
**现在的解决方案**:
|
||||
- 提取最近 **3 轮对话**(可配置)
|
||||
- 包括 human 和 AI 消息
|
||||
- 更完整的对话上下文
|
||||
|
||||
**示例**:
|
||||
```
|
||||
单条消息: "怎么写测试?"
|
||||
→ 缺少上下文,不知道是什么项目
|
||||
|
||||
3轮对话: "Python 项目 + FastAPI + 怎么写测试?"
|
||||
→ 完整上下文,能选择更相关的 facts
|
||||
```
|
||||
|
||||
## 实现方式
|
||||
|
||||
### Middleware 动态注入
|
||||
|
||||
使用 `before_model` 钩子在**每次 LLM 调用前**注入 memory:
|
||||
|
||||
```python
|
||||
# src/agents/middlewares/memory_middleware.py
|
||||
|
||||
def _extract_conversation_context(messages: list, max_turns: int = 3) -> str:
|
||||
"""提取最近 3 轮对话(只包含用户输入和最终回复)"""
|
||||
context_parts = []
|
||||
turn_count = 0
|
||||
|
||||
for msg in reversed(messages):
|
||||
msg_type = getattr(msg, "type", None)
|
||||
|
||||
if msg_type == "human":
|
||||
# ✅ 总是包含用户消息
|
||||
content = extract_text(msg)
|
||||
if content:
|
||||
context_parts.append(content)
|
||||
turn_count += 1
|
||||
if turn_count >= max_turns:
|
||||
break
|
||||
|
||||
elif msg_type == "ai":
|
||||
# ✅ 只包含没有 tool_calls 的 AI 消息(最终回复)
|
||||
tool_calls = getattr(msg, "tool_calls", None)
|
||||
if not tool_calls:
|
||||
content = extract_text(msg)
|
||||
if content:
|
||||
context_parts.append(content)
|
||||
|
||||
# ✅ 跳过 tool messages 和带 tool_calls 的 AI 消息
|
||||
|
||||
return " ".join(reversed(context_parts))
|
||||
|
||||
|
||||
class MemoryMiddleware:
|
||||
def before_model(self, state, runtime):
|
||||
"""在每次 LLM 调用前注入 memory(不是 before_agent)"""
|
||||
|
||||
# 1. 提取最近 3 轮对话(过滤掉 tool calls)
|
||||
messages = state["messages"]
|
||||
conversation_context = _extract_conversation_context(messages, max_turns=3)
|
||||
|
||||
# 2. 使用干净的对话上下文选择相关 facts
|
||||
memory_data = get_memory_data()
|
||||
memory_content = format_memory_for_injection(
|
||||
memory_data,
|
||||
max_tokens=config.max_injection_tokens,
|
||||
current_context=conversation_context, # ✅ 只包含真实对话内容
|
||||
)
|
||||
|
||||
# 3. 作为 system message 注入到消息列表开头
|
||||
memory_message = SystemMessage(
|
||||
content=f"<memory>\n{memory_content}\n</memory>",
|
||||
name="memory_context", # 用于去重检测
|
||||
)
|
||||
|
||||
# 4. 插入到消息列表开头
|
||||
updated_messages = [memory_message] + messages
|
||||
return {"messages": updated_messages}
|
||||
```
|
||||
|
||||
### 为什么这样设计?
|
||||
|
||||
基于你的三个重要观察:
|
||||
|
||||
1. **应该用 `before_model` 而不是 `before_agent`**
|
||||
- ✅ `before_agent`: 只在整个 agent 开始时调用一次
|
||||
- ✅ `before_model`: 在**每次 LLM 调用前**都会调用
|
||||
- ✅ 这样每次 LLM 推理都能看到最新的相关 memory
|
||||
|
||||
2. **messages 数组里只有 human/ai/tool,没有 system**
|
||||
- ✅ 虽然不常见,但 LangChain 允许在对话中插入 system message
|
||||
- ✅ Middleware 可以修改 messages 数组
|
||||
- ✅ 使用 `name="memory_context"` 防止重复注入
|
||||
|
||||
3. **应该剔除 tool call 的 AI messages,只传用户输入和最终输出**
|
||||
- ✅ 过滤掉带 `tool_calls` 的 AI 消息(中间步骤)
|
||||
- ✅ 只保留: - Human 消息(用户输入)
|
||||
- AI 消息但无 tool_calls(最终回复)
|
||||
- ✅ 上下文更干净,TF-IDF 相似度计算更准确
|
||||
|
||||
## 配置选项
|
||||
|
||||
在 `config.yaml` 中可以调整:
|
||||
|
||||
```yaml
|
||||
memory:
|
||||
enabled: true
|
||||
max_injection_tokens: 2000 # ✅ 使用精确 token 计数
|
||||
|
||||
# 高级设置(可选)
|
||||
# max_context_turns: 3 # 对话轮数(默认 3)
|
||||
# similarity_weight: 0.6 # 相似度权重
|
||||
# confidence_weight: 0.4 # 置信度权重
|
||||
```
|
||||
|
||||
## 依赖变更
|
||||
|
||||
新增依赖:
|
||||
```toml
|
||||
dependencies = [
|
||||
"tiktoken>=0.8.0", # 精确 token 计数
|
||||
"scikit-learn>=1.6.1", # TF-IDF 向量化
|
||||
]
|
||||
```
|
||||
|
||||
安装:
|
||||
```bash
|
||||
cd backend
|
||||
uv sync
|
||||
```
|
||||
|
||||
## 性能影响
|
||||
|
||||
- **TF-IDF 计算**:O(n × m),n=facts 数量,m=词汇表大小
|
||||
- 典型场景(10-100 facts):< 10ms
|
||||
- **Token 计数**:~100µs per call
|
||||
- 比字符计数还快
|
||||
- **总开销**:可忽略(相比 LLM 推理)
|
||||
|
||||
## 向后兼容性
|
||||
|
||||
✅ 完全向后兼容:
|
||||
- 如果没有 `current_context`,退化为按 confidence 排序
|
||||
- 所有现有配置继续工作
|
||||
- 不影响其他功能
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
1. **核心功能**
|
||||
- `src/agents/memory/prompt.py` - 添加 TF-IDF 召回和精确 token 计数
|
||||
- `src/agents/lead_agent/prompt.py` - 动态系统提示
|
||||
- `src/agents/lead_agent/agent.py` - 传入函数而非字符串
|
||||
|
||||
2. **依赖**
|
||||
- `pyproject.toml` - 添加 tiktoken 和 scikit-learn
|
||||
|
||||
3. **文档**
|
||||
- `docs/MEMORY_IMPROVEMENTS.md` - 详细技术文档
|
||||
- `docs/MEMORY_IMPROVEMENTS_SUMMARY.md` - 改进总结(本文件)
|
||||
- `CLAUDE.md` - 更新架构说明
|
||||
- `config.example.yaml` - 添加配置说明
|
||||
|
||||
## 测试验证
|
||||
|
||||
运行项目验证:
|
||||
```bash
|
||||
cd backend
|
||||
make dev
|
||||
```
|
||||
|
||||
在对话中测试:
|
||||
1. 讨论不同主题(Python、React、Docker 等)
|
||||
2. 观察不同对话注入的 facts 是否不同
|
||||
3. 检查 token 预算是否被准确控制
|
||||
|
||||
## 总结
|
||||
|
||||
| 问题 | 之前 | 现在 |
|
||||
|------|------|------|
|
||||
| Token 计算 | `len(text) // 4` (±25% 误差) | `tiktoken.encode()` (精确) |
|
||||
| Facts 选择 | 按 confidence 固定排序 | TF-IDF 相似度 + confidence |
|
||||
| 上下文 | 无 | 最近 3 轮对话 |
|
||||
| 实现方式 | 静态系统提示 | 动态系统提示函数 |
|
||||
| 配置灵活性 | 有限 | 可调轮数和权重 |
|
||||
|
||||
所有改进都实现了,并且:
|
||||
- ✅ 不修改 messages 数组
|
||||
- ✅ 使用多轮对话上下文
|
||||
- ✅ 精确 token 计数
|
||||
- ✅ 智能相似度召回
|
||||
- ✅ 完全向后兼容
|
||||
289
backend/docs/PATH_EXAMPLES.md
Normal file
289
backend/docs/PATH_EXAMPLES.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# 文件路径使用示例
|
||||
|
||||
## 三种路径类型
|
||||
|
||||
DeerFlow 的文件上传系统返回三种不同的路径,每种路径用于不同的场景:
|
||||
|
||||
### 1. 实际文件系统路径 (path)
|
||||
|
||||
```
|
||||
.deer-flow/threads/{thread_id}/user-data/uploads/document.pdf
|
||||
```
|
||||
|
||||
**用途:**
|
||||
- 文件在服务器文件系统中的实际位置
|
||||
- 相对于 `backend/` 目录
|
||||
- 用于直接文件系统访问、备份、调试等
|
||||
|
||||
**示例:**
|
||||
```python
|
||||
# Python 代码中直接访问
|
||||
from pathlib import Path
|
||||
file_path = Path("backend/.deer-flow/threads/abc123/user-data/uploads/document.pdf")
|
||||
content = file_path.read_bytes()
|
||||
```
|
||||
|
||||
### 2. 虚拟路径 (virtual_path)
|
||||
|
||||
```
|
||||
/mnt/user-data/uploads/document.pdf
|
||||
```
|
||||
|
||||
**用途:**
|
||||
- Agent 在沙箱环境中使用的路径
|
||||
- 沙箱系统会自动映射到实际路径
|
||||
- Agent 的所有文件操作工具都使用这个路径
|
||||
|
||||
**示例:**
|
||||
Agent 在对话中使用:
|
||||
```python
|
||||
# Agent 使用 read_file 工具
|
||||
read_file(path="/mnt/user-data/uploads/document.pdf")
|
||||
|
||||
# Agent 使用 bash 工具
|
||||
bash(command="cat /mnt/user-data/uploads/document.pdf")
|
||||
```
|
||||
|
||||
### 3. HTTP 访问 URL (artifact_url)
|
||||
|
||||
```
|
||||
/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/document.pdf
|
||||
```
|
||||
|
||||
**用途:**
|
||||
- 前端通过 HTTP 访问文件
|
||||
- 用于下载、预览文件
|
||||
- 可以直接在浏览器中打开
|
||||
|
||||
**示例:**
|
||||
```typescript
|
||||
// 前端 TypeScript/JavaScript 代码
|
||||
const threadId = 'abc123';
|
||||
const filename = 'document.pdf';
|
||||
|
||||
// 下载文件
|
||||
const downloadUrl = `/api/threads/${threadId}/artifacts/mnt/user-data/uploads/${filename}?download=true`;
|
||||
window.open(downloadUrl);
|
||||
|
||||
// 在新窗口预览
|
||||
const viewUrl = `/api/threads/${threadId}/artifacts/mnt/user-data/uploads/${filename}`;
|
||||
window.open(viewUrl, '_blank');
|
||||
|
||||
// 使用 fetch API 获取
|
||||
const response = await fetch(viewUrl);
|
||||
const blob = await response.blob();
|
||||
```
|
||||
|
||||
## 完整使用流程示例
|
||||
|
||||
### 场景:前端上传文件并让 Agent 处理
|
||||
|
||||
```typescript
|
||||
// 1. 前端上传文件
|
||||
async function uploadAndProcess(threadId: string, file: File) {
|
||||
// 上传文件
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
`/api/threads/${threadId}/uploads`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
|
||||
const uploadData = await uploadResponse.json();
|
||||
const fileInfo = uploadData.files[0];
|
||||
|
||||
console.log('文件信息:', fileInfo);
|
||||
// {
|
||||
// filename: "report.pdf",
|
||||
// path: ".deer-flow/threads/abc123/user-data/uploads/report.pdf",
|
||||
// virtual_path: "/mnt/user-data/uploads/report.pdf",
|
||||
// artifact_url: "/api/threads/abc123/artifacts/mnt/user-data/uploads/report.pdf",
|
||||
// markdown_file: "report.md",
|
||||
// markdown_path: ".deer-flow/threads/abc123/user-data/uploads/report.md",
|
||||
// markdown_virtual_path: "/mnt/user-data/uploads/report.md",
|
||||
// markdown_artifact_url: "/api/threads/abc123/artifacts/mnt/user-data/uploads/report.md"
|
||||
// }
|
||||
|
||||
// 2. 发送消息给 Agent
|
||||
await sendMessage(threadId, "请分析刚上传的 PDF 文件");
|
||||
|
||||
// Agent 会自动看到文件列表,包含:
|
||||
// - report.pdf (虚拟路径: /mnt/user-data/uploads/report.pdf)
|
||||
// - report.md (虚拟路径: /mnt/user-data/uploads/report.md)
|
||||
|
||||
// 3. 前端可以直接访问转换后的 Markdown
|
||||
const mdResponse = await fetch(fileInfo.markdown_artifact_url);
|
||||
const markdownContent = await mdResponse.text();
|
||||
console.log('Markdown 内容:', markdownContent);
|
||||
|
||||
// 4. 或者下载原始 PDF
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.href = fileInfo.artifact_url + '?download=true';
|
||||
downloadLink.download = fileInfo.filename;
|
||||
downloadLink.click();
|
||||
}
|
||||
```
|
||||
|
||||
## 路径转换表
|
||||
|
||||
| 场景 | 使用的路径类型 | 示例 |
|
||||
|------|---------------|------|
|
||||
| 服务器后端代码直接访问 | `path` | `.deer-flow/threads/abc123/user-data/uploads/file.pdf` |
|
||||
| Agent 工具调用 | `virtual_path` | `/mnt/user-data/uploads/file.pdf` |
|
||||
| 前端下载/预览 | `artifact_url` | `/api/threads/abc123/artifacts/mnt/user-data/uploads/file.pdf` |
|
||||
| 备份脚本 | `path` | `.deer-flow/threads/abc123/user-data/uploads/file.pdf` |
|
||||
| 日志记录 | `path` | `.deer-flow/threads/abc123/user-data/uploads/file.pdf` |
|
||||
|
||||
## 代码示例集合
|
||||
|
||||
### Python - 后端处理
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from src.agents.middlewares.thread_data_middleware import THREAD_DATA_BASE_DIR
|
||||
|
||||
def process_uploaded_file(thread_id: str, filename: str):
|
||||
# 使用实际路径
|
||||
base_dir = Path.cwd() / THREAD_DATA_BASE_DIR / thread_id / "user-data" / "uploads"
|
||||
file_path = base_dir / filename
|
||||
|
||||
# 直接读取
|
||||
with open(file_path, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
return content
|
||||
```
|
||||
|
||||
### JavaScript - 前端访问
|
||||
|
||||
```javascript
|
||||
// 列出已上传的文件
|
||||
async function listUploadedFiles(threadId) {
|
||||
const response = await fetch(`/api/threads/${threadId}/uploads/list`);
|
||||
const data = await response.json();
|
||||
|
||||
// 为每个文件创建下载链接
|
||||
data.files.forEach(file => {
|
||||
console.log(`文件: ${file.filename}`);
|
||||
console.log(`下载: ${file.artifact_url}?download=true`);
|
||||
console.log(`预览: ${file.artifact_url}`);
|
||||
|
||||
// 如果是文档,还有 Markdown 版本
|
||||
if (file.markdown_artifact_url) {
|
||||
console.log(`Markdown: ${file.markdown_artifact_url}`);
|
||||
}
|
||||
});
|
||||
|
||||
return data.files;
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
async function deleteFile(threadId, filename) {
|
||||
const response = await fetch(
|
||||
`/api/threads/${threadId}/uploads/${filename}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
### React 组件示例
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface UploadedFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
path: string;
|
||||
virtual_path: string;
|
||||
artifact_url: string;
|
||||
extension: string;
|
||||
modified: number;
|
||||
markdown_artifact_url?: string;
|
||||
}
|
||||
|
||||
function FileUploadList({ threadId }: { threadId: string }) {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
}, [threadId]);
|
||||
|
||||
async function fetchFiles() {
|
||||
const response = await fetch(`/api/threads/${threadId}/uploads/list`);
|
||||
const data = await response.json();
|
||||
setFiles(data.files);
|
||||
}
|
||||
|
||||
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const fileList = event.target.files;
|
||||
if (!fileList) return;
|
||||
|
||||
const formData = new FormData();
|
||||
Array.from(fileList).forEach(file => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
await fetch(`/api/threads/${threadId}/uploads`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
fetchFiles(); // 刷新列表
|
||||
}
|
||||
|
||||
async function handleDelete(filename: string) {
|
||||
await fetch(`/api/threads/${threadId}/uploads/${filename}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchFiles(); // 刷新列表
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="file" multiple onChange={handleUpload} />
|
||||
|
||||
<ul>
|
||||
{files.map(file => (
|
||||
<li key={file.filename}>
|
||||
<span>{file.filename}</span>
|
||||
<a href={file.artifact_url} target="_blank">预览</a>
|
||||
<a href={`${file.artifact_url}?download=true`}>下载</a>
|
||||
{file.markdown_artifact_url && (
|
||||
<a href={file.markdown_artifact_url} target="_blank">Markdown</a>
|
||||
)}
|
||||
<button onClick={() => handleDelete(file.filename)}>删除</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **路径安全性**
|
||||
- 实际路径(`path`)包含线程 ID,确保隔离
|
||||
- API 会验证路径,防止目录遍历攻击
|
||||
- 前端不应直接使用 `path`,而应使用 `artifact_url`
|
||||
|
||||
2. **Agent 使用**
|
||||
- Agent 只能看到和使用 `virtual_path`
|
||||
- 沙箱系统自动映射到实际路径
|
||||
- Agent 不需要知道实际的文件系统结构
|
||||
|
||||
3. **前端集成**
|
||||
- 始终使用 `artifact_url` 访问文件
|
||||
- 不要尝试直接访问文件系统路径
|
||||
- 使用 `?download=true` 参数强制下载
|
||||
|
||||
4. **Markdown 转换**
|
||||
- 转换成功时,会返回额外的 `markdown_*` 字段
|
||||
- 建议优先使用 Markdown 版本(更易处理)
|
||||
- 原始文件始终保留
|
||||
53
backend/docs/README.md
Normal file
53
backend/docs/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Documentation
|
||||
|
||||
This directory contains detailed documentation for the DeerFlow backend.
|
||||
|
||||
## Quick Links
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | System architecture overview |
|
||||
| [API.md](API.md) | Complete API reference |
|
||||
| [CONFIGURATION.md](CONFIGURATION.md) | Configuration options |
|
||||
| [SETUP.md](SETUP.md) | Quick setup guide |
|
||||
|
||||
## Feature Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [FILE_UPLOAD.md](FILE_UPLOAD.md) | File upload functionality |
|
||||
| [PATH_EXAMPLES.md](PATH_EXAMPLES.md) | Path types and usage examples |
|
||||
| [summarization.md](summarization.md) | Context summarization feature |
|
||||
| [plan_mode_usage.md](plan_mode_usage.md) | Plan mode with TodoList |
|
||||
| [AUTO_TITLE_GENERATION.md](AUTO_TITLE_GENERATION.md) | Automatic title generation |
|
||||
|
||||
## Development
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [TODO.md](TODO.md) | Planned features and known issues |
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **New to DeerFlow?** Start with [SETUP.md](SETUP.md) for quick installation
|
||||
2. **Configuring the system?** See [CONFIGURATION.md](CONFIGURATION.md)
|
||||
3. **Understanding the architecture?** Read [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
4. **Building integrations?** Check [API.md](API.md) for API reference
|
||||
|
||||
## Document Organization
|
||||
|
||||
```
|
||||
docs/
|
||||
├── README.md # This file
|
||||
├── ARCHITECTURE.md # System architecture
|
||||
├── API.md # API reference
|
||||
├── CONFIGURATION.md # Configuration guide
|
||||
├── SETUP.md # Setup instructions
|
||||
├── FILE_UPLOAD.md # File upload feature
|
||||
├── PATH_EXAMPLES.md # Path usage examples
|
||||
├── summarization.md # Summarization feature
|
||||
├── plan_mode_usage.md # Plan mode feature
|
||||
├── AUTO_TITLE_GENERATION.md # Title generation
|
||||
├── TITLE_GENERATION_IMPLEMENTATION.md # Title implementation details
|
||||
└── TODO.md # Roadmap and issues
|
||||
```
|
||||
92
backend/docs/SETUP.md
Normal file
92
backend/docs/SETUP.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Setup Guide
|
||||
|
||||
Quick setup instructions for DeerFlow.
|
||||
|
||||
## Configuration Setup
|
||||
|
||||
DeerFlow uses a YAML configuration file that should be placed in the **project root directory**.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Navigate to project root**:
|
||||
```bash
|
||||
cd /path/to/deer-flow
|
||||
```
|
||||
|
||||
2. **Copy example configuration**:
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
3. **Edit configuration**:
|
||||
```bash
|
||||
# Option A: Set environment variables (recommended)
|
||||
export OPENAI_API_KEY="your-key-here"
|
||||
|
||||
# Option B: Edit config.yaml directly
|
||||
vim config.yaml # or your preferred editor
|
||||
```
|
||||
|
||||
4. **Verify configuration**:
|
||||
```bash
|
||||
cd backend
|
||||
python -c "from src.config import get_app_config; print('✓ Config loaded:', get_app_config().models[0].name)"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Location**: `config.yaml` should be in `deer-flow/` (project root), not `deer-flow/backend/`
|
||||
- **Git**: `config.yaml` is automatically ignored by git (contains secrets)
|
||||
- **Priority**: If both `backend/config.yaml` and `../config.yaml` exist, backend version takes precedence
|
||||
|
||||
## Configuration File Locations
|
||||
|
||||
The backend searches for `config.yaml` in this order:
|
||||
|
||||
1. `DEER_FLOW_CONFIG_PATH` environment variable (if set)
|
||||
2. `backend/config.yaml` (current directory when running from backend/)
|
||||
3. `deer-flow/config.yaml` (parent directory - **recommended location**)
|
||||
|
||||
**Recommended**: Place `config.yaml` in project root (`deer-flow/config.yaml`).
|
||||
|
||||
## Sandbox Setup (Optional but Recommended)
|
||||
|
||||
If you plan to use Docker/Container-based sandbox (configured in `config.yaml` under `sandbox.use: src.community.aio_sandbox:AioSandboxProvider`), it's highly recommended to pre-pull the container image:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
make setup-sandbox
|
||||
```
|
||||
|
||||
**Why pre-pull?**
|
||||
- The sandbox image (~500MB+) is pulled on first use, causing a long wait
|
||||
- Pre-pulling provides clear progress indication
|
||||
- Avoids confusion when first using the agent
|
||||
|
||||
If you skip this step, the image will be automatically pulled on first agent execution, which may take several minutes depending on your network speed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Config file not found
|
||||
|
||||
```bash
|
||||
# Check where the backend is looking
|
||||
cd deer-flow/backend
|
||||
python -c "from src.config.app_config import AppConfig; print(AppConfig.resolve_config_path())"
|
||||
```
|
||||
|
||||
If it can't find the config:
|
||||
1. Ensure you've copied `config.example.yaml` to `config.yaml`
|
||||
2. Verify you're in the correct directory
|
||||
3. Check the file exists: `ls -la ../config.yaml`
|
||||
|
||||
### Permission denied
|
||||
|
||||
```bash
|
||||
chmod 600 ../config.yaml # Protect sensitive configuration
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Configuration Guide](docs/CONFIGURATION.md) - Detailed configuration options
|
||||
- [Architecture Overview](CLAUDE.md) - System architecture
|
||||
222
backend/docs/TITLE_GENERATION_IMPLEMENTATION.md
Normal file
222
backend/docs/TITLE_GENERATION_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 自动 Title 生成功能实现总结
|
||||
|
||||
## ✅ 已完成的工作
|
||||
|
||||
### 1. 核心实现文件
|
||||
|
||||
#### [`src/agents/thread_state.py`](../src/agents/thread_state.py)
|
||||
- ✅ 添加 `title: str | None = None` 字段到 `ThreadState`
|
||||
|
||||
#### [`src/config/title_config.py`](../src/config/title_config.py) (新建)
|
||||
- ✅ 创建 `TitleConfig` 配置类
|
||||
- ✅ 支持配置:enabled, max_words, max_chars, model_name, prompt_template
|
||||
- ✅ 提供 `get_title_config()` 和 `set_title_config()` 函数
|
||||
- ✅ 提供 `load_title_config_from_dict()` 从配置文件加载
|
||||
|
||||
#### [`src/agents/title_middleware.py`](../src/agents/title_middleware.py) (新建)
|
||||
- ✅ 创建 `TitleMiddleware` 类
|
||||
- ✅ 实现 `_should_generate_title()` 检查是否需要生成
|
||||
- ✅ 实现 `_generate_title()` 调用 LLM 生成标题
|
||||
- ✅ 实现 `after_agent()` 钩子,在首次对话后自动触发
|
||||
- ✅ 包含 fallback 策略(LLM 失败时使用用户消息前几个词)
|
||||
|
||||
#### [`src/config/app_config.py`](../src/config/app_config.py)
|
||||
- ✅ 导入 `load_title_config_from_dict`
|
||||
- ✅ 在 `from_file()` 中加载 title 配置
|
||||
|
||||
#### [`src/agents/lead_agent/agent.py`](../src/agents/lead_agent/agent.py)
|
||||
- ✅ 导入 `TitleMiddleware`
|
||||
- ✅ 注册到 `middleware` 列表:`[SandboxMiddleware(), TitleMiddleware()]`
|
||||
|
||||
### 2. 配置文件
|
||||
|
||||
#### [`config.yaml`](../config.yaml)
|
||||
- ✅ 添加 title 配置段:
|
||||
```yaml
|
||||
title:
|
||||
enabled: true
|
||||
max_words: 6
|
||||
max_chars: 60
|
||||
model_name: null
|
||||
```
|
||||
|
||||
### 3. 文档
|
||||
|
||||
#### [`docs/AUTO_TITLE_GENERATION.md`](../docs/AUTO_TITLE_GENERATION.md) (新建)
|
||||
- ✅ 完整的功能说明文档
|
||||
- ✅ 实现方式和架构设计
|
||||
- ✅ 配置说明
|
||||
- ✅ 客户端使用示例(TypeScript)
|
||||
- ✅ 工作流程图(Mermaid)
|
||||
- ✅ 故障排查指南
|
||||
- ✅ State vs Metadata 对比
|
||||
|
||||
#### [`BACKEND_TODO.md`](../BACKEND_TODO.md)
|
||||
- ✅ 添加功能完成记录
|
||||
|
||||
### 4. 测试
|
||||
|
||||
#### [`tests/test_title_generation.py`](../tests/test_title_generation.py) (新建)
|
||||
- ✅ 配置类测试
|
||||
- ✅ Middleware 初始化测试
|
||||
- ✅ TODO: 集成测试(需要 mock Runtime)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心设计决策
|
||||
|
||||
### 为什么使用 State 而非 Metadata?
|
||||
|
||||
| 方面 | State (✅ 采用) | Metadata (❌ 未采用) |
|
||||
|------|----------------|---------------------|
|
||||
| **持久化** | 自动(通过 checkpointer) | 取决于实现,不可靠 |
|
||||
| **版本控制** | 支持时间旅行 | 不支持 |
|
||||
| **类型安全** | TypedDict 定义 | 任意字典 |
|
||||
| **标准化** | LangGraph 核心机制 | 扩展功能 |
|
||||
|
||||
### 工作流程
|
||||
|
||||
```
|
||||
用户发送首条消息
|
||||
↓
|
||||
Agent 处理并返回回复
|
||||
↓
|
||||
TitleMiddleware.after_agent() 触发
|
||||
↓
|
||||
检查:是否首次对话?是否已有 title?
|
||||
↓
|
||||
调用 LLM 生成 title
|
||||
↓
|
||||
返回 {"title": "..."} 更新 state
|
||||
↓
|
||||
Checkpointer 自动持久化(如果配置了)
|
||||
↓
|
||||
客户端从 state.values.title 读取
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 使用指南
|
||||
|
||||
### 后端配置
|
||||
|
||||
1. **启用/禁用功能**
|
||||
```yaml
|
||||
# config.yaml
|
||||
title:
|
||||
enabled: true # 设为 false 禁用
|
||||
```
|
||||
|
||||
2. **自定义配置**
|
||||
```yaml
|
||||
title:
|
||||
enabled: true
|
||||
max_words: 8 # 标题最多 8 个词
|
||||
max_chars: 80 # 标题最多 80 个字符
|
||||
model_name: null # 使用默认模型
|
||||
```
|
||||
|
||||
3. **配置持久化(可选)**
|
||||
|
||||
如果需要在本地开发时持久化 title:
|
||||
|
||||
```python
|
||||
# checkpointer.py
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
|
||||
checkpointer = SqliteSaver.from_conn_string("checkpoints.db")
|
||||
```
|
||||
|
||||
```json
|
||||
// langgraph.json
|
||||
{
|
||||
"graphs": {
|
||||
"lead_agent": "src.agents:lead_agent"
|
||||
},
|
||||
"checkpointer": "checkpointer:checkpointer"
|
||||
}
|
||||
```
|
||||
|
||||
### 客户端使用
|
||||
|
||||
```typescript
|
||||
// 获取 thread title
|
||||
const state = await client.threads.getState(threadId);
|
||||
const title = state.values.title || "New Conversation";
|
||||
|
||||
// 显示在对话列表
|
||||
<li>{title}</li>
|
||||
```
|
||||
|
||||
**⚠️ 注意**:Title 在 `state.values.title`,而非 `thread.metadata.title`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```bash
|
||||
# 运行测试
|
||||
pytest tests/test_title_generation.py -v
|
||||
|
||||
# 运行所有测试
|
||||
pytest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 故障排查
|
||||
|
||||
### Title 没有生成?
|
||||
|
||||
1. 检查配置:`title.enabled = true`
|
||||
2. 查看日志:搜索 "Generated thread title"
|
||||
3. 确认是首次对话(1 个用户消息 + 1 个助手回复)
|
||||
|
||||
### Title 生成但看不到?
|
||||
|
||||
1. 确认读取位置:`state.values.title`(不是 `thread.metadata.title`)
|
||||
2. 检查 API 响应是否包含 title
|
||||
3. 重新获取 state
|
||||
|
||||
### Title 重启后丢失?
|
||||
|
||||
1. 本地开发需要配置 checkpointer
|
||||
2. LangGraph Platform 会自动持久化
|
||||
3. 检查数据库确认 checkpointer 工作正常
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能影响
|
||||
|
||||
- **延迟增加**:约 0.5-1 秒(LLM 调用)
|
||||
- **并发安全**:在 `after_agent` 中运行,不阻塞主流程
|
||||
- **资源消耗**:每个 thread 只生成一次
|
||||
|
||||
### 优化建议
|
||||
|
||||
1. 使用更快的模型(如 `gpt-3.5-turbo`)
|
||||
2. 减少 `max_words` 和 `max_chars`
|
||||
3. 调整 prompt 使其更简洁
|
||||
|
||||
---
|
||||
|
||||
## 🚀 下一步
|
||||
|
||||
- [ ] 添加集成测试(需要 mock LangGraph Runtime)
|
||||
- [ ] 支持自定义 prompt template
|
||||
- [ ] 支持多语言 title 生成
|
||||
- [ ] 添加 title 重新生成功能
|
||||
- [ ] 监控 title 生成成功率和延迟
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关资源
|
||||
|
||||
- [完整文档](../docs/AUTO_TITLE_GENERATION.md)
|
||||
- [LangGraph Middleware](https://langchain-ai.github.io/langgraph/concepts/middleware/)
|
||||
- [LangGraph State 管理](https://langchain-ai.github.io/langgraph/concepts/low_level/#state)
|
||||
- [LangGraph Checkpointer](https://langchain-ai.github.io/langgraph/concepts/persistence/)
|
||||
|
||||
---
|
||||
|
||||
*实现完成时间: 2026-01-14*
|
||||
27
backend/docs/TODO.md
Normal file
27
backend/docs/TODO.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# TODO List
|
||||
|
||||
## Completed Features
|
||||
|
||||
- [x] Launch the sandbox only after the first file system or bash tool is called
|
||||
- [x] Add Clarification Process for the whole process
|
||||
- [x] Implement Context Summarization Mechanism to avoid context explosion
|
||||
- [x] Integrate MCP (Model Context Protocol) for extensible tools
|
||||
- [x] Add file upload support with automatic document conversion
|
||||
- [x] Implement automatic thread title generation
|
||||
- [x] Add Plan Mode with TodoList middleware
|
||||
- [x] Add vision model support with ViewImageMiddleware
|
||||
- [x] Skills system with SKILL.md format
|
||||
|
||||
## Planned Features
|
||||
|
||||
- [ ] Pooling the sandbox resources to reduce the number of sandbox containers
|
||||
- [ ] Add authentication/authorization layer
|
||||
- [ ] Implement rate limiting
|
||||
- [ ] Add metrics and monitoring
|
||||
- [ ] Support for more document formats in upload
|
||||
- [ ] Skill marketplace / remote skill installation
|
||||
|
||||
## Resolved Issues
|
||||
|
||||
- [x] Make sure that no duplicated files in `state.artifacts`
|
||||
- [x] Long thinking but with empty content (answer inside thinking process)
|
||||
204
backend/docs/plan_mode_usage.md
Normal file
204
backend/docs/plan_mode_usage.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Plan Mode with TodoList Middleware
|
||||
|
||||
This document describes how to enable and use the Plan Mode feature with TodoList middleware in DeerFlow 2.0.
|
||||
|
||||
## Overview
|
||||
|
||||
Plan Mode adds a TodoList middleware to the agent, which provides a `write_todos` tool that helps the agent:
|
||||
- Break down complex tasks into smaller, manageable steps
|
||||
- Track progress as work progresses
|
||||
- Provide visibility to users about what's being done
|
||||
|
||||
The TodoList middleware is built on LangChain's `TodoListMiddleware`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enabling Plan Mode
|
||||
|
||||
Plan mode is controlled via **runtime configuration** through the `is_plan_mode` parameter in the `configurable` section of `RunnableConfig`. This allows you to dynamically enable or disable plan mode on a per-request basis.
|
||||
|
||||
```python
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from src.agents.lead_agent.agent import make_lead_agent
|
||||
|
||||
# Enable plan mode via runtime configuration
|
||||
config = RunnableConfig(
|
||||
configurable={
|
||||
"thread_id": "example-thread",
|
||||
"thinking_enabled": True,
|
||||
"is_plan_mode": True, # Enable plan mode
|
||||
}
|
||||
)
|
||||
|
||||
# Create agent with plan mode enabled
|
||||
agent = make_lead_agent(config)
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- **is_plan_mode** (bool): Whether to enable plan mode with TodoList middleware. Default: `False`
|
||||
- Pass via `config.get("configurable", {}).get("is_plan_mode", False)`
|
||||
- Can be set dynamically for each agent invocation
|
||||
- No global configuration needed
|
||||
|
||||
## Default Behavior
|
||||
|
||||
When plan mode is enabled with default settings, the agent will have access to a `write_todos` tool with the following behavior:
|
||||
|
||||
### When to Use TodoList
|
||||
|
||||
The agent will use the todo list for:
|
||||
1. Complex multi-step tasks (3+ distinct steps)
|
||||
2. Non-trivial tasks requiring careful planning
|
||||
3. When user explicitly requests a todo list
|
||||
4. When user provides multiple tasks
|
||||
|
||||
### When NOT to Use TodoList
|
||||
|
||||
The agent will skip using the todo list for:
|
||||
1. Single, straightforward tasks
|
||||
2. Trivial tasks (< 3 steps)
|
||||
3. Purely conversational or informational requests
|
||||
|
||||
### Task States
|
||||
|
||||
- **pending**: Task not yet started
|
||||
- **in_progress**: Currently working on (can have multiple parallel tasks)
|
||||
- **completed**: Task finished successfully
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from src.agents.lead_agent.agent import make_lead_agent
|
||||
|
||||
# Create agent with plan mode ENABLED
|
||||
config_with_plan_mode = RunnableConfig(
|
||||
configurable={
|
||||
"thread_id": "example-thread",
|
||||
"thinking_enabled": True,
|
||||
"is_plan_mode": True, # TodoList middleware will be added
|
||||
}
|
||||
)
|
||||
agent_with_todos = make_lead_agent(config_with_plan_mode)
|
||||
|
||||
# Create agent with plan mode DISABLED (default)
|
||||
config_without_plan_mode = RunnableConfig(
|
||||
configurable={
|
||||
"thread_id": "another-thread",
|
||||
"thinking_enabled": True,
|
||||
"is_plan_mode": False, # No TodoList middleware
|
||||
}
|
||||
)
|
||||
agent_without_todos = make_lead_agent(config_without_plan_mode)
|
||||
```
|
||||
|
||||
### Dynamic Plan Mode per Request
|
||||
|
||||
You can enable/disable plan mode dynamically for different conversations or tasks:
|
||||
|
||||
```python
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from src.agents.lead_agent.agent import make_lead_agent
|
||||
|
||||
def create_agent_for_task(task_complexity: str):
|
||||
"""Create agent with plan mode based on task complexity."""
|
||||
is_complex = task_complexity in ["high", "very_high"]
|
||||
|
||||
config = RunnableConfig(
|
||||
configurable={
|
||||
"thread_id": f"task-{task_complexity}",
|
||||
"thinking_enabled": True,
|
||||
"is_plan_mode": is_complex, # Enable only for complex tasks
|
||||
}
|
||||
)
|
||||
|
||||
return make_lead_agent(config)
|
||||
|
||||
# Simple task - no TodoList needed
|
||||
simple_agent = create_agent_for_task("low")
|
||||
|
||||
# Complex task - TodoList enabled for better tracking
|
||||
complex_agent = create_agent_for_task("high")
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. When `make_lead_agent(config)` is called, it extracts `is_plan_mode` from `config.configurable`
|
||||
2. The config is passed to `_build_middlewares(config)`
|
||||
3. `_build_middlewares()` reads `is_plan_mode` and calls `_create_todo_list_middleware(is_plan_mode)`
|
||||
4. If `is_plan_mode=True`, a `TodoListMiddleware` instance is created and added to the middleware chain
|
||||
5. The middleware automatically adds a `write_todos` tool to the agent's toolset
|
||||
6. The agent can use this tool to manage tasks during execution
|
||||
7. The middleware handles the todo list state and provides it to the agent
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
make_lead_agent(config)
|
||||
│
|
||||
├─> Extracts: is_plan_mode = config.configurable.get("is_plan_mode", False)
|
||||
│
|
||||
└─> _build_middlewares(config)
|
||||
│
|
||||
├─> ThreadDataMiddleware
|
||||
├─> SandboxMiddleware
|
||||
├─> SummarizationMiddleware (if enabled via global config)
|
||||
├─> TodoListMiddleware (if is_plan_mode=True) ← NEW
|
||||
├─> TitleMiddleware
|
||||
└─> ClarificationMiddleware
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Agent Module
|
||||
- **Location**: `src/agents/lead_agent/agent.py`
|
||||
- **Function**: `_create_todo_list_middleware(is_plan_mode: bool)` - Creates TodoListMiddleware if plan mode is enabled
|
||||
- **Function**: `_build_middlewares(config: RunnableConfig)` - Builds middleware chain based on runtime config
|
||||
- **Function**: `make_lead_agent(config: RunnableConfig)` - Creates agent with appropriate middlewares
|
||||
|
||||
### Runtime Configuration
|
||||
Plan mode is controlled via the `is_plan_mode` parameter in `RunnableConfig.configurable`:
|
||||
```python
|
||||
config = RunnableConfig(
|
||||
configurable={
|
||||
"is_plan_mode": True, # Enable plan mode
|
||||
# ... other configurable options
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Key Benefits
|
||||
|
||||
1. **Dynamic Control**: Enable/disable plan mode per request without global state
|
||||
2. **Flexibility**: Different conversations can have different plan mode settings
|
||||
3. **Simplicity**: No need for global configuration management
|
||||
4. **Context-Aware**: Plan mode decision can be based on task complexity, user preferences, etc.
|
||||
|
||||
## Custom Prompts
|
||||
|
||||
DeerFlow uses custom `system_prompt` and `tool_description` for the TodoListMiddleware that match the overall DeerFlow prompt style:
|
||||
|
||||
### System Prompt Features
|
||||
- Uses XML tags (`<todo_list_system>`) for structure consistency with DeerFlow's main prompt
|
||||
- Emphasizes CRITICAL rules and best practices
|
||||
- Clear "When to Use" vs "When NOT to Use" guidelines
|
||||
- Focuses on real-time updates and immediate task completion
|
||||
|
||||
### Tool Description Features
|
||||
- Detailed usage scenarios with examples
|
||||
- Strong emphasis on NOT using for simple tasks
|
||||
- Clear task state definitions (pending, in_progress, completed)
|
||||
- Comprehensive best practices section
|
||||
- Task completion requirements to prevent premature marking
|
||||
|
||||
The custom prompts are defined in `_create_todo_list_middleware()` in `/Users/hetao/workspace/deer-flow/backend/src/agents/lead_agent/agent.py:57`.
|
||||
|
||||
## Notes
|
||||
|
||||
- TodoList middleware uses LangChain's built-in `TodoListMiddleware` with **custom DeerFlow-style prompts**
|
||||
- Plan mode is **disabled by default** (`is_plan_mode=False`) to maintain backward compatibility
|
||||
- The middleware is positioned before `ClarificationMiddleware` to allow todo management during clarification flows
|
||||
- Custom prompts emphasize the same principles as DeerFlow's main system prompt (clarity, action-oriented, critical rules)
|
||||
353
backend/docs/summarization.md
Normal file
353
backend/docs/summarization.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# Conversation Summarization
|
||||
|
||||
DeerFlow includes automatic conversation summarization to handle long conversations that approach model token limits. When enabled, the system automatically condenses older messages while preserving recent context.
|
||||
|
||||
## Overview
|
||||
|
||||
The summarization feature uses LangChain's `SummarizationMiddleware` to monitor conversation history and trigger summarization based on configurable thresholds. When activated, it:
|
||||
|
||||
1. Monitors message token counts in real-time
|
||||
2. Triggers summarization when thresholds are met
|
||||
3. Keeps recent messages intact while summarizing older exchanges
|
||||
4. Maintains AI/Tool message pairs together for context continuity
|
||||
5. Injects the summary back into the conversation
|
||||
|
||||
## Configuration
|
||||
|
||||
Summarization is configured in `config.yaml` under the `summarization` key:
|
||||
|
||||
```yaml
|
||||
summarization:
|
||||
enabled: true
|
||||
model_name: null # Use default model or specify a lightweight model
|
||||
|
||||
# Trigger conditions (OR logic - any condition triggers summarization)
|
||||
trigger:
|
||||
- type: tokens
|
||||
value: 4000
|
||||
# Additional triggers (optional)
|
||||
# - type: messages
|
||||
# value: 50
|
||||
# - type: fraction
|
||||
# value: 0.8 # 80% of model's max input tokens
|
||||
|
||||
# Context retention policy
|
||||
keep:
|
||||
type: messages
|
||||
value: 20
|
||||
|
||||
# Token trimming for summarization call
|
||||
trim_tokens_to_summarize: 4000
|
||||
|
||||
# Custom summary prompt (optional)
|
||||
summary_prompt: null
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### `enabled`
|
||||
- **Type**: Boolean
|
||||
- **Default**: `false`
|
||||
- **Description**: Enable or disable automatic summarization
|
||||
|
||||
#### `model_name`
|
||||
- **Type**: String or null
|
||||
- **Default**: `null` (uses default model)
|
||||
- **Description**: Model to use for generating summaries. Recommended to use a lightweight, cost-effective model like `gpt-4o-mini` or equivalent.
|
||||
|
||||
#### `trigger`
|
||||
- **Type**: Single `ContextSize` or list of `ContextSize` objects
|
||||
- **Required**: At least one trigger must be specified when enabled
|
||||
- **Description**: Thresholds that trigger summarization. Uses OR logic - summarization runs when ANY threshold is met.
|
||||
|
||||
**ContextSize Types:**
|
||||
|
||||
1. **Token-based trigger**: Activates when token count reaches the specified value
|
||||
```yaml
|
||||
trigger:
|
||||
type: tokens
|
||||
value: 4000
|
||||
```
|
||||
|
||||
2. **Message-based trigger**: Activates when message count reaches the specified value
|
||||
```yaml
|
||||
trigger:
|
||||
type: messages
|
||||
value: 50
|
||||
```
|
||||
|
||||
3. **Fraction-based trigger**: Activates when token usage reaches a percentage of the model's maximum input tokens
|
||||
```yaml
|
||||
trigger:
|
||||
type: fraction
|
||||
value: 0.8 # 80% of max input tokens
|
||||
```
|
||||
|
||||
**Multiple Triggers:**
|
||||
```yaml
|
||||
trigger:
|
||||
- type: tokens
|
||||
value: 4000
|
||||
- type: messages
|
||||
value: 50
|
||||
```
|
||||
|
||||
#### `keep`
|
||||
- **Type**: `ContextSize` object
|
||||
- **Default**: `{type: messages, value: 20}`
|
||||
- **Description**: Specifies how much recent conversation history to preserve after summarization.
|
||||
|
||||
**Examples:**
|
||||
```yaml
|
||||
# Keep most recent 20 messages
|
||||
keep:
|
||||
type: messages
|
||||
value: 20
|
||||
|
||||
# Keep most recent 3000 tokens
|
||||
keep:
|
||||
type: tokens
|
||||
value: 3000
|
||||
|
||||
# Keep most recent 30% of model's max input tokens
|
||||
keep:
|
||||
type: fraction
|
||||
value: 0.3
|
||||
```
|
||||
|
||||
#### `trim_tokens_to_summarize`
|
||||
- **Type**: Integer or null
|
||||
- **Default**: `4000`
|
||||
- **Description**: Maximum tokens to include when preparing messages for the summarization call itself. Set to `null` to skip trimming (not recommended for very long conversations).
|
||||
|
||||
#### `summary_prompt`
|
||||
- **Type**: String or null
|
||||
- **Default**: `null` (uses LangChain's default prompt)
|
||||
- **Description**: Custom prompt template for generating summaries. The prompt should guide the model to extract the most important context.
|
||||
|
||||
**Default Prompt Behavior:**
|
||||
The default LangChain prompt instructs the model to:
|
||||
- Extract highest quality/most relevant context
|
||||
- Focus on information critical to the overall goal
|
||||
- Avoid repeating completed actions
|
||||
- Return only the extracted context
|
||||
|
||||
## How It Works
|
||||
|
||||
### Summarization Flow
|
||||
|
||||
1. **Monitoring**: Before each model call, the middleware counts tokens in the message history
|
||||
2. **Trigger Check**: If any configured threshold is met, summarization is triggered
|
||||
3. **Message Partitioning**: Messages are split into:
|
||||
- Messages to summarize (older messages beyond the `keep` threshold)
|
||||
- Messages to preserve (recent messages within the `keep` threshold)
|
||||
4. **Summary Generation**: The model generates a concise summary of the older messages
|
||||
5. **Context Replacement**: The message history is updated:
|
||||
- All old messages are removed
|
||||
- A single summary message is added
|
||||
- Recent messages are preserved
|
||||
6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together
|
||||
|
||||
### Token Counting
|
||||
|
||||
- Uses approximate token counting based on character count
|
||||
- For Anthropic models: ~3.3 characters per token
|
||||
- For other models: Uses LangChain's default estimation
|
||||
- Can be customized with a custom `token_counter` function
|
||||
|
||||
### Message Preservation
|
||||
|
||||
The middleware intelligently preserves message context:
|
||||
|
||||
- **Recent Messages**: Always kept intact based on `keep` configuration
|
||||
- **AI/Tool Pairs**: Never split - if a cutoff point falls within tool messages, the system adjusts to keep the entire AI + Tool message sequence together
|
||||
- **Summary Format**: Summary is injected as a HumanMessage with the format:
|
||||
```
|
||||
Here is a summary of the conversation to date:
|
||||
|
||||
[Generated summary text]
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Choosing Trigger Thresholds
|
||||
|
||||
1. **Token-based triggers**: Recommended for most use cases
|
||||
- Set to 60-80% of your model's context window
|
||||
- Example: For 8K context, use 4000-6000 tokens
|
||||
|
||||
2. **Message-based triggers**: Useful for controlling conversation length
|
||||
- Good for applications with many short messages
|
||||
- Example: 50-100 messages depending on average message length
|
||||
|
||||
3. **Fraction-based triggers**: Ideal when using multiple models
|
||||
- Automatically adapts to each model's capacity
|
||||
- Example: 0.8 (80% of model's max input tokens)
|
||||
|
||||
### Choosing Retention Policy (`keep`)
|
||||
|
||||
1. **Message-based retention**: Best for most scenarios
|
||||
- Preserves natural conversation flow
|
||||
- Recommended: 15-25 messages
|
||||
|
||||
2. **Token-based retention**: Use when precise control is needed
|
||||
- Good for managing exact token budgets
|
||||
- Recommended: 2000-4000 tokens
|
||||
|
||||
3. **Fraction-based retention**: For multi-model setups
|
||||
- Automatically scales with model capacity
|
||||
- Recommended: 0.2-0.4 (20-40% of max input)
|
||||
|
||||
### Model Selection
|
||||
|
||||
- **Recommended**: Use a lightweight, cost-effective model for summaries
|
||||
- Examples: `gpt-4o-mini`, `claude-haiku`, or equivalent
|
||||
- Summaries don't require the most powerful models
|
||||
- Significant cost savings on high-volume applications
|
||||
|
||||
- **Default**: If `model_name` is `null`, uses the default model
|
||||
- May be more expensive but ensures consistency
|
||||
- Good for simple setups
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. **Balance triggers**: Combine token and message triggers for robust handling
|
||||
```yaml
|
||||
trigger:
|
||||
- type: tokens
|
||||
value: 4000
|
||||
- type: messages
|
||||
value: 50
|
||||
```
|
||||
|
||||
2. **Conservative retention**: Keep more messages initially, adjust based on performance
|
||||
```yaml
|
||||
keep:
|
||||
type: messages
|
||||
value: 25 # Start higher, reduce if needed
|
||||
```
|
||||
|
||||
3. **Trim strategically**: Limit tokens sent to summarization model
|
||||
```yaml
|
||||
trim_tokens_to_summarize: 4000 # Prevents expensive summarization calls
|
||||
```
|
||||
|
||||
4. **Monitor and iterate**: Track summary quality and adjust configuration
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Summary Quality Issues
|
||||
|
||||
**Problem**: Summaries losing important context
|
||||
|
||||
**Solutions**:
|
||||
1. Increase `keep` value to preserve more messages
|
||||
2. Decrease trigger thresholds to summarize earlier
|
||||
3. Customize `summary_prompt` to emphasize key information
|
||||
4. Use a more capable model for summarization
|
||||
|
||||
### Performance Issues
|
||||
|
||||
**Problem**: Summarization calls taking too long
|
||||
|
||||
**Solutions**:
|
||||
1. Use a faster model for summaries (e.g., `gpt-4o-mini`)
|
||||
2. Reduce `trim_tokens_to_summarize` to send less context
|
||||
3. Increase trigger thresholds to summarize less frequently
|
||||
|
||||
### Token Limit Errors
|
||||
|
||||
**Problem**: Still hitting token limits despite summarization
|
||||
|
||||
**Solutions**:
|
||||
1. Lower trigger thresholds to summarize earlier
|
||||
2. Reduce `keep` value to preserve fewer messages
|
||||
3. Check if individual messages are very large
|
||||
4. Consider using fraction-based triggers
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Code Structure
|
||||
|
||||
- **Configuration**: `src/config/summarization_config.py`
|
||||
- **Integration**: `src/agents/lead_agent/agent.py`
|
||||
- **Middleware**: Uses `langchain.agents.middleware.SummarizationMiddleware`
|
||||
|
||||
### Middleware Order
|
||||
|
||||
Summarization runs after ThreadData and Sandbox initialization but before Title and Clarification:
|
||||
|
||||
1. ThreadDataMiddleware
|
||||
2. SandboxMiddleware
|
||||
3. **SummarizationMiddleware** ← Runs here
|
||||
4. TitleMiddleware
|
||||
5. ClarificationMiddleware
|
||||
|
||||
### State Management
|
||||
|
||||
- Summarization is stateless - configuration is loaded once at startup
|
||||
- Summaries are added as regular messages in the conversation history
|
||||
- The checkpointer persists the summarized history automatically
|
||||
|
||||
## Example Configurations
|
||||
|
||||
### Minimal Configuration
|
||||
```yaml
|
||||
summarization:
|
||||
enabled: true
|
||||
trigger:
|
||||
type: tokens
|
||||
value: 4000
|
||||
keep:
|
||||
type: messages
|
||||
value: 20
|
||||
```
|
||||
|
||||
### Production Configuration
|
||||
```yaml
|
||||
summarization:
|
||||
enabled: true
|
||||
model_name: gpt-4o-mini # Lightweight model for cost efficiency
|
||||
trigger:
|
||||
- type: tokens
|
||||
value: 6000
|
||||
- type: messages
|
||||
value: 75
|
||||
keep:
|
||||
type: messages
|
||||
value: 25
|
||||
trim_tokens_to_summarize: 5000
|
||||
```
|
||||
|
||||
### Multi-Model Configuration
|
||||
```yaml
|
||||
summarization:
|
||||
enabled: true
|
||||
model_name: gpt-4o-mini
|
||||
trigger:
|
||||
type: fraction
|
||||
value: 0.7 # 70% of model's max input
|
||||
keep:
|
||||
type: fraction
|
||||
value: 0.3 # Keep 30% of max input
|
||||
trim_tokens_to_summarize: 4000
|
||||
```
|
||||
|
||||
### Conservative Configuration (High Quality)
|
||||
```yaml
|
||||
summarization:
|
||||
enabled: true
|
||||
model_name: gpt-4 # Use full model for high-quality summaries
|
||||
trigger:
|
||||
type: tokens
|
||||
value: 8000
|
||||
keep:
|
||||
type: messages
|
||||
value: 40 # Keep more context
|
||||
trim_tokens_to_summarize: null # No trimming
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [LangChain Summarization Middleware Documentation](https://docs.langchain.com/oss/python/langchain/middleware/built-in#summarization)
|
||||
- [LangChain Source Code](https://github.com/langchain-ai/langchain)
|
||||
174
backend/docs/task_tool_improvements.md
Normal file
174
backend/docs/task_tool_improvements.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Task Tool Improvements
|
||||
|
||||
## Overview
|
||||
|
||||
The task tool has been improved to eliminate wasteful LLM polling. Previously, when using background tasks, the LLM had to repeatedly call `task_status` to poll for completion, causing unnecessary API requests.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Removed `run_in_background` Parameter
|
||||
|
||||
The `run_in_background` parameter has been removed from the `task` tool. All subagent tasks now run asynchronously by default, but the tool handles completion automatically.
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
# LLM had to manage polling
|
||||
task_id = task(
|
||||
subagent_type="bash",
|
||||
prompt="Run tests",
|
||||
description="Run tests",
|
||||
run_in_background=True
|
||||
)
|
||||
# Then LLM had to poll repeatedly:
|
||||
while True:
|
||||
status = task_status(task_id)
|
||||
if completed:
|
||||
break
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
# Tool blocks until complete, polling happens in backend
|
||||
result = task(
|
||||
subagent_type="bash",
|
||||
prompt="Run tests",
|
||||
description="Run tests"
|
||||
)
|
||||
# Result is available immediately after the call returns
|
||||
```
|
||||
|
||||
### 2. Backend Polling
|
||||
|
||||
The `task_tool` now:
|
||||
- Starts the subagent task asynchronously
|
||||
- Polls for completion in the backend (every 2 seconds)
|
||||
- Blocks the tool call until completion
|
||||
- Returns the final result directly
|
||||
|
||||
This means:
|
||||
- ✅ LLM makes only ONE tool call
|
||||
- ✅ No wasteful LLM polling requests
|
||||
- ✅ Backend handles all status checking
|
||||
- ✅ Timeout protection (5 minutes max)
|
||||
|
||||
### 3. Removed `task_status` from LLM Tools
|
||||
|
||||
The `task_status_tool` is no longer exposed to the LLM. It's kept in the codebase for potential internal/debugging use, but the LLM cannot call it.
|
||||
|
||||
### 4. Updated Documentation
|
||||
|
||||
- Updated `SUBAGENT_SECTION` in `prompt.py` to remove all references to background tasks and polling
|
||||
- Simplified usage examples
|
||||
- Made it clear that the tool automatically waits for completion
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Polling Logic
|
||||
|
||||
Located in `src/tools/builtins/task_tool.py`:
|
||||
|
||||
```python
|
||||
# Start background execution
|
||||
task_id = executor.execute_async(prompt)
|
||||
|
||||
# Poll for task completion in backend
|
||||
while True:
|
||||
result = get_background_task_result(task_id)
|
||||
|
||||
# Check if task completed or failed
|
||||
if result.status == SubagentStatus.COMPLETED:
|
||||
return f"[Subagent: {subagent_type}]\n\n{result.result}"
|
||||
elif result.status == SubagentStatus.FAILED:
|
||||
return f"[Subagent: {subagent_type}] Task failed: {result.error}"
|
||||
|
||||
# Wait before next poll
|
||||
time.sleep(2)
|
||||
|
||||
# Timeout protection (5 minutes)
|
||||
if poll_count > 150:
|
||||
return "Task timed out after 5 minutes"
|
||||
```
|
||||
|
||||
### Execution Timeout
|
||||
|
||||
In addition to polling timeout, subagent execution now has a built-in timeout mechanism:
|
||||
|
||||
**Configuration** (`src/subagents/config.py`):
|
||||
```python
|
||||
@dataclass
|
||||
class SubagentConfig:
|
||||
# ...
|
||||
timeout_seconds: int = 300 # 5 minutes default
|
||||
```
|
||||
|
||||
**Thread Pool Architecture**:
|
||||
|
||||
To avoid nested thread pools and resource waste, we use two dedicated thread pools:
|
||||
|
||||
1. **Scheduler Pool** (`_scheduler_pool`):
|
||||
- Max workers: 4
|
||||
- Purpose: Orchestrates background task execution
|
||||
- Runs `run_task()` function that manages task lifecycle
|
||||
|
||||
2. **Execution Pool** (`_execution_pool`):
|
||||
- Max workers: 8 (larger to avoid blocking)
|
||||
- Purpose: Actual subagent execution with timeout support
|
||||
- Runs `execute()` method that invokes the agent
|
||||
|
||||
**How it works**:
|
||||
```python
|
||||
# In execute_async():
|
||||
_scheduler_pool.submit(run_task) # Submit orchestration task
|
||||
|
||||
# In run_task():
|
||||
future = _execution_pool.submit(self.execute, task) # Submit execution
|
||||
exec_result = future.result(timeout=timeout_seconds) # Wait with timeout
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Clean separation of concerns (scheduling vs execution)
|
||||
- ✅ No nested thread pools
|
||||
- ✅ Timeout enforcement at the right level
|
||||
- ✅ Better resource utilization
|
||||
|
||||
**Two-Level Timeout Protection**:
|
||||
1. **Execution Timeout**: Subagent execution itself has a 5-minute timeout (configurable in SubagentConfig)
|
||||
2. **Polling Timeout**: Tool polling has a 5-minute timeout (30 polls × 10 seconds)
|
||||
|
||||
This ensures that even if subagent execution hangs, the system won't wait indefinitely.
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Reduced API Costs**: No more repeated LLM requests for polling
|
||||
2. **Simpler UX**: LLM doesn't need to manage polling logic
|
||||
3. **Better Reliability**: Backend handles all status checking consistently
|
||||
4. **Timeout Protection**: Two-level timeout prevents infinite waiting (execution + polling)
|
||||
|
||||
## Testing
|
||||
|
||||
To verify the changes work correctly:
|
||||
|
||||
1. Start a subagent task that takes a few seconds
|
||||
2. Verify the tool call blocks until completion
|
||||
3. Verify the result is returned directly
|
||||
4. Verify no `task_status` calls are made
|
||||
|
||||
Example test scenario:
|
||||
```python
|
||||
# This should block for ~10 seconds then return result
|
||||
result = task(
|
||||
subagent_type="bash",
|
||||
prompt="sleep 10 && echo 'Done'",
|
||||
description="Test task"
|
||||
)
|
||||
# result should contain "Done"
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
|
||||
For users/code that previously used `run_in_background=True`:
|
||||
- Simply remove the parameter
|
||||
- Remove any polling logic
|
||||
- The tool will automatically wait for completion
|
||||
|
||||
No other changes needed - the API is backward compatible (minus the removed parameter).
|
||||
10
backend/langgraph.json
Normal file
10
backend/langgraph.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://langgra.ph/schema.json",
|
||||
"dependencies": [
|
||||
"."
|
||||
],
|
||||
"env": ".env",
|
||||
"graphs": {
|
||||
"lead_agent": "src.agents:make_lead_agent"
|
||||
}
|
||||
}
|
||||
35
backend/pyproject.toml
Normal file
35
backend/pyproject.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
[project]
|
||||
name = "deer-flow"
|
||||
version = "0.1.0"
|
||||
description = "LangGraph-based AI agent system with sandbox execution capabilities"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"agent-sandbox>=0.0.19",
|
||||
"dotenv>=0.9.9",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.28.0",
|
||||
"kubernetes>=30.0.0",
|
||||
"langchain>=1.2.3",
|
||||
"langchain-deepseek>=1.0.1",
|
||||
"langchain-mcp-adapters>=0.1.0",
|
||||
"langchain-openai>=1.1.7",
|
||||
"langgraph>=1.0.6",
|
||||
"langgraph-cli[inmem]>=0.4.11",
|
||||
"markdownify>=1.2.2",
|
||||
"markitdown[all,xlsx]>=0.0.1a2",
|
||||
"pydantic>=2.12.5",
|
||||
"python-multipart>=0.0.20",
|
||||
"pyyaml>=6.0.3",
|
||||
"readabilipy>=0.3.0",
|
||||
"sse-starlette>=2.1.0",
|
||||
"tavily-python>=0.7.17",
|
||||
"firecrawl-py>=1.15.0",
|
||||
"tiktoken>=0.8.0",
|
||||
"uvicorn[standard]>=0.34.0",
|
||||
"ddgs>=9.10.0",
|
||||
"duckdb>=1.4.4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8.0.0", "ruff>=0.14.11"]
|
||||
10
backend/ruff.toml
Normal file
10
backend/ruff.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
line-length = 240
|
||||
target-version = "py312"
|
||||
|
||||
[lint]
|
||||
select = ["E", "F", "I", "UP"]
|
||||
ignore = []
|
||||
|
||||
[format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
0
backend/src/__init__.py
Normal file
0
backend/src/__init__.py
Normal file
4
backend/src/agents/__init__.py
Normal file
4
backend/src/agents/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .lead_agent import make_lead_agent
|
||||
from .thread_state import SandboxState, ThreadState
|
||||
|
||||
__all__ = ["make_lead_agent", "SandboxState", "ThreadState"]
|
||||
3
backend/src/agents/lead_agent/__init__.py
Normal file
3
backend/src/agents/lead_agent/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .agent import make_lead_agent
|
||||
|
||||
__all__ = ["make_lead_agent"]
|
||||
254
backend/src/agents/lead_agent/agent.py
Normal file
254
backend/src/agents/lead_agent/agent.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import SummarizationMiddleware, TodoListMiddleware
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from src.agents.lead_agent.prompt import apply_prompt_template
|
||||
from src.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||
from src.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||
from src.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||
from src.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||
from src.agents.middlewares.thread_data_middleware import ThreadDataMiddleware
|
||||
from src.agents.middlewares.title_middleware import TitleMiddleware
|
||||
from src.agents.middlewares.uploads_middleware import UploadsMiddleware
|
||||
from src.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||||
from src.agents.thread_state import ThreadState
|
||||
from src.config.summarization_config import get_summarization_config
|
||||
from src.models import create_chat_model
|
||||
from src.sandbox.middleware import SandboxMiddleware
|
||||
|
||||
|
||||
def _create_summarization_middleware() -> SummarizationMiddleware | None:
|
||||
"""Create and configure the summarization middleware from config."""
|
||||
config = get_summarization_config()
|
||||
|
||||
if not config.enabled:
|
||||
return None
|
||||
|
||||
# Prepare trigger parameter
|
||||
trigger = None
|
||||
if config.trigger is not None:
|
||||
if isinstance(config.trigger, list):
|
||||
trigger = [t.to_tuple() for t in config.trigger]
|
||||
else:
|
||||
trigger = config.trigger.to_tuple()
|
||||
|
||||
# Prepare keep parameter
|
||||
keep = config.keep.to_tuple()
|
||||
|
||||
# Prepare model parameter
|
||||
if config.model_name:
|
||||
model = config.model_name
|
||||
else:
|
||||
# Use a lightweight model for summarization to save costs
|
||||
# Falls back to default model if not explicitly specified
|
||||
model = create_chat_model(thinking_enabled=False)
|
||||
|
||||
# Prepare kwargs
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"trigger": trigger,
|
||||
"keep": keep,
|
||||
}
|
||||
|
||||
if config.trim_tokens_to_summarize is not None:
|
||||
kwargs["trim_tokens_to_summarize"] = config.trim_tokens_to_summarize
|
||||
|
||||
if config.summary_prompt is not None:
|
||||
kwargs["summary_prompt"] = config.summary_prompt
|
||||
|
||||
return SummarizationMiddleware(**kwargs)
|
||||
|
||||
|
||||
def _create_todo_list_middleware(is_plan_mode: bool) -> TodoListMiddleware | None:
|
||||
"""Create and configure the TodoList middleware.
|
||||
|
||||
Args:
|
||||
is_plan_mode: Whether to enable plan mode with TodoList middleware.
|
||||
|
||||
Returns:
|
||||
TodoListMiddleware instance if plan mode is enabled, None otherwise.
|
||||
"""
|
||||
if not is_plan_mode:
|
||||
return None
|
||||
|
||||
# Custom prompts matching DeerFlow's style
|
||||
system_prompt = """
|
||||
<todo_list_system>
|
||||
You have access to the `write_todos` tool to help you manage and track complex multi-step objectives.
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Mark todos as completed IMMEDIATELY after finishing each step - do NOT batch completions
|
||||
- Keep EXACTLY ONE task as `in_progress` at any time (unless tasks can run in parallel)
|
||||
- Update the todo list in REAL-TIME as you work - this gives users visibility into your progress
|
||||
- DO NOT use this tool for simple tasks (< 3 steps) - just complete them directly
|
||||
|
||||
**When to Use:**
|
||||
This tool is designed for complex objectives that require systematic tracking:
|
||||
- Complex multi-step tasks requiring 3+ distinct steps
|
||||
- Non-trivial tasks needing careful planning and execution
|
||||
- User explicitly requests a todo list
|
||||
- User provides multiple tasks (numbered or comma-separated list)
|
||||
- The plan may need revisions based on intermediate results
|
||||
|
||||
**When NOT to Use:**
|
||||
- Single, straightforward tasks
|
||||
- Trivial tasks (< 3 steps)
|
||||
- Purely conversational or informational requests
|
||||
- Simple tool calls where the approach is obvious
|
||||
|
||||
**Best Practices:**
|
||||
- Break down complex tasks into smaller, actionable steps
|
||||
- Use clear, descriptive task names
|
||||
- Remove tasks that become irrelevant
|
||||
- Add new tasks discovered during implementation
|
||||
- Don't be afraid to revise the todo list as you learn more
|
||||
|
||||
**Task Management:**
|
||||
Writing todos takes time and tokens - use it when helpful for managing complex problems, not for simple requests.
|
||||
</todo_list_system>
|
||||
"""
|
||||
|
||||
tool_description = """Use this tool to create and manage a structured task list for complex work sessions.
|
||||
|
||||
**IMPORTANT: Only use this tool for complex tasks (3+ steps). For simple requests, just do the work directly.**
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this tool in these scenarios:
|
||||
1. **Complex multi-step tasks**: When a task requires 3 or more distinct steps or actions
|
||||
2. **Non-trivial tasks**: Tasks requiring careful planning or multiple operations
|
||||
3. **User explicitly requests todo list**: When the user directly asks you to track tasks
|
||||
4. **Multiple tasks**: When users provide a list of things to be done
|
||||
5. **Dynamic planning**: When the plan may need updates based on intermediate results
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
Skip this tool when:
|
||||
1. The task is straightforward and takes less than 3 steps
|
||||
2. The task is trivial and tracking provides no benefit
|
||||
3. The task is purely conversational or informational
|
||||
4. It's clear what needs to be done and you can just do it
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Starting a task**: Mark it as `in_progress` BEFORE beginning work
|
||||
2. **Completing a task**: Mark it as `completed` IMMEDIATELY after finishing
|
||||
3. **Updating the list**: Add new tasks, remove irrelevant ones, or update descriptions as needed
|
||||
4. **Multiple updates**: You can make several updates at once (e.g., complete one task and start the next)
|
||||
|
||||
## Task States
|
||||
|
||||
- `pending`: Task not yet started
|
||||
- `in_progress`: Currently working on (can have multiple if tasks run in parallel)
|
||||
- `completed`: Task finished successfully
|
||||
|
||||
## Task Completion Requirements
|
||||
|
||||
**CRITICAL: Only mark a task as completed when you have FULLY accomplished it.**
|
||||
|
||||
Never mark a task as completed if:
|
||||
- There are unresolved issues or errors
|
||||
- Work is partial or incomplete
|
||||
- You encountered blockers preventing completion
|
||||
- You couldn't find necessary resources or dependencies
|
||||
- Quality standards haven't been met
|
||||
|
||||
If blocked, keep the task as `in_progress` and create a new task describing what needs to be resolved.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Create specific, actionable items
|
||||
- Break complex tasks into smaller, manageable steps
|
||||
- Use clear, descriptive task names
|
||||
- Update task status in real-time as you work
|
||||
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
|
||||
- Remove tasks that are no longer relevant
|
||||
- **IMPORTANT**: When you write the todo list, mark your first task(s) as `in_progress` immediately
|
||||
- **IMPORTANT**: Unless all tasks are completed, always have at least one task `in_progress` to show progress
|
||||
|
||||
Being proactive with task management demonstrates thoroughness and ensures all requirements are completed successfully.
|
||||
|
||||
**Remember**: If you only need a few tool calls to complete a task and it's clear what to do, it's better to just do the task directly and NOT use this tool at all.
|
||||
"""
|
||||
|
||||
return TodoListMiddleware(system_prompt=system_prompt, tool_description=tool_description)
|
||||
|
||||
|
||||
# ThreadDataMiddleware must be before SandboxMiddleware to ensure thread_id is available
|
||||
# UploadsMiddleware should be after ThreadDataMiddleware to access thread_id
|
||||
# DanglingToolCallMiddleware patches missing ToolMessages before model sees the history
|
||||
# SummarizationMiddleware should be early to reduce context before other processing
|
||||
# TodoListMiddleware should be before ClarificationMiddleware to allow todo management
|
||||
# TitleMiddleware generates title after first exchange
|
||||
# MemoryMiddleware queues conversation for memory update (after TitleMiddleware)
|
||||
# ViewImageMiddleware should be before ClarificationMiddleware to inject image details before LLM
|
||||
# ClarificationMiddleware should be last to intercept clarification requests after model calls
|
||||
def _build_middlewares(config: RunnableConfig):
|
||||
"""Build middleware chain based on runtime configuration.
|
||||
|
||||
Args:
|
||||
config: Runtime configuration containing configurable options like is_plan_mode.
|
||||
|
||||
Returns:
|
||||
List of middleware instances.
|
||||
"""
|
||||
middlewares = [ThreadDataMiddleware(), UploadsMiddleware(), SandboxMiddleware(), DanglingToolCallMiddleware()]
|
||||
|
||||
# Add summarization middleware if enabled
|
||||
summarization_middleware = _create_summarization_middleware()
|
||||
if summarization_middleware is not None:
|
||||
middlewares.append(summarization_middleware)
|
||||
|
||||
# Add TodoList middleware if plan mode is enabled
|
||||
is_plan_mode = config.get("configurable", {}).get("is_plan_mode", False)
|
||||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
||||
if todo_list_middleware is not None:
|
||||
middlewares.append(todo_list_middleware)
|
||||
|
||||
# Add TitleMiddleware
|
||||
middlewares.append(TitleMiddleware())
|
||||
|
||||
# Add MemoryMiddleware (after TitleMiddleware)
|
||||
middlewares.append(MemoryMiddleware())
|
||||
|
||||
# Add ViewImageMiddleware only if the current model supports vision
|
||||
model_name = config.get("configurable", {}).get("model_name") or config.get("configurable", {}).get("model")
|
||||
from src.config import get_app_config
|
||||
|
||||
app_config = get_app_config()
|
||||
# If no model_name specified, use the first model (default)
|
||||
if model_name is None and app_config.models:
|
||||
model_name = app_config.models[0].name
|
||||
|
||||
model_config = app_config.get_model_config(model_name) if model_name else None
|
||||
if model_config is not None and model_config.supports_vision:
|
||||
middlewares.append(ViewImageMiddleware())
|
||||
|
||||
# Add SubagentLimitMiddleware to truncate excess parallel task calls
|
||||
subagent_enabled = config.get("configurable", {}).get("subagent_enabled", False)
|
||||
if subagent_enabled:
|
||||
max_concurrent_subagents = config.get("configurable", {}).get("max_concurrent_subagents", 3)
|
||||
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents))
|
||||
|
||||
# ClarificationMiddleware should always be last
|
||||
middlewares.append(ClarificationMiddleware())
|
||||
return middlewares
|
||||
|
||||
|
||||
def make_lead_agent(config: RunnableConfig):
|
||||
# Lazy import to avoid circular dependency
|
||||
from src.tools import get_available_tools
|
||||
|
||||
thinking_enabled = config.get("configurable", {}).get("thinking_enabled", True)
|
||||
model_name = config.get("configurable", {}).get("model_name") or config.get("configurable", {}).get("model")
|
||||
is_plan_mode = config.get("configurable", {}).get("is_plan_mode", False)
|
||||
subagent_enabled = config.get("configurable", {}).get("subagent_enabled", False)
|
||||
max_concurrent_subagents = config.get("configurable", {}).get("max_concurrent_subagents", 3)
|
||||
print(f"thinking_enabled: {thinking_enabled}, model_name: {model_name}, is_plan_mode: {is_plan_mode}, subagent_enabled: {subagent_enabled}, max_concurrent_subagents: {max_concurrent_subagents}")
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled),
|
||||
tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled),
|
||||
middleware=_build_middlewares(config),
|
||||
system_prompt=apply_prompt_template(subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
391
backend/src/agents/lead_agent/prompt.py
Normal file
391
backend/src/agents/lead_agent/prompt.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from datetime import datetime
|
||||
|
||||
from src.skills import load_skills
|
||||
|
||||
|
||||
def _build_subagent_section(max_concurrent: int) -> str:
|
||||
"""Build the subagent system prompt section with dynamic concurrency limit.
|
||||
|
||||
Args:
|
||||
max_concurrent: Maximum number of concurrent subagent calls allowed per response.
|
||||
|
||||
Returns:
|
||||
Formatted subagent section string.
|
||||
"""
|
||||
n = max_concurrent
|
||||
return f"""<subagent_system>
|
||||
**🚀 SUBAGENT MODE ACTIVE - DECOMPOSE, DELEGATE, SYNTHESIZE**
|
||||
|
||||
You are running with subagent capabilities enabled. Your role is to be a **task orchestrator**:
|
||||
1. **DECOMPOSE**: Break complex tasks into parallel sub-tasks
|
||||
2. **DELEGATE**: Launch multiple subagents simultaneously using parallel `task` calls
|
||||
3. **SYNTHESIZE**: Collect and integrate results into a coherent answer
|
||||
|
||||
**CORE PRINCIPLE: Complex tasks should be decomposed and distributed across multiple subagents for parallel execution.**
|
||||
|
||||
**⛔ HARD CONCURRENCY LIMIT: MAXIMUM {n} `task` CALLS PER RESPONSE. THIS IS NOT OPTIONAL.**
|
||||
- Each response, you may include **at most {n}** `task` tool calls. Any excess calls are **silently discarded** by the system — you will lose that work.
|
||||
- **Before launching subagents, you MUST count your sub-tasks in your thinking:**
|
||||
- If count ≤ {n}: Launch all in this response.
|
||||
- If count > {n}: **Pick the {n} most important/foundational sub-tasks for this turn.** Save the rest for the next turn.
|
||||
- **Multi-batch execution** (for >{n} sub-tasks):
|
||||
- Turn 1: Launch sub-tasks 1-{n} in parallel → wait for results
|
||||
- Turn 2: Launch next batch in parallel → wait for results
|
||||
- ... continue until all sub-tasks are complete
|
||||
- Final turn: Synthesize ALL results into a coherent answer
|
||||
- **Example thinking pattern**: "I identified 6 sub-tasks. Since the limit is {n} per turn, I will launch the first {n} now, and the rest in the next turn."
|
||||
|
||||
**Available Subagents:**
|
||||
- **general-purpose**: For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.
|
||||
- **bash**: For command execution (git, build, test, deploy operations)
|
||||
|
||||
**Your Orchestration Strategy:**
|
||||
|
||||
✅ **DECOMPOSE + PARALLEL EXECUTION (Preferred Approach):**
|
||||
|
||||
For complex queries, break them down into focused sub-tasks and execute in parallel batches (max {n} per turn):
|
||||
|
||||
**Example 1: "Why is Tencent's stock price declining?" (3 sub-tasks → 1 batch)**
|
||||
→ Turn 1: Launch 3 subagents in parallel:
|
||||
- Subagent 1: Recent financial reports, earnings data, and revenue trends
|
||||
- Subagent 2: Negative news, controversies, and regulatory issues
|
||||
- Subagent 3: Industry trends, competitor performance, and market sentiment
|
||||
→ Turn 2: Synthesize results
|
||||
|
||||
**Example 2: "Compare 5 cloud providers" (5 sub-tasks → multi-batch)**
|
||||
→ Turn 1: Launch {n} subagents in parallel (first batch)
|
||||
→ Turn 2: Launch remaining subagents in parallel
|
||||
→ Final turn: Synthesize ALL results into comprehensive comparison
|
||||
|
||||
**Example 3: "Refactor the authentication system"**
|
||||
→ Turn 1: Launch 3 subagents in parallel:
|
||||
- Subagent 1: Analyze current auth implementation and technical debt
|
||||
- Subagent 2: Research best practices and security patterns
|
||||
- Subagent 3: Review related tests, documentation, and vulnerabilities
|
||||
→ Turn 2: Synthesize results
|
||||
|
||||
✅ **USE Parallel Subagents (max {n} per turn) when:**
|
||||
- **Complex research questions**: Requires multiple information sources or perspectives
|
||||
- **Multi-aspect analysis**: Task has several independent dimensions to explore
|
||||
- **Large codebases**: Need to analyze different parts simultaneously
|
||||
- **Comprehensive investigations**: Questions requiring thorough coverage from multiple angles
|
||||
|
||||
❌ **DO NOT use subagents (execute directly) when:**
|
||||
- **Task cannot be decomposed**: If you can't break it into 2+ meaningful parallel sub-tasks, execute directly
|
||||
- **Ultra-simple actions**: Read one file, quick edits, single commands
|
||||
- **Need immediate clarification**: Must ask user before proceeding
|
||||
- **Meta conversation**: Questions about conversation history
|
||||
- **Sequential dependencies**: Each step depends on previous results (do steps yourself sequentially)
|
||||
|
||||
**CRITICAL WORKFLOW** (STRICTLY follow this before EVERY action):
|
||||
1. **COUNT**: In your thinking, list all sub-tasks and count them explicitly: "I have N sub-tasks"
|
||||
2. **PLAN BATCHES**: If N > {n}, explicitly plan which sub-tasks go in which batch:
|
||||
- "Batch 1 (this turn): first {n} sub-tasks"
|
||||
- "Batch 2 (next turn): next batch of sub-tasks"
|
||||
3. **EXECUTE**: Launch ONLY the current batch (max {n} `task` calls). Do NOT launch sub-tasks from future batches.
|
||||
4. **REPEAT**: After results return, launch the next batch. Continue until all batches complete.
|
||||
5. **SYNTHESIZE**: After ALL batches are done, synthesize all results.
|
||||
6. **Cannot decompose** → Execute directly using available tools (bash, read_file, web_search, etc.)
|
||||
|
||||
**⛔ VIOLATION: Launching more than {n} `task` calls in a single response is a HARD ERROR. The system WILL discard excess calls and you WILL lose work. Always batch.**
|
||||
|
||||
**Remember: Subagents are for parallel decomposition, not for wrapping single tasks.**
|
||||
|
||||
**How It Works:**
|
||||
- The task tool runs subagents asynchronously in the background
|
||||
- The backend automatically polls for completion (you don't need to poll)
|
||||
- The tool call will block until the subagent completes its work
|
||||
- Once complete, the result is returned to you directly
|
||||
|
||||
**Usage Example 1 - Single Batch (≤{n} sub-tasks):**
|
||||
|
||||
```python
|
||||
# User asks: "Why is Tencent's stock price declining?"
|
||||
# Thinking: 3 sub-tasks → fits in 1 batch
|
||||
|
||||
# Turn 1: Launch 3 subagents in parallel
|
||||
task(description="Tencent financial data", prompt="...", subagent_type="general-purpose")
|
||||
task(description="Tencent news & regulation", prompt="...", subagent_type="general-purpose")
|
||||
task(description="Industry & market trends", prompt="...", subagent_type="general-purpose")
|
||||
# All 3 run in parallel → synthesize results
|
||||
```
|
||||
|
||||
**Usage Example 2 - Multiple Batches (>{n} sub-tasks):**
|
||||
|
||||
```python
|
||||
# User asks: "Compare AWS, Azure, GCP, Alibaba Cloud, and Oracle Cloud"
|
||||
# Thinking: 5 sub-tasks → need multiple batches (max {n} per batch)
|
||||
|
||||
# Turn 1: Launch first batch of {n}
|
||||
task(description="AWS analysis", prompt="...", subagent_type="general-purpose")
|
||||
task(description="Azure analysis", prompt="...", subagent_type="general-purpose")
|
||||
task(description="GCP analysis", prompt="...", subagent_type="general-purpose")
|
||||
|
||||
# Turn 2: Launch remaining batch (after first batch completes)
|
||||
task(description="Alibaba Cloud analysis", prompt="...", subagent_type="general-purpose")
|
||||
task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-purpose")
|
||||
|
||||
# Turn 3: Synthesize ALL results from both batches
|
||||
```
|
||||
|
||||
**Counter-Example - Direct Execution (NO subagents):**
|
||||
|
||||
```python
|
||||
# User asks: "Run the tests"
|
||||
# Thinking: Cannot decompose into parallel sub-tasks
|
||||
# → Execute directly
|
||||
|
||||
bash("npm test") # Direct execution, not task()
|
||||
```
|
||||
|
||||
**CRITICAL**:
|
||||
- **Max {n} `task` calls per turn** - the system enforces this, excess calls are discarded
|
||||
- Only use `task` when you can launch 2+ subagents in parallel
|
||||
- Single task = No value from subagents = Execute directly
|
||||
- For >{n} sub-tasks, use sequential batches of {n} across multiple turns
|
||||
</subagent_system>"""
|
||||
|
||||
|
||||
SYSTEM_PROMPT_TEMPLATE = """
|
||||
<role>
|
||||
You are DeerFlow 2.0, an open-source super agent.
|
||||
</role>
|
||||
|
||||
{memory_context}
|
||||
|
||||
<thinking_style>
|
||||
- Think concisely and strategically about the user's request BEFORE taking action
|
||||
- Break down the task: What is clear? What is ambiguous? What is missing?
|
||||
- **PRIORITY CHECK: If anything is unclear, missing, or has multiple interpretations, you MUST ask for clarification FIRST - do NOT proceed with work**
|
||||
{subagent_thinking}- Never write down your full final answer or report in thinking process, but only outline
|
||||
- CRITICAL: After thinking, you MUST provide your actual response to the user. Thinking is for planning, the response is for delivery.
|
||||
- Your response must contain the actual answer, not just a reference to what you thought about
|
||||
</thinking_style>
|
||||
|
||||
<clarification_system>
|
||||
**WORKFLOW PRIORITY: CLARIFY → PLAN → ACT**
|
||||
1. **FIRST**: Analyze the request in your thinking - identify what's unclear, missing, or ambiguous
|
||||
2. **SECOND**: If clarification is needed, call `ask_clarification` tool IMMEDIATELY - do NOT start working
|
||||
3. **THIRD**: Only after all clarifications are resolved, proceed with planning and execution
|
||||
|
||||
**CRITICAL RULE: Clarification ALWAYS comes BEFORE action. Never start working and clarify mid-execution.**
|
||||
|
||||
**MANDATORY Clarification Scenarios - You MUST call ask_clarification BEFORE starting work when:**
|
||||
|
||||
1. **Missing Information** (`missing_info`): Required details not provided
|
||||
- Example: User says "create a web scraper" but doesn't specify the target website
|
||||
- Example: "Deploy the app" without specifying environment
|
||||
- **REQUIRED ACTION**: Call ask_clarification to get the missing information
|
||||
|
||||
2. **Ambiguous Requirements** (`ambiguous_requirement`): Multiple valid interpretations exist
|
||||
- Example: "Optimize the code" could mean performance, readability, or memory usage
|
||||
- Example: "Make it better" is unclear what aspect to improve
|
||||
- **REQUIRED ACTION**: Call ask_clarification to clarify the exact requirement
|
||||
|
||||
3. **Approach Choices** (`approach_choice`): Several valid approaches exist
|
||||
- Example: "Add authentication" could use JWT, OAuth, session-based, or API keys
|
||||
- Example: "Store data" could use database, files, cache, etc.
|
||||
- **REQUIRED ACTION**: Call ask_clarification to let user choose the approach
|
||||
|
||||
4. **Risky Operations** (`risk_confirmation`): Destructive actions need confirmation
|
||||
- Example: Deleting files, modifying production configs, database operations
|
||||
- Example: Overwriting existing code or data
|
||||
- **REQUIRED ACTION**: Call ask_clarification to get explicit confirmation
|
||||
|
||||
5. **Suggestions** (`suggestion`): You have a recommendation but want approval
|
||||
- Example: "I recommend refactoring this code. Should I proceed?"
|
||||
- **REQUIRED ACTION**: Call ask_clarification to get approval
|
||||
|
||||
**STRICT ENFORCEMENT:**
|
||||
- ❌ DO NOT start working and then ask for clarification mid-execution - clarify FIRST
|
||||
- ❌ DO NOT skip clarification for "efficiency" - accuracy matters more than speed
|
||||
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
|
||||
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first
|
||||
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
|
||||
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
|
||||
- ✅ After calling ask_clarification, execution will be interrupted automatically
|
||||
- ✅ Wait for user response - do NOT continue with assumptions
|
||||
|
||||
**How to Use:**
|
||||
```python
|
||||
ask_clarification(
|
||||
question="Your specific question here?",
|
||||
clarification_type="missing_info", # or other type
|
||||
context="Why you need this information", # optional but recommended
|
||||
options=["option1", "option2"] # optional, for choices
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
User: "Deploy the application"
|
||||
You (thinking): Missing environment info - I MUST ask for clarification
|
||||
You (action): ask_clarification(
|
||||
question="Which environment should I deploy to?",
|
||||
clarification_type="approach_choice",
|
||||
context="I need to know the target environment for proper configuration",
|
||||
options=["development", "staging", "production"]
|
||||
)
|
||||
[Execution stops - wait for user response]
|
||||
|
||||
User: "staging"
|
||||
You: "Deploying to staging..." [proceed]
|
||||
</clarification_system>
|
||||
|
||||
{skills_section}
|
||||
|
||||
{subagent_section}
|
||||
|
||||
<working_directory existed="true">
|
||||
- User uploads: `/mnt/user-data/uploads` - Files uploaded by the user (automatically listed in context)
|
||||
- User workspace: `/mnt/user-data/workspace` - Working directory for temporary files
|
||||
- Output files: `/mnt/user-data/outputs` - Final deliverables must be saved here
|
||||
|
||||
**File Management:**
|
||||
- Uploaded files are automatically listed in the <uploaded_files> section before each request
|
||||
- Use `read_file` tool to read uploaded files using their paths from the list
|
||||
- For PDF, PPT, Excel, and Word files, converted Markdown versions (*.md) are available alongside originals
|
||||
- All temporary work happens in `/mnt/user-data/workspace`
|
||||
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_file` tool
|
||||
</working_directory>
|
||||
|
||||
<response_style>
|
||||
- Clear and Concise: Avoid over-formatting unless requested
|
||||
- Natural Tone: Use paragraphs and prose, not bullet points by default
|
||||
- Action-Oriented: Focus on delivering results, not explaining processes
|
||||
</response_style>
|
||||
|
||||
<citations>
|
||||
- When to Use: After web_search, include citations if applicable
|
||||
- Format: Use Markdown link format `[citation:TITLE](URL)`
|
||||
- Example:
|
||||
```markdown
|
||||
The key AI trends for 2026 include enhanced reasoning capabilities and multimodal integration
|
||||
[citation:AI Trends 2026](https://techcrunch.com/ai-trends).
|
||||
Recent breakthroughs in language models have also accelerated progress
|
||||
[citation:OpenAI Research](https://openai.com/research).
|
||||
```
|
||||
</citations>
|
||||
|
||||
<critical_reminders>
|
||||
- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess
|
||||
{subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks.
|
||||
- Progressive Loading: Load resources incrementally as referenced in skills
|
||||
- Output Files: Final deliverables must be in `/mnt/user-data/outputs`
|
||||
- Clarity: Be direct and helpful, avoid unnecessary meta-commentary
|
||||
- Including Images and Mermaid: Images and Mermaid diagrams are always welcomed in the Markdown format, and you're encouraged to use `\n\n` or "```mermaid" to display images in response or Markdown files
|
||||
- Multi-task: Better utilize parallel tool calling to call multiple tools at one time for better performance
|
||||
- Language Consistency: Keep using the same language as user's
|
||||
- Always Respond: Your thinking is internal. You MUST always provide a visible response to the user after thinking.
|
||||
</critical_reminders>
|
||||
"""
|
||||
|
||||
|
||||
def _get_memory_context() -> str:
|
||||
"""Get memory context for injection into system prompt.
|
||||
|
||||
Returns:
|
||||
Formatted memory context string wrapped in XML tags, or empty string if disabled.
|
||||
"""
|
||||
try:
|
||||
from src.agents.memory import format_memory_for_injection, get_memory_data
|
||||
from src.config.memory_config import get_memory_config
|
||||
|
||||
config = get_memory_config()
|
||||
if not config.enabled or not config.injection_enabled:
|
||||
return ""
|
||||
|
||||
memory_data = get_memory_data()
|
||||
memory_content = format_memory_for_injection(memory_data, max_tokens=config.max_injection_tokens)
|
||||
|
||||
if not memory_content.strip():
|
||||
return ""
|
||||
|
||||
return f"""<memory>
|
||||
{memory_content}
|
||||
</memory>
|
||||
"""
|
||||
except Exception as e:
|
||||
print(f"Failed to load memory context: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def get_skills_prompt_section() -> str:
|
||||
"""Generate the skills prompt section with available skills list.
|
||||
|
||||
Returns the <skill_system>...</skill_system> block listing all enabled skills,
|
||||
suitable for injection into any agent's system prompt.
|
||||
"""
|
||||
skills = load_skills(enabled_only=True)
|
||||
|
||||
try:
|
||||
from src.config import get_app_config
|
||||
|
||||
config = get_app_config()
|
||||
container_base_path = config.skills.container_path
|
||||
except Exception:
|
||||
container_base_path = "/mnt/skills"
|
||||
|
||||
if not skills:
|
||||
return ""
|
||||
|
||||
skill_items = "\n".join(
|
||||
f" <skill>\n <name>{skill.name}</name>\n <description>{skill.description}</description>\n <location>{skill.get_container_file_path(container_base_path)}</location>\n </skill>" for skill in skills
|
||||
)
|
||||
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
|
||||
|
||||
return f"""<skill_system>
|
||||
You have access to skills that provide optimized workflows for specific tasks. Each skill contains best practices, frameworks, and references to additional resources.
|
||||
|
||||
**Progressive Loading Pattern:**
|
||||
1. When a user query matches a skill's use case, immediately call `read_file` on the skill's main file using the path attribute provided in the skill tag below
|
||||
2. Read and understand the skill's workflow and instructions
|
||||
3. The skill file contains references to external resources under the same folder
|
||||
4. Load referenced resources only when needed during execution
|
||||
5. Follow the skill's instructions precisely
|
||||
|
||||
**Skills are located at:** {container_base_path}
|
||||
|
||||
{skills_list}
|
||||
|
||||
</skill_system>"""
|
||||
|
||||
|
||||
def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagents: int = 3) -> str:
|
||||
# Get memory context
|
||||
memory_context = _get_memory_context()
|
||||
|
||||
# Include subagent section only if enabled (from runtime parameter)
|
||||
n = max_concurrent_subagents
|
||||
subagent_section = _build_subagent_section(n) if subagent_enabled else ""
|
||||
|
||||
# Add subagent reminder to critical_reminders if enabled
|
||||
subagent_reminder = (
|
||||
"- **Orchestrator Mode**: You are a task orchestrator - decompose complex tasks into parallel sub-tasks. "
|
||||
f"**HARD LIMIT: max {n} `task` calls per response.** "
|
||||
f"If >{n} sub-tasks, split into sequential batches of ≤{n}. Synthesize after ALL batches complete.\n"
|
||||
if subagent_enabled
|
||||
else ""
|
||||
)
|
||||
|
||||
# Add subagent thinking guidance if enabled
|
||||
subagent_thinking = (
|
||||
"- **DECOMPOSITION CHECK: Can this task be broken into 2+ parallel sub-tasks? If YES, COUNT them. "
|
||||
f"If count > {n}, you MUST plan batches of ≤{n} and only launch the FIRST batch now. "
|
||||
f"NEVER launch more than {n} `task` calls in one response.**\n"
|
||||
if subagent_enabled
|
||||
else ""
|
||||
)
|
||||
|
||||
# Get skills section
|
||||
skills_section = get_skills_prompt_section()
|
||||
|
||||
# Format the prompt with dynamic skills and memory
|
||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||
skills_section=skills_section,
|
||||
memory_context=memory_context,
|
||||
subagent_section=subagent_section,
|
||||
subagent_reminder=subagent_reminder,
|
||||
subagent_thinking=subagent_thinking,
|
||||
)
|
||||
|
||||
return prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||
44
backend/src/agents/memory/__init__.py
Normal file
44
backend/src/agents/memory/__init__.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Memory module for DeerFlow.
|
||||
|
||||
This module provides a global memory mechanism that:
|
||||
- Stores user context and conversation history in memory.json
|
||||
- Uses LLM to summarize and extract facts from conversations
|
||||
- Injects relevant memory into system prompts for personalized responses
|
||||
"""
|
||||
|
||||
from src.agents.memory.prompt import (
|
||||
FACT_EXTRACTION_PROMPT,
|
||||
MEMORY_UPDATE_PROMPT,
|
||||
format_conversation_for_update,
|
||||
format_memory_for_injection,
|
||||
)
|
||||
from src.agents.memory.queue import (
|
||||
ConversationContext,
|
||||
MemoryUpdateQueue,
|
||||
get_memory_queue,
|
||||
reset_memory_queue,
|
||||
)
|
||||
from src.agents.memory.updater import (
|
||||
MemoryUpdater,
|
||||
get_memory_data,
|
||||
reload_memory_data,
|
||||
update_memory_from_conversation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Prompt utilities
|
||||
"MEMORY_UPDATE_PROMPT",
|
||||
"FACT_EXTRACTION_PROMPT",
|
||||
"format_memory_for_injection",
|
||||
"format_conversation_for_update",
|
||||
# Queue
|
||||
"ConversationContext",
|
||||
"MemoryUpdateQueue",
|
||||
"get_memory_queue",
|
||||
"reset_memory_queue",
|
||||
# Updater
|
||||
"MemoryUpdater",
|
||||
"get_memory_data",
|
||||
"reload_memory_data",
|
||||
"update_memory_from_conversation",
|
||||
]
|
||||
261
backend/src/agents/memory/prompt.py
Normal file
261
backend/src/agents/memory/prompt.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""Prompt templates for memory update and injection."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import tiktoken
|
||||
|
||||
TIKTOKEN_AVAILABLE = True
|
||||
except ImportError:
|
||||
TIKTOKEN_AVAILABLE = False
|
||||
|
||||
# Prompt template for updating memory based on conversation
|
||||
MEMORY_UPDATE_PROMPT = """You are a memory management system. Your task is to analyze a conversation and update the user's memory profile.
|
||||
|
||||
Current Memory State:
|
||||
<current_memory>
|
||||
{current_memory}
|
||||
</current_memory>
|
||||
|
||||
New Conversation to Process:
|
||||
<conversation>
|
||||
{conversation}
|
||||
</conversation>
|
||||
|
||||
Instructions:
|
||||
1. Analyze the conversation for important information about the user
|
||||
2. Extract relevant facts, preferences, and context with specific details (numbers, names, technologies)
|
||||
3. Update the memory sections as needed following the detailed length guidelines below
|
||||
|
||||
Memory Section Guidelines:
|
||||
|
||||
**User Context** (Current state - concise summaries):
|
||||
- workContext: Professional role, company, key projects, main technologies (2-3 sentences)
|
||||
Example: Core contributor, project names with metrics (16k+ stars), technical stack
|
||||
- personalContext: Languages, communication preferences, key interests (1-2 sentences)
|
||||
Example: Bilingual capabilities, specific interest areas, expertise domains
|
||||
- topOfMind: Multiple ongoing focus areas and priorities (3-5 sentences, detailed paragraph)
|
||||
Example: Primary project work, parallel technical investigations, ongoing learning/tracking
|
||||
Include: Active implementation work, troubleshooting issues, market/research interests
|
||||
Note: This captures SEVERAL concurrent focus areas, not just one task
|
||||
|
||||
**History** (Temporal context - rich paragraphs):
|
||||
- recentMonths: Detailed summary of recent activities (4-6 sentences or 1-2 paragraphs)
|
||||
Timeline: Last 1-3 months of interactions
|
||||
Include: Technologies explored, projects worked on, problems solved, interests demonstrated
|
||||
- earlierContext: Important historical patterns (3-5 sentences or 1 paragraph)
|
||||
Timeline: 3-12 months ago
|
||||
Include: Past projects, learning journeys, established patterns
|
||||
- longTermBackground: Persistent background and foundational context (2-4 sentences)
|
||||
Timeline: Overall/foundational information
|
||||
Include: Core expertise, longstanding interests, fundamental working style
|
||||
|
||||
**Facts Extraction**:
|
||||
- Extract specific, quantifiable details (e.g., "16k+ GitHub stars", "200+ datasets")
|
||||
- Include proper nouns (company names, project names, technology names)
|
||||
- Preserve technical terminology and version numbers
|
||||
- Categories:
|
||||
* preference: Tools, styles, approaches user prefers/dislikes
|
||||
* knowledge: Specific expertise, technologies mastered, domain knowledge
|
||||
* context: Background facts (job title, projects, locations, languages)
|
||||
* behavior: Working patterns, communication habits, problem-solving approaches
|
||||
* goal: Stated objectives, learning targets, project ambitions
|
||||
- Confidence levels:
|
||||
* 0.9-1.0: Explicitly stated facts ("I work on X", "My role is Y")
|
||||
* 0.7-0.8: Strongly implied from actions/discussions
|
||||
* 0.5-0.6: Inferred patterns (use sparingly, only for clear patterns)
|
||||
|
||||
**What Goes Where**:
|
||||
- workContext: Current job, active projects, primary tech stack
|
||||
- personalContext: Languages, personality, interests outside direct work tasks
|
||||
- topOfMind: Multiple ongoing priorities and focus areas user cares about recently (gets updated most frequently)
|
||||
Should capture 3-5 concurrent themes: main work, side explorations, learning/tracking interests
|
||||
- recentMonths: Detailed account of recent technical explorations and work
|
||||
- earlierContext: Patterns from slightly older interactions still relevant
|
||||
- longTermBackground: Unchanging foundational facts about the user
|
||||
|
||||
**Multilingual Content**:
|
||||
- Preserve original language for proper nouns and company names
|
||||
- Keep technical terms in their original form (DeepSeek, LangGraph, etc.)
|
||||
- Note language capabilities in personalContext
|
||||
|
||||
Output Format (JSON):
|
||||
{{
|
||||
"user": {{
|
||||
"workContext": {{ "summary": "...", "shouldUpdate": true/false }},
|
||||
"personalContext": {{ "summary": "...", "shouldUpdate": true/false }},
|
||||
"topOfMind": {{ "summary": "...", "shouldUpdate": true/false }}
|
||||
}},
|
||||
"history": {{
|
||||
"recentMonths": {{ "summary": "...", "shouldUpdate": true/false }},
|
||||
"earlierContext": {{ "summary": "...", "shouldUpdate": true/false }},
|
||||
"longTermBackground": {{ "summary": "...", "shouldUpdate": true/false }}
|
||||
}},
|
||||
"newFacts": [
|
||||
{{ "content": "...", "category": "preference|knowledge|context|behavior|goal", "confidence": 0.0-1.0 }}
|
||||
],
|
||||
"factsToRemove": ["fact_id_1", "fact_id_2"]
|
||||
}}
|
||||
|
||||
Important Rules:
|
||||
- Only set shouldUpdate=true if there's meaningful new information
|
||||
- Follow length guidelines: workContext/personalContext are concise (1-3 sentences), topOfMind and history sections are detailed (paragraphs)
|
||||
- Include specific metrics, version numbers, and proper nouns in facts
|
||||
- Only add facts that are clearly stated (0.9+) or strongly implied (0.7+)
|
||||
- Remove facts that are contradicted by new information
|
||||
- When updating topOfMind, integrate new focus areas while removing completed/abandoned ones
|
||||
Keep 3-5 concurrent focus themes that are still active and relevant
|
||||
- For history sections, integrate new information chronologically into appropriate time period
|
||||
- Preserve technical accuracy - keep exact names of technologies, companies, projects
|
||||
- Focus on information useful for future interactions and personalization
|
||||
|
||||
Return ONLY valid JSON, no explanation or markdown."""
|
||||
|
||||
|
||||
# Prompt template for extracting facts from a single message
|
||||
FACT_EXTRACTION_PROMPT = """Extract factual information about the user from this message.
|
||||
|
||||
Message:
|
||||
{message}
|
||||
|
||||
Extract facts in this JSON format:
|
||||
{{
|
||||
"facts": [
|
||||
{{ "content": "...", "category": "preference|knowledge|context|behavior|goal", "confidence": 0.0-1.0 }}
|
||||
]
|
||||
}}
|
||||
|
||||
Categories:
|
||||
- preference: User preferences (likes/dislikes, styles, tools)
|
||||
- knowledge: User's expertise or knowledge areas
|
||||
- context: Background context (location, job, projects)
|
||||
- behavior: Behavioral patterns
|
||||
- goal: User's goals or objectives
|
||||
|
||||
Rules:
|
||||
- Only extract clear, specific facts
|
||||
- Confidence should reflect certainty (explicit statement = 0.9+, implied = 0.6-0.8)
|
||||
- Skip vague or temporary information
|
||||
|
||||
Return ONLY valid JSON."""
|
||||
|
||||
|
||||
def _count_tokens(text: str, encoding_name: str = "cl100k_base") -> int:
|
||||
"""Count tokens in text using tiktoken.
|
||||
|
||||
Args:
|
||||
text: The text to count tokens for.
|
||||
encoding_name: The encoding to use (default: cl100k_base for GPT-4/3.5).
|
||||
|
||||
Returns:
|
||||
The number of tokens in the text.
|
||||
"""
|
||||
if not TIKTOKEN_AVAILABLE:
|
||||
# Fallback to character-based estimation if tiktoken is not available
|
||||
return len(text) // 4
|
||||
|
||||
try:
|
||||
encoding = tiktoken.get_encoding(encoding_name)
|
||||
return len(encoding.encode(text))
|
||||
except Exception:
|
||||
# Fallback to character-based estimation on error
|
||||
return len(text) // 4
|
||||
|
||||
|
||||
def format_memory_for_injection(memory_data: dict[str, Any], max_tokens: int = 2000) -> str:
|
||||
"""Format memory data for injection into system prompt.
|
||||
|
||||
Args:
|
||||
memory_data: The memory data dictionary.
|
||||
max_tokens: Maximum tokens to use (counted via tiktoken for accuracy).
|
||||
|
||||
Returns:
|
||||
Formatted memory string for system prompt injection.
|
||||
"""
|
||||
if not memory_data:
|
||||
return ""
|
||||
|
||||
sections = []
|
||||
|
||||
# Format user context
|
||||
user_data = memory_data.get("user", {})
|
||||
if user_data:
|
||||
user_sections = []
|
||||
|
||||
work_ctx = user_data.get("workContext", {})
|
||||
if work_ctx.get("summary"):
|
||||
user_sections.append(f"Work: {work_ctx['summary']}")
|
||||
|
||||
personal_ctx = user_data.get("personalContext", {})
|
||||
if personal_ctx.get("summary"):
|
||||
user_sections.append(f"Personal: {personal_ctx['summary']}")
|
||||
|
||||
top_of_mind = user_data.get("topOfMind", {})
|
||||
if top_of_mind.get("summary"):
|
||||
user_sections.append(f"Current Focus: {top_of_mind['summary']}")
|
||||
|
||||
if user_sections:
|
||||
sections.append("User Context:\n" + "\n".join(f"- {s}" for s in user_sections))
|
||||
|
||||
# Format history
|
||||
history_data = memory_data.get("history", {})
|
||||
if history_data:
|
||||
history_sections = []
|
||||
|
||||
recent = history_data.get("recentMonths", {})
|
||||
if recent.get("summary"):
|
||||
history_sections.append(f"Recent: {recent['summary']}")
|
||||
|
||||
earlier = history_data.get("earlierContext", {})
|
||||
if earlier.get("summary"):
|
||||
history_sections.append(f"Earlier: {earlier['summary']}")
|
||||
|
||||
if history_sections:
|
||||
sections.append("History:\n" + "\n".join(f"- {s}" for s in history_sections))
|
||||
|
||||
if not sections:
|
||||
return ""
|
||||
|
||||
result = "\n\n".join(sections)
|
||||
|
||||
# Use accurate token counting with tiktoken
|
||||
token_count = _count_tokens(result)
|
||||
if token_count > max_tokens:
|
||||
# Truncate to fit within token limit
|
||||
# Estimate characters to remove based on token ratio
|
||||
char_per_token = len(result) / token_count
|
||||
target_chars = int(max_tokens * char_per_token * 0.95) # 95% to leave margin
|
||||
result = result[:target_chars] + "\n..."
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_conversation_for_update(messages: list[Any]) -> str:
|
||||
"""Format conversation messages for memory update prompt.
|
||||
|
||||
Args:
|
||||
messages: List of conversation messages.
|
||||
|
||||
Returns:
|
||||
Formatted conversation string.
|
||||
"""
|
||||
lines = []
|
||||
for msg in messages:
|
||||
role = getattr(msg, "type", "unknown")
|
||||
content = getattr(msg, "content", str(msg))
|
||||
|
||||
# Handle content that might be a list (multimodal)
|
||||
if isinstance(content, list):
|
||||
text_parts = [p.get("text", "") for p in content if isinstance(p, dict) and "text" in p]
|
||||
content = " ".join(text_parts) if text_parts else str(content)
|
||||
|
||||
# Truncate very long messages
|
||||
if len(str(content)) > 1000:
|
||||
content = str(content)[:1000] + "..."
|
||||
|
||||
if role == "human":
|
||||
lines.append(f"User: {content}")
|
||||
elif role == "ai":
|
||||
lines.append(f"Assistant: {content}")
|
||||
|
||||
return "\n\n".join(lines)
|
||||
191
backend/src/agents/memory/queue.py
Normal file
191
backend/src/agents/memory/queue.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Memory update queue with debounce mechanism."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from src.config.memory_config import get_memory_config
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationContext:
|
||||
"""Context for a conversation to be processed for memory update."""
|
||||
|
||||
thread_id: str
|
||||
messages: list[Any]
|
||||
timestamp: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class MemoryUpdateQueue:
|
||||
"""Queue for memory updates with debounce mechanism.
|
||||
|
||||
This queue collects conversation contexts and processes them after
|
||||
a configurable debounce period. Multiple conversations received within
|
||||
the debounce window are batched together.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the memory update queue."""
|
||||
self._queue: list[ConversationContext] = []
|
||||
self._lock = threading.Lock()
|
||||
self._timer: threading.Timer | None = None
|
||||
self._processing = False
|
||||
|
||||
def add(self, thread_id: str, messages: list[Any]) -> None:
|
||||
"""Add a conversation to the update queue.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
messages: The conversation messages.
|
||||
"""
|
||||
config = get_memory_config()
|
||||
if not config.enabled:
|
||||
return
|
||||
|
||||
context = ConversationContext(
|
||||
thread_id=thread_id,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
# Check if this thread already has a pending update
|
||||
# If so, replace it with the newer one
|
||||
self._queue = [c for c in self._queue if c.thread_id != thread_id]
|
||||
self._queue.append(context)
|
||||
|
||||
# Reset or start the debounce timer
|
||||
self._reset_timer()
|
||||
|
||||
print(f"Memory update queued for thread {thread_id}, queue size: {len(self._queue)}")
|
||||
|
||||
def _reset_timer(self) -> None:
|
||||
"""Reset the debounce timer."""
|
||||
config = get_memory_config()
|
||||
|
||||
# Cancel existing timer if any
|
||||
if self._timer is not None:
|
||||
self._timer.cancel()
|
||||
|
||||
# Start new timer
|
||||
self._timer = threading.Timer(
|
||||
config.debounce_seconds,
|
||||
self._process_queue,
|
||||
)
|
||||
self._timer.daemon = True
|
||||
self._timer.start()
|
||||
|
||||
print(f"Memory update timer set for {config.debounce_seconds}s")
|
||||
|
||||
def _process_queue(self) -> None:
|
||||
"""Process all queued conversation contexts."""
|
||||
# Import here to avoid circular dependency
|
||||
from src.agents.memory.updater import MemoryUpdater
|
||||
|
||||
with self._lock:
|
||||
if self._processing:
|
||||
# Already processing, reschedule
|
||||
self._reset_timer()
|
||||
return
|
||||
|
||||
if not self._queue:
|
||||
return
|
||||
|
||||
self._processing = True
|
||||
contexts_to_process = self._queue.copy()
|
||||
self._queue.clear()
|
||||
self._timer = None
|
||||
|
||||
print(f"Processing {len(contexts_to_process)} queued memory updates")
|
||||
|
||||
try:
|
||||
updater = MemoryUpdater()
|
||||
|
||||
for context in contexts_to_process:
|
||||
try:
|
||||
print(f"Updating memory for thread {context.thread_id}")
|
||||
success = updater.update_memory(
|
||||
messages=context.messages,
|
||||
thread_id=context.thread_id,
|
||||
)
|
||||
if success:
|
||||
print(f"Memory updated successfully for thread {context.thread_id}")
|
||||
else:
|
||||
print(f"Memory update skipped/failed for thread {context.thread_id}")
|
||||
except Exception as e:
|
||||
print(f"Error updating memory for thread {context.thread_id}: {e}")
|
||||
|
||||
# Small delay between updates to avoid rate limiting
|
||||
if len(contexts_to_process) > 1:
|
||||
time.sleep(0.5)
|
||||
|
||||
finally:
|
||||
with self._lock:
|
||||
self._processing = False
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Force immediate processing of the queue.
|
||||
|
||||
This is useful for testing or graceful shutdown.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._timer is not None:
|
||||
self._timer.cancel()
|
||||
self._timer = None
|
||||
|
||||
self._process_queue()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the queue without processing.
|
||||
|
||||
This is useful for testing.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._timer is not None:
|
||||
self._timer.cancel()
|
||||
self._timer = None
|
||||
self._queue.clear()
|
||||
self._processing = False
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
"""Get the number of pending updates."""
|
||||
with self._lock:
|
||||
return len(self._queue)
|
||||
|
||||
@property
|
||||
def is_processing(self) -> bool:
|
||||
"""Check if the queue is currently being processed."""
|
||||
with self._lock:
|
||||
return self._processing
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_memory_queue: MemoryUpdateQueue | None = None
|
||||
_queue_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_memory_queue() -> MemoryUpdateQueue:
|
||||
"""Get the global memory update queue singleton.
|
||||
|
||||
Returns:
|
||||
The memory update queue instance.
|
||||
"""
|
||||
global _memory_queue
|
||||
with _queue_lock:
|
||||
if _memory_queue is None:
|
||||
_memory_queue = MemoryUpdateQueue()
|
||||
return _memory_queue
|
||||
|
||||
|
||||
def reset_memory_queue() -> None:
|
||||
"""Reset the global memory queue.
|
||||
|
||||
This is useful for testing.
|
||||
"""
|
||||
global _memory_queue
|
||||
with _queue_lock:
|
||||
if _memory_queue is not None:
|
||||
_memory_queue.clear()
|
||||
_memory_queue = None
|
||||
316
backend/src/agents/memory/updater.py
Normal file
316
backend/src/agents/memory/updater.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""Memory updater for reading, writing, and updating memory data."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.agents.memory.prompt import (
|
||||
MEMORY_UPDATE_PROMPT,
|
||||
format_conversation_for_update,
|
||||
)
|
||||
from src.config.memory_config import get_memory_config
|
||||
from src.models import create_chat_model
|
||||
|
||||
|
||||
def _get_memory_file_path() -> Path:
|
||||
"""Get the path to the memory file."""
|
||||
config = get_memory_config()
|
||||
# Resolve relative to current working directory (backend/)
|
||||
return Path(os.getcwd()) / config.storage_path
|
||||
|
||||
|
||||
def _create_empty_memory() -> dict[str, Any]:
|
||||
"""Create an empty memory structure."""
|
||||
return {
|
||||
"version": "1.0",
|
||||
"lastUpdated": datetime.utcnow().isoformat() + "Z",
|
||||
"user": {
|
||||
"workContext": {"summary": "", "updatedAt": ""},
|
||||
"personalContext": {"summary": "", "updatedAt": ""},
|
||||
"topOfMind": {"summary": "", "updatedAt": ""},
|
||||
},
|
||||
"history": {
|
||||
"recentMonths": {"summary": "", "updatedAt": ""},
|
||||
"earlierContext": {"summary": "", "updatedAt": ""},
|
||||
"longTermBackground": {"summary": "", "updatedAt": ""},
|
||||
},
|
||||
"facts": [],
|
||||
}
|
||||
|
||||
|
||||
# Global memory data cache
|
||||
_memory_data: dict[str, Any] | None = None
|
||||
# Track file modification time for cache invalidation
|
||||
_memory_file_mtime: float | None = None
|
||||
|
||||
|
||||
def get_memory_data() -> dict[str, Any]:
|
||||
"""Get the current memory data (cached with file modification time check).
|
||||
|
||||
The cache is automatically invalidated if the memory file has been modified
|
||||
since the last load, ensuring fresh data is always returned.
|
||||
|
||||
Returns:
|
||||
The memory data dictionary.
|
||||
"""
|
||||
global _memory_data, _memory_file_mtime
|
||||
|
||||
file_path = _get_memory_file_path()
|
||||
|
||||
# Get current file modification time
|
||||
try:
|
||||
current_mtime = file_path.stat().st_mtime if file_path.exists() else None
|
||||
except OSError:
|
||||
current_mtime = None
|
||||
|
||||
# Invalidate cache if file has been modified or doesn't exist
|
||||
if _memory_data is None or _memory_file_mtime != current_mtime:
|
||||
_memory_data = _load_memory_from_file()
|
||||
_memory_file_mtime = current_mtime
|
||||
|
||||
return _memory_data
|
||||
|
||||
|
||||
def reload_memory_data() -> dict[str, Any]:
|
||||
"""Reload memory data from file, forcing cache invalidation.
|
||||
|
||||
Returns:
|
||||
The reloaded memory data dictionary.
|
||||
"""
|
||||
global _memory_data, _memory_file_mtime
|
||||
|
||||
file_path = _get_memory_file_path()
|
||||
_memory_data = _load_memory_from_file()
|
||||
|
||||
# Update file modification time after reload
|
||||
try:
|
||||
_memory_file_mtime = file_path.stat().st_mtime if file_path.exists() else None
|
||||
except OSError:
|
||||
_memory_file_mtime = None
|
||||
|
||||
return _memory_data
|
||||
|
||||
|
||||
def _load_memory_from_file() -> dict[str, Any]:
|
||||
"""Load memory data from file.
|
||||
|
||||
Returns:
|
||||
The memory data dictionary.
|
||||
"""
|
||||
file_path = _get_memory_file_path()
|
||||
|
||||
if not file_path.exists():
|
||||
return _create_empty_memory()
|
||||
|
||||
try:
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"Failed to load memory file: {e}")
|
||||
return _create_empty_memory()
|
||||
|
||||
|
||||
def _save_memory_to_file(memory_data: dict[str, Any]) -> bool:
|
||||
"""Save memory data to file and update cache.
|
||||
|
||||
Args:
|
||||
memory_data: The memory data to save.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
global _memory_data, _memory_file_mtime
|
||||
file_path = _get_memory_file_path()
|
||||
|
||||
try:
|
||||
# Ensure directory exists
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Update lastUpdated timestamp
|
||||
memory_data["lastUpdated"] = datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
# Write atomically using temp file
|
||||
temp_path = file_path.with_suffix(".tmp")
|
||||
with open(temp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(memory_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Rename temp file to actual file (atomic on most systems)
|
||||
temp_path.replace(file_path)
|
||||
|
||||
# Update cache and file modification time
|
||||
_memory_data = memory_data
|
||||
try:
|
||||
_memory_file_mtime = file_path.stat().st_mtime
|
||||
except OSError:
|
||||
_memory_file_mtime = None
|
||||
|
||||
print(f"Memory saved to {file_path}")
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"Failed to save memory file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class MemoryUpdater:
|
||||
"""Updates memory using LLM based on conversation context."""
|
||||
|
||||
def __init__(self, model_name: str | None = None):
|
||||
"""Initialize the memory updater.
|
||||
|
||||
Args:
|
||||
model_name: Optional model name to use. If None, uses config or default.
|
||||
"""
|
||||
self._model_name = model_name
|
||||
|
||||
def _get_model(self):
|
||||
"""Get the model for memory updates."""
|
||||
config = get_memory_config()
|
||||
model_name = self._model_name or config.model_name
|
||||
return create_chat_model(name=model_name, thinking_enabled=False)
|
||||
|
||||
def update_memory(self, messages: list[Any], thread_id: str | None = None) -> bool:
|
||||
"""Update memory based on conversation messages.
|
||||
|
||||
Args:
|
||||
messages: List of conversation messages.
|
||||
thread_id: Optional thread ID for tracking source.
|
||||
|
||||
Returns:
|
||||
True if update was successful, False otherwise.
|
||||
"""
|
||||
config = get_memory_config()
|
||||
if not config.enabled:
|
||||
return False
|
||||
|
||||
if not messages:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Get current memory
|
||||
current_memory = get_memory_data()
|
||||
|
||||
# Format conversation for prompt
|
||||
conversation_text = format_conversation_for_update(messages)
|
||||
|
||||
if not conversation_text.strip():
|
||||
return False
|
||||
|
||||
# Build prompt
|
||||
prompt = MEMORY_UPDATE_PROMPT.format(
|
||||
current_memory=json.dumps(current_memory, indent=2),
|
||||
conversation=conversation_text,
|
||||
)
|
||||
|
||||
# Call LLM
|
||||
model = self._get_model()
|
||||
response = model.invoke(prompt)
|
||||
response_text = str(response.content).strip()
|
||||
|
||||
# Parse response
|
||||
# Remove markdown code blocks if present
|
||||
if response_text.startswith("```"):
|
||||
lines = response_text.split("\n")
|
||||
response_text = "\n".join(lines[1:-1] if lines[-1] == "```" else lines[1:])
|
||||
|
||||
update_data = json.loads(response_text)
|
||||
|
||||
# Apply updates
|
||||
updated_memory = self._apply_updates(current_memory, update_data, thread_id)
|
||||
|
||||
# Save
|
||||
return _save_memory_to_file(updated_memory)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Failed to parse LLM response for memory update: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Memory update failed: {e}")
|
||||
return False
|
||||
|
||||
def _apply_updates(
|
||||
self,
|
||||
current_memory: dict[str, Any],
|
||||
update_data: dict[str, Any],
|
||||
thread_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply LLM-generated updates to memory.
|
||||
|
||||
Args:
|
||||
current_memory: Current memory data.
|
||||
update_data: Updates from LLM.
|
||||
thread_id: Optional thread ID for tracking.
|
||||
|
||||
Returns:
|
||||
Updated memory data.
|
||||
"""
|
||||
config = get_memory_config()
|
||||
now = datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
# Update user sections
|
||||
user_updates = update_data.get("user", {})
|
||||
for section in ["workContext", "personalContext", "topOfMind"]:
|
||||
section_data = user_updates.get(section, {})
|
||||
if section_data.get("shouldUpdate") and section_data.get("summary"):
|
||||
current_memory["user"][section] = {
|
||||
"summary": section_data["summary"],
|
||||
"updatedAt": now,
|
||||
}
|
||||
|
||||
# Update history sections
|
||||
history_updates = update_data.get("history", {})
|
||||
for section in ["recentMonths", "earlierContext", "longTermBackground"]:
|
||||
section_data = history_updates.get(section, {})
|
||||
if section_data.get("shouldUpdate") and section_data.get("summary"):
|
||||
current_memory["history"][section] = {
|
||||
"summary": section_data["summary"],
|
||||
"updatedAt": now,
|
||||
}
|
||||
|
||||
# Remove facts
|
||||
facts_to_remove = set(update_data.get("factsToRemove", []))
|
||||
if facts_to_remove:
|
||||
current_memory["facts"] = [f for f in current_memory.get("facts", []) if f.get("id") not in facts_to_remove]
|
||||
|
||||
# Add new facts
|
||||
new_facts = update_data.get("newFacts", [])
|
||||
for fact in new_facts:
|
||||
confidence = fact.get("confidence", 0.5)
|
||||
if confidence >= config.fact_confidence_threshold:
|
||||
fact_entry = {
|
||||
"id": f"fact_{uuid.uuid4().hex[:8]}",
|
||||
"content": fact.get("content", ""),
|
||||
"category": fact.get("category", "context"),
|
||||
"confidence": confidence,
|
||||
"createdAt": now,
|
||||
"source": thread_id or "unknown",
|
||||
}
|
||||
current_memory["facts"].append(fact_entry)
|
||||
|
||||
# Enforce max facts limit
|
||||
if len(current_memory["facts"]) > config.max_facts:
|
||||
# Sort by confidence and keep top ones
|
||||
current_memory["facts"] = sorted(
|
||||
current_memory["facts"],
|
||||
key=lambda f: f.get("confidence", 0),
|
||||
reverse=True,
|
||||
)[: config.max_facts]
|
||||
|
||||
return current_memory
|
||||
|
||||
|
||||
def update_memory_from_conversation(messages: list[Any], thread_id: str | None = None) -> bool:
|
||||
"""Convenience function to update memory from a conversation.
|
||||
|
||||
Args:
|
||||
messages: List of conversation messages.
|
||||
thread_id: Optional thread ID.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
updater = MemoryUpdater()
|
||||
return updater.update_memory(messages, thread_id)
|
||||
173
backend/src/agents/middlewares/clarification_middleware.py
Normal file
173
backend/src/agents/middlewares/clarification_middleware.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Middleware for intercepting clarification requests and presenting them to the user."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.graph import END
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
|
||||
class ClarificationMiddlewareState(AgentState):
|
||||
"""Compatible with the `ThreadState` schema."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
"""Intercepts clarification tool calls and interrupts execution to present questions to the user.
|
||||
|
||||
When the model calls the `ask_clarification` tool, this middleware:
|
||||
1. Intercepts the tool call before execution
|
||||
2. Extracts the clarification question and metadata
|
||||
3. Formats a user-friendly message
|
||||
4. Returns a Command that interrupts execution and presents the question
|
||||
5. Waits for user response before continuing
|
||||
|
||||
This replaces the tool-based approach where clarification continued the conversation flow.
|
||||
"""
|
||||
|
||||
state_schema = ClarificationMiddlewareState
|
||||
|
||||
def _is_chinese(self, text: str) -> bool:
|
||||
"""Check if text contains Chinese characters.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text contains Chinese characters
|
||||
"""
|
||||
return any("\u4e00" <= char <= "\u9fff" for char in text)
|
||||
|
||||
def _format_clarification_message(self, args: dict) -> str:
|
||||
"""Format the clarification arguments into a user-friendly message.
|
||||
|
||||
Args:
|
||||
args: The tool call arguments containing clarification details
|
||||
|
||||
Returns:
|
||||
Formatted message string
|
||||
"""
|
||||
question = args.get("question", "")
|
||||
clarification_type = args.get("clarification_type", "missing_info")
|
||||
context = args.get("context")
|
||||
options = args.get("options", [])
|
||||
|
||||
# Type-specific icons
|
||||
type_icons = {
|
||||
"missing_info": "❓",
|
||||
"ambiguous_requirement": "🤔",
|
||||
"approach_choice": "🔀",
|
||||
"risk_confirmation": "⚠️",
|
||||
"suggestion": "💡",
|
||||
}
|
||||
|
||||
icon = type_icons.get(clarification_type, "❓")
|
||||
|
||||
# Build the message naturally
|
||||
message_parts = []
|
||||
|
||||
# Add icon and question together for a more natural flow
|
||||
if context:
|
||||
# If there's context, present it first as background
|
||||
message_parts.append(f"{icon} {context}")
|
||||
message_parts.append(f"\n{question}")
|
||||
else:
|
||||
# Just the question with icon
|
||||
message_parts.append(f"{icon} {question}")
|
||||
|
||||
# Add options in a cleaner format
|
||||
if options and len(options) > 0:
|
||||
message_parts.append("") # blank line for spacing
|
||||
for i, option in enumerate(options, 1):
|
||||
message_parts.append(f" {i}. {option}")
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
||||
"""Handle clarification request and return command to interrupt execution.
|
||||
|
||||
Args:
|
||||
request: Tool call request
|
||||
|
||||
Returns:
|
||||
Command that interrupts execution with the formatted clarification message
|
||||
"""
|
||||
# Extract clarification arguments
|
||||
args = request.tool_call.get("args", {})
|
||||
question = args.get("question", "")
|
||||
|
||||
print("[ClarificationMiddleware] Intercepted clarification request")
|
||||
print(f"[ClarificationMiddleware] Question: {question}")
|
||||
|
||||
# Format the clarification message
|
||||
formatted_message = self._format_clarification_message(args)
|
||||
|
||||
# Get the tool call ID
|
||||
tool_call_id = request.tool_call.get("id", "")
|
||||
|
||||
# Create a ToolMessage with the formatted question
|
||||
# This will be added to the message history
|
||||
tool_message = ToolMessage(
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
)
|
||||
|
||||
# Return a Command that:
|
||||
# 1. Adds the formatted tool message
|
||||
# 2. Interrupts execution by going to __end__
|
||||
# Note: We don't add an extra AIMessage here - the frontend will detect
|
||||
# and display ask_clarification tool messages directly
|
||||
return Command(
|
||||
update={"messages": [tool_message]},
|
||||
goto=END,
|
||||
)
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
"""Intercept ask_clarification tool calls and interrupt execution (sync version).
|
||||
|
||||
Args:
|
||||
request: Tool call request
|
||||
handler: Original tool execution handler
|
||||
|
||||
Returns:
|
||||
Command that interrupts execution with the formatted clarification message
|
||||
"""
|
||||
# Check if this is an ask_clarification tool call
|
||||
if request.tool_call.get("name") != "ask_clarification":
|
||||
# Not a clarification call, execute normally
|
||||
return handler(request)
|
||||
|
||||
return self._handle_clarification(request)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
"""Intercept ask_clarification tool calls and interrupt execution (async version).
|
||||
|
||||
Args:
|
||||
request: Tool call request
|
||||
handler: Original tool execution handler (async)
|
||||
|
||||
Returns:
|
||||
Command that interrupts execution with the formatted clarification message
|
||||
"""
|
||||
# Check if this is an ask_clarification tool call
|
||||
if request.tool_call.get("name") != "ask_clarification":
|
||||
# Not a clarification call, execute normally
|
||||
return await handler(request)
|
||||
|
||||
return self._handle_clarification(request)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Middleware to fix dangling tool calls in message history.
|
||||
|
||||
A dangling tool call occurs when an AIMessage contains tool_calls but there are
|
||||
no corresponding ToolMessages in the history (e.g., due to user interruption or
|
||||
request cancellation). This causes LLM errors due to incomplete message format.
|
||||
|
||||
This middleware runs before the model call to detect and patch such gaps by
|
||||
inserting synthetic ToolMessages with an error indicator.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Inserts placeholder ToolMessages for dangling tool calls before model invocation.
|
||||
|
||||
Scans the message history for AIMessages whose tool_calls lack corresponding
|
||||
ToolMessages, and injects synthetic error responses so the LLM receives a
|
||||
well-formed conversation.
|
||||
"""
|
||||
|
||||
def _fix_dangling_tool_calls(self, state: AgentState) -> dict | None:
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
# Collect IDs of all existing ToolMessages
|
||||
existing_tool_msg_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
if isinstance(msg, ToolMessage):
|
||||
existing_tool_msg_ids.add(msg.tool_call_id)
|
||||
|
||||
# Find dangling tool calls and build patch messages
|
||||
patches: list[ToolMessage] = []
|
||||
for msg in messages:
|
||||
if getattr(msg, "type", None) != "ai":
|
||||
continue
|
||||
tool_calls = getattr(msg, "tool_calls", None)
|
||||
if not tool_calls:
|
||||
continue
|
||||
for tc in tool_calls:
|
||||
tc_id = tc.get("id")
|
||||
if tc_id and tc_id not in existing_tool_msg_ids:
|
||||
patches.append(
|
||||
ToolMessage(
|
||||
content="[Tool call was interrupted and did not return a result.]",
|
||||
tool_call_id=tc_id,
|
||||
name=tc.get("name", "unknown"),
|
||||
status="error",
|
||||
)
|
||||
)
|
||||
existing_tool_msg_ids.add(tc_id)
|
||||
|
||||
if not patches:
|
||||
return None
|
||||
|
||||
logger.warning(f"Injecting {len(patches)} placeholder ToolMessage(s) for dangling tool calls")
|
||||
return {"messages": patches}
|
||||
|
||||
@override
|
||||
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._fix_dangling_tool_calls(state)
|
||||
|
||||
@override
|
||||
async def abefore_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._fix_dangling_tool_calls(state)
|
||||
107
backend/src/agents/middlewares/memory_middleware.py
Normal file
107
backend/src/agents/middlewares/memory_middleware.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Middleware for memory mechanism."""
|
||||
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from src.agents.memory.queue import get_memory_queue
|
||||
from src.config.memory_config import get_memory_config
|
||||
|
||||
|
||||
class MemoryMiddlewareState(AgentState):
|
||||
"""Compatible with the `ThreadState` schema."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def _filter_messages_for_memory(messages: list[Any]) -> list[Any]:
|
||||
"""Filter messages to keep only user inputs and final assistant responses.
|
||||
|
||||
This filters out:
|
||||
- Tool messages (intermediate tool call results)
|
||||
- AI messages with tool_calls (intermediate steps, not final responses)
|
||||
|
||||
Only keeps:
|
||||
- Human messages (user input)
|
||||
- AI messages without tool_calls (final assistant responses)
|
||||
|
||||
Args:
|
||||
messages: List of all conversation messages.
|
||||
|
||||
Returns:
|
||||
Filtered list containing only user inputs and final assistant responses.
|
||||
"""
|
||||
filtered = []
|
||||
for msg in messages:
|
||||
msg_type = getattr(msg, "type", None)
|
||||
|
||||
if msg_type == "human":
|
||||
# Always keep user messages
|
||||
filtered.append(msg)
|
||||
elif msg_type == "ai":
|
||||
# Only keep AI messages that are final responses (no tool_calls)
|
||||
tool_calls = getattr(msg, "tool_calls", None)
|
||||
if not tool_calls:
|
||||
filtered.append(msg)
|
||||
# Skip tool messages and AI messages with tool_calls
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
||||
"""Middleware that queues conversation for memory update after agent execution.
|
||||
|
||||
This middleware:
|
||||
1. After each agent execution, queues the conversation for memory update
|
||||
2. Only includes user inputs and final assistant responses (ignores tool calls)
|
||||
3. The queue uses debouncing to batch multiple updates together
|
||||
4. Memory is updated asynchronously via LLM summarization
|
||||
"""
|
||||
|
||||
state_schema = MemoryMiddlewareState
|
||||
|
||||
@override
|
||||
def after_agent(self, state: MemoryMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
"""Queue conversation for memory update after agent completes.
|
||||
|
||||
Args:
|
||||
state: The current agent state.
|
||||
runtime: The runtime context.
|
||||
|
||||
Returns:
|
||||
None (no state changes needed from this middleware).
|
||||
"""
|
||||
config = get_memory_config()
|
||||
if not config.enabled:
|
||||
return None
|
||||
|
||||
# Get thread ID from runtime context
|
||||
thread_id = runtime.context.get("thread_id")
|
||||
if not thread_id:
|
||||
print("MemoryMiddleware: No thread_id in context, skipping memory update")
|
||||
return None
|
||||
|
||||
# Get messages from state
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
print("MemoryMiddleware: No messages in state, skipping memory update")
|
||||
return None
|
||||
|
||||
# Filter to only keep user inputs and final assistant responses
|
||||
filtered_messages = _filter_messages_for_memory(messages)
|
||||
|
||||
# Only queue if there's meaningful conversation
|
||||
# At minimum need one user message and one assistant response
|
||||
user_messages = [m for m in filtered_messages if getattr(m, "type", None) == "human"]
|
||||
assistant_messages = [m for m in filtered_messages if getattr(m, "type", None) == "ai"]
|
||||
|
||||
if not user_messages or not assistant_messages:
|
||||
return None
|
||||
|
||||
# Queue the filtered conversation for memory update
|
||||
queue = get_memory_queue()
|
||||
queue.add(thread_id=thread_id, messages=filtered_messages)
|
||||
|
||||
return None
|
||||
75
backend/src/agents/middlewares/subagent_limit_middleware.py
Normal file
75
backend/src/agents/middlewares/subagent_limit_middleware.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Middleware to enforce maximum concurrent subagent tool calls per model response."""
|
||||
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from src.subagents.executor import MAX_CONCURRENT_SUBAGENTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid range for max_concurrent_subagents
|
||||
MIN_SUBAGENT_LIMIT = 2
|
||||
MAX_SUBAGENT_LIMIT = 4
|
||||
|
||||
|
||||
def _clamp_subagent_limit(value: int) -> int:
|
||||
"""Clamp subagent limit to valid range [2, 4]."""
|
||||
return max(MIN_SUBAGENT_LIMIT, min(MAX_SUBAGENT_LIMIT, value))
|
||||
|
||||
|
||||
class SubagentLimitMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Truncates excess 'task' tool calls from a single model response.
|
||||
|
||||
When an LLM generates more than max_concurrent parallel task tool calls
|
||||
in one response, this middleware keeps only the first max_concurrent and
|
||||
discards the rest. This is more reliable than prompt-based limits.
|
||||
|
||||
Args:
|
||||
max_concurrent: Maximum number of concurrent subagent calls allowed.
|
||||
Defaults to MAX_CONCURRENT_SUBAGENTS (3). Clamped to [2, 4].
|
||||
"""
|
||||
|
||||
def __init__(self, max_concurrent: int = MAX_CONCURRENT_SUBAGENTS):
|
||||
super().__init__()
|
||||
self.max_concurrent = _clamp_subagent_limit(max_concurrent)
|
||||
|
||||
def _truncate_task_calls(self, state: AgentState) -> dict | None:
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
last_msg = messages[-1]
|
||||
if getattr(last_msg, "type", None) != "ai":
|
||||
return None
|
||||
|
||||
tool_calls = getattr(last_msg, "tool_calls", None)
|
||||
if not tool_calls:
|
||||
return None
|
||||
|
||||
# Count task tool calls
|
||||
task_indices = [i for i, tc in enumerate(tool_calls) if tc.get("name") == "task"]
|
||||
if len(task_indices) <= self.max_concurrent:
|
||||
return None
|
||||
|
||||
# Build set of indices to drop (excess task calls beyond the limit)
|
||||
indices_to_drop = set(task_indices[self.max_concurrent :])
|
||||
truncated_tool_calls = [tc for i, tc in enumerate(tool_calls) if i not in indices_to_drop]
|
||||
|
||||
dropped_count = len(indices_to_drop)
|
||||
logger.warning(f"Truncated {dropped_count} excess task tool call(s) from model response (limit: {self.max_concurrent})")
|
||||
|
||||
# Replace the AIMessage with truncated tool_calls (same id triggers replacement)
|
||||
updated_msg = last_msg.model_copy(update={"tool_calls": truncated_tool_calls})
|
||||
return {"messages": [updated_msg]}
|
||||
|
||||
@override
|
||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._truncate_task_calls(state)
|
||||
|
||||
@override
|
||||
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._truncate_task_calls(state)
|
||||
95
backend/src/agents/middlewares/thread_data_middleware.py
Normal file
95
backend/src/agents/middlewares/thread_data_middleware.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import NotRequired, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from src.agents.thread_state import ThreadDataState
|
||||
from src.sandbox.consts import THREAD_DATA_BASE_DIR
|
||||
|
||||
|
||||
class ThreadDataMiddlewareState(AgentState):
|
||||
"""Compatible with the `ThreadState` schema."""
|
||||
|
||||
thread_data: NotRequired[ThreadDataState | None]
|
||||
|
||||
|
||||
class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
||||
"""Create thread data directories for each thread execution.
|
||||
|
||||
Creates the following directory structure:
|
||||
- backend/.deer-flow/threads/{thread_id}/user-data/workspace
|
||||
- backend/.deer-flow/threads/{thread_id}/user-data/uploads
|
||||
- backend/.deer-flow/threads/{thread_id}/user-data/outputs
|
||||
|
||||
Lifecycle Management:
|
||||
- With lazy_init=True (default): Only compute paths, directories created on-demand
|
||||
- With lazy_init=False: Eagerly create directories in before_agent()
|
||||
"""
|
||||
|
||||
state_schema = ThreadDataMiddlewareState
|
||||
|
||||
def __init__(self, base_dir: str | None = None, lazy_init: bool = True):
|
||||
"""Initialize the middleware.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for thread data. Defaults to the current working directory.
|
||||
lazy_init: If True, defer directory creation until needed.
|
||||
If False, create directories eagerly in before_agent().
|
||||
Default is True for optimal performance.
|
||||
"""
|
||||
super().__init__()
|
||||
self._base_dir = base_dir or os.getcwd()
|
||||
self._lazy_init = lazy_init
|
||||
|
||||
def _get_thread_paths(self, thread_id: str) -> dict[str, str]:
|
||||
"""Get the paths for a thread's data directories.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
|
||||
Returns:
|
||||
Dictionary with workspace_path, uploads_path, and outputs_path.
|
||||
"""
|
||||
thread_dir = Path(self._base_dir) / THREAD_DATA_BASE_DIR / thread_id / "user-data"
|
||||
return {
|
||||
"workspace_path": str(thread_dir / "workspace"),
|
||||
"uploads_path": str(thread_dir / "uploads"),
|
||||
"outputs_path": str(thread_dir / "outputs"),
|
||||
}
|
||||
|
||||
def _create_thread_directories(self, thread_id: str) -> dict[str, str]:
|
||||
"""Create the thread data directories.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
|
||||
Returns:
|
||||
Dictionary with the created directory paths.
|
||||
"""
|
||||
paths = self._get_thread_paths(thread_id)
|
||||
for path in paths.values():
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return paths
|
||||
|
||||
@override
|
||||
def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
thread_id = runtime.context.get("thread_id")
|
||||
if thread_id is None:
|
||||
raise ValueError("Thread ID is required in the context")
|
||||
|
||||
if self._lazy_init:
|
||||
# Lazy initialization: only compute paths, don't create directories
|
||||
paths = self._get_thread_paths(thread_id)
|
||||
else:
|
||||
# Eager initialization: create directories immediately
|
||||
paths = self._create_thread_directories(thread_id)
|
||||
print(f"Created thread data directories for thread {thread_id}")
|
||||
|
||||
return {
|
||||
"thread_data": {
|
||||
**paths,
|
||||
}
|
||||
}
|
||||
93
backend/src/agents/middlewares/title_middleware.py
Normal file
93
backend/src/agents/middlewares/title_middleware.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Middleware for automatic thread title generation."""
|
||||
|
||||
from typing import NotRequired, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from src.config.title_config import get_title_config
|
||||
from src.models import create_chat_model
|
||||
|
||||
|
||||
class TitleMiddlewareState(AgentState):
|
||||
"""Compatible with the `ThreadState` schema."""
|
||||
|
||||
title: NotRequired[str | None]
|
||||
|
||||
|
||||
class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
"""Automatically generate a title for the thread after the first user message."""
|
||||
|
||||
state_schema = TitleMiddlewareState
|
||||
|
||||
def _should_generate_title(self, state: TitleMiddlewareState) -> bool:
|
||||
"""Check if we should generate a title for this thread."""
|
||||
config = get_title_config()
|
||||
if not config.enabled:
|
||||
return False
|
||||
|
||||
# Check if thread already has a title in state
|
||||
if state.get("title"):
|
||||
return False
|
||||
|
||||
# Check if this is the first turn (has at least one user message and one assistant response)
|
||||
messages = state.get("messages", [])
|
||||
if len(messages) < 2:
|
||||
return False
|
||||
|
||||
# Count user and assistant messages
|
||||
user_messages = [m for m in messages if m.type == "human"]
|
||||
assistant_messages = [m for m in messages if m.type == "ai"]
|
||||
|
||||
# Generate title after first complete exchange
|
||||
return len(user_messages) == 1 and len(assistant_messages) >= 1
|
||||
|
||||
def _generate_title(self, state: TitleMiddlewareState) -> str:
|
||||
"""Generate a concise title based on the conversation."""
|
||||
config = get_title_config()
|
||||
messages = state.get("messages", [])
|
||||
|
||||
# Get first user message and first assistant response
|
||||
user_msg_content = next((m.content for m in messages if m.type == "human"), "")
|
||||
assistant_msg_content = next((m.content for m in messages if m.type == "ai"), "")
|
||||
|
||||
# Ensure content is string (LangChain messages can have list content)
|
||||
user_msg = str(user_msg_content) if user_msg_content else ""
|
||||
assistant_msg = str(assistant_msg_content) if assistant_msg_content else ""
|
||||
|
||||
# Use a lightweight model to generate title
|
||||
model = create_chat_model(thinking_enabled=False)
|
||||
|
||||
prompt = config.prompt_template.format(
|
||||
max_words=config.max_words,
|
||||
user_msg=user_msg[:500],
|
||||
assistant_msg=assistant_msg[:500],
|
||||
)
|
||||
|
||||
try:
|
||||
response = model.invoke(prompt)
|
||||
# Ensure response content is string
|
||||
title_content = str(response.content) if response.content else ""
|
||||
title = title_content.strip().strip('"').strip("'")
|
||||
# Limit to max characters
|
||||
return title[: config.max_chars] if len(title) > config.max_chars else title
|
||||
except Exception as e:
|
||||
print(f"Failed to generate title: {e}")
|
||||
# Fallback: use first part of user message (by character count)
|
||||
fallback_chars = min(config.max_chars, 50) # Use max_chars or 50, whichever is smaller
|
||||
if len(user_msg) > fallback_chars:
|
||||
return user_msg[:fallback_chars].rstrip() + "..."
|
||||
return user_msg if user_msg else "New Conversation"
|
||||
|
||||
@override
|
||||
def after_agent(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
"""Generate and set thread title after the first agent response."""
|
||||
if self._should_generate_title(state):
|
||||
title = self._generate_title(state)
|
||||
print(f"Generated thread title: {title}")
|
||||
|
||||
# Store title in state (will be persisted by checkpointer if configured)
|
||||
return {"title": title}
|
||||
|
||||
return None
|
||||
221
backend/src/agents/middlewares/uploads_middleware.py
Normal file
221
backend/src/agents/middlewares/uploads_middleware.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Middleware to inject uploaded files information into agent context."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import NotRequired, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from src.agents.middlewares.thread_data_middleware import THREAD_DATA_BASE_DIR
|
||||
|
||||
|
||||
class UploadsMiddlewareState(AgentState):
|
||||
"""State schema for uploads middleware."""
|
||||
|
||||
uploaded_files: NotRequired[list[dict] | None]
|
||||
|
||||
|
||||
class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
"""Middleware to inject uploaded files information into the agent context.
|
||||
|
||||
This middleware lists all files in the thread's uploads directory and
|
||||
adds a system message with the file list before the agent processes the request.
|
||||
"""
|
||||
|
||||
state_schema = UploadsMiddlewareState
|
||||
|
||||
def __init__(self, base_dir: str | None = None):
|
||||
"""Initialize the middleware.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for thread data. Defaults to the current working directory.
|
||||
"""
|
||||
super().__init__()
|
||||
self._base_dir = base_dir or os.getcwd()
|
||||
|
||||
def _get_uploads_dir(self, thread_id: str) -> Path:
|
||||
"""Get the uploads directory for a thread.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
|
||||
Returns:
|
||||
Path to the uploads directory.
|
||||
"""
|
||||
return Path(self._base_dir) / THREAD_DATA_BASE_DIR / thread_id / "user-data" / "uploads"
|
||||
|
||||
def _list_newly_uploaded_files(self, thread_id: str, last_message_files: set[str]) -> list[dict]:
|
||||
"""List only newly uploaded files that weren't in the last message.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
last_message_files: Set of filenames that were already shown in previous messages.
|
||||
|
||||
Returns:
|
||||
List of new file information dictionaries.
|
||||
"""
|
||||
uploads_dir = self._get_uploads_dir(thread_id)
|
||||
|
||||
if not uploads_dir.exists():
|
||||
return []
|
||||
|
||||
files = []
|
||||
for file_path in sorted(uploads_dir.iterdir()):
|
||||
if file_path.is_file() and file_path.name not in last_message_files:
|
||||
stat = file_path.stat()
|
||||
files.append(
|
||||
{
|
||||
"filename": file_path.name,
|
||||
"size": stat.st_size,
|
||||
"path": f"/mnt/user-data/uploads/{file_path.name}",
|
||||
"extension": file_path.suffix,
|
||||
}
|
||||
)
|
||||
|
||||
return files
|
||||
|
||||
def _create_files_message(self, files: list[dict]) -> str:
|
||||
"""Create a formatted message listing uploaded files.
|
||||
|
||||
Args:
|
||||
files: List of file information dictionaries.
|
||||
|
||||
Returns:
|
||||
Formatted string listing the files.
|
||||
"""
|
||||
if not files:
|
||||
return "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>"
|
||||
|
||||
lines = ["<uploaded_files>", "The following files have been uploaded and are available for use:", ""]
|
||||
|
||||
for file in files:
|
||||
size_kb = file["size"] / 1024
|
||||
if size_kb < 1024:
|
||||
size_str = f"{size_kb:.1f} KB"
|
||||
else:
|
||||
size_str = f"{size_kb / 1024:.1f} MB"
|
||||
|
||||
lines.append(f"- {file['filename']} ({size_str})")
|
||||
lines.append(f" Path: {file['path']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("You can read these files using the `read_file` tool with the paths shown above.")
|
||||
lines.append("</uploaded_files>")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _extract_files_from_message(self, content: str) -> set[str]:
|
||||
"""Extract filenames from uploaded_files tag in message content.
|
||||
|
||||
Args:
|
||||
content: Message content that may contain <uploaded_files> tag.
|
||||
|
||||
Returns:
|
||||
Set of filenames mentioned in the tag.
|
||||
"""
|
||||
# Match <uploaded_files>...</uploaded_files> tag
|
||||
match = re.search(r"<uploaded_files>([\s\S]*?)</uploaded_files>", content)
|
||||
if not match:
|
||||
return set()
|
||||
|
||||
files_content = match.group(1)
|
||||
|
||||
# Extract filenames from lines like "- filename.ext (size)"
|
||||
# Need to capture everything before the opening parenthesis, including spaces
|
||||
filenames = set()
|
||||
for line in files_content.split("\n"):
|
||||
# Match pattern: - filename with spaces.ext (size)
|
||||
# Changed from [^\s(]+ to [^(]+ to allow spaces in filename
|
||||
file_match = re.match(r"^-\s+(.+?)\s*\(", line.strip())
|
||||
if file_match:
|
||||
filenames.add(file_match.group(1).strip())
|
||||
|
||||
return filenames
|
||||
|
||||
@override
|
||||
def before_agent(self, state: UploadsMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
"""Inject uploaded files information before agent execution.
|
||||
|
||||
Only injects files that weren't already shown in previous messages.
|
||||
Prepends file info to the last human message content.
|
||||
|
||||
Args:
|
||||
state: Current agent state.
|
||||
runtime: Runtime context containing thread_id.
|
||||
|
||||
Returns:
|
||||
State updates including uploaded files list.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
thread_id = runtime.context.get("thread_id")
|
||||
if thread_id is None:
|
||||
return None
|
||||
|
||||
messages = list(state.get("messages", []))
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
# Track all filenames that have been shown in previous messages (EXCEPT the last one)
|
||||
shown_files: set[str] = set()
|
||||
for msg in messages[:-1]: # Scan all messages except the last one
|
||||
if isinstance(msg, HumanMessage):
|
||||
content = msg.content if isinstance(msg.content, str) else ""
|
||||
extracted = self._extract_files_from_message(content)
|
||||
shown_files.update(extracted)
|
||||
if extracted:
|
||||
logger.info(f"Found previously shown files: {extracted}")
|
||||
|
||||
logger.info(f"Total shown files from history: {shown_files}")
|
||||
|
||||
# List only newly uploaded files
|
||||
files = self._list_newly_uploaded_files(thread_id, shown_files)
|
||||
logger.info(f"Newly uploaded files to inject: {[f['filename'] for f in files]}")
|
||||
|
||||
if not files:
|
||||
return None
|
||||
|
||||
# Find the last human message and prepend file info to it
|
||||
last_message_index = len(messages) - 1
|
||||
last_message = messages[last_message_index]
|
||||
|
||||
if not isinstance(last_message, HumanMessage):
|
||||
return None
|
||||
|
||||
# Create files message and prepend to the last human message content
|
||||
files_message = self._create_files_message(files)
|
||||
|
||||
# Extract original content - handle both string and list formats
|
||||
original_content = ""
|
||||
if isinstance(last_message.content, str):
|
||||
original_content = last_message.content
|
||||
elif isinstance(last_message.content, list):
|
||||
# Content is a list of content blocks (e.g., [{"type": "text", "text": "..."}])
|
||||
text_parts = []
|
||||
for block in last_message.content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
original_content = "\n".join(text_parts)
|
||||
|
||||
logger.info(f"Original message content: {original_content[:100] if original_content else '(empty)'}")
|
||||
|
||||
# Create new message with combined content
|
||||
updated_message = HumanMessage(
|
||||
content=f"{files_message}\n\n{original_content}",
|
||||
id=last_message.id,
|
||||
additional_kwargs=last_message.additional_kwargs,
|
||||
)
|
||||
|
||||
# Replace the last message
|
||||
messages[last_message_index] = updated_message
|
||||
|
||||
return {
|
||||
"uploaded_files": files,
|
||||
"messages": messages,
|
||||
}
|
||||
221
backend/src/agents/middlewares/view_image_middleware.py
Normal file
221
backend/src/agents/middlewares/view_image_middleware.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Middleware for injecting image details into conversation before LLM call."""
|
||||
|
||||
from typing import NotRequired, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from src.agents.thread_state import ViewedImageData
|
||||
|
||||
|
||||
class ViewImageMiddlewareState(AgentState):
|
||||
"""Compatible with the `ThreadState` schema."""
|
||||
|
||||
viewed_images: NotRequired[dict[str, ViewedImageData] | None]
|
||||
|
||||
|
||||
class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
|
||||
"""Injects image details as a human message before LLM calls when view_image tools have completed.
|
||||
|
||||
This middleware:
|
||||
1. Runs before each LLM call
|
||||
2. Checks if the last assistant message contains view_image tool calls
|
||||
3. Verifies all tool calls in that message have been completed (have corresponding ToolMessages)
|
||||
4. If conditions are met, creates a human message with all viewed image details (including base64 data)
|
||||
5. Adds the message to state so the LLM can see and analyze the images
|
||||
|
||||
This enables the LLM to automatically receive and analyze images that were loaded via view_image tool,
|
||||
without requiring explicit user prompts to describe the images.
|
||||
"""
|
||||
|
||||
state_schema = ViewImageMiddlewareState
|
||||
|
||||
def _get_last_assistant_message(self, messages: list) -> AIMessage | None:
|
||||
"""Get the last assistant message from the message list.
|
||||
|
||||
Args:
|
||||
messages: List of messages
|
||||
|
||||
Returns:
|
||||
Last AIMessage or None if not found
|
||||
"""
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, AIMessage):
|
||||
return msg
|
||||
return None
|
||||
|
||||
def _has_view_image_tool(self, message: AIMessage) -> bool:
|
||||
"""Check if the assistant message contains view_image tool calls.
|
||||
|
||||
Args:
|
||||
message: Assistant message to check
|
||||
|
||||
Returns:
|
||||
True if message contains view_image tool calls
|
||||
"""
|
||||
if not hasattr(message, "tool_calls") or not message.tool_calls:
|
||||
return False
|
||||
|
||||
return any(tool_call.get("name") == "view_image" for tool_call in message.tool_calls)
|
||||
|
||||
def _all_tools_completed(self, messages: list, assistant_msg: AIMessage) -> bool:
|
||||
"""Check if all tool calls in the assistant message have been completed.
|
||||
|
||||
Args:
|
||||
messages: List of all messages
|
||||
assistant_msg: The assistant message containing tool calls
|
||||
|
||||
Returns:
|
||||
True if all tool calls have corresponding ToolMessages
|
||||
"""
|
||||
if not hasattr(assistant_msg, "tool_calls") or not assistant_msg.tool_calls:
|
||||
return False
|
||||
|
||||
# Get all tool call IDs from the assistant message
|
||||
tool_call_ids = {tool_call.get("id") for tool_call in assistant_msg.tool_calls if tool_call.get("id")}
|
||||
|
||||
# Find the index of the assistant message
|
||||
try:
|
||||
assistant_idx = messages.index(assistant_msg)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# Get all ToolMessages after the assistant message
|
||||
completed_tool_ids = set()
|
||||
for msg in messages[assistant_idx + 1 :]:
|
||||
if isinstance(msg, ToolMessage) and msg.tool_call_id:
|
||||
completed_tool_ids.add(msg.tool_call_id)
|
||||
|
||||
# Check if all tool calls have been completed
|
||||
return tool_call_ids.issubset(completed_tool_ids)
|
||||
|
||||
def _create_image_details_message(self, state: ViewImageMiddlewareState) -> list[str | dict]:
|
||||
"""Create a formatted message with all viewed image details.
|
||||
|
||||
Args:
|
||||
state: Current state containing viewed_images
|
||||
|
||||
Returns:
|
||||
List of content blocks (text and images) for the HumanMessage
|
||||
"""
|
||||
viewed_images = state.get("viewed_images", {})
|
||||
if not viewed_images:
|
||||
return ["No images have been viewed."]
|
||||
|
||||
# Build the message with image information
|
||||
content_blocks: list[str | dict] = [{"type": "text", "text": "Here are the images you've viewed:"}]
|
||||
|
||||
for image_path, image_data in viewed_images.items():
|
||||
mime_type = image_data.get("mime_type", "unknown")
|
||||
base64_data = image_data.get("base64", "")
|
||||
|
||||
# Add text description
|
||||
content_blocks.append({"type": "text", "text": f"\n- **{image_path}** ({mime_type})"})
|
||||
|
||||
# Add the actual image data so LLM can "see" it
|
||||
if base64_data:
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{mime_type};base64,{base64_data}"},
|
||||
}
|
||||
)
|
||||
|
||||
return content_blocks
|
||||
|
||||
def _should_inject_image_message(self, state: ViewImageMiddlewareState) -> bool:
|
||||
"""Determine if we should inject an image details message.
|
||||
|
||||
Args:
|
||||
state: Current state
|
||||
|
||||
Returns:
|
||||
True if we should inject the message
|
||||
"""
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return False
|
||||
|
||||
# Get the last assistant message
|
||||
last_assistant_msg = self._get_last_assistant_message(messages)
|
||||
if not last_assistant_msg:
|
||||
return False
|
||||
|
||||
# Check if it has view_image tool calls
|
||||
if not self._has_view_image_tool(last_assistant_msg):
|
||||
return False
|
||||
|
||||
# Check if all tools have been completed
|
||||
if not self._all_tools_completed(messages, last_assistant_msg):
|
||||
return False
|
||||
|
||||
# Check if we've already added an image details message
|
||||
# Look for a human message after the last assistant message that contains image details
|
||||
assistant_idx = messages.index(last_assistant_msg)
|
||||
for msg in messages[assistant_idx + 1 :]:
|
||||
if isinstance(msg, HumanMessage):
|
||||
content_str = str(msg.content)
|
||||
if "Here are the images you've viewed" in content_str or "Here are the details of the images you've viewed" in content_str:
|
||||
# Already added, don't add again
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _inject_image_message(self, state: ViewImageMiddlewareState) -> dict | None:
|
||||
"""Internal helper to inject image details message.
|
||||
|
||||
Args:
|
||||
state: Current state
|
||||
|
||||
Returns:
|
||||
State update with additional human message, or None if no update needed
|
||||
"""
|
||||
if not self._should_inject_image_message(state):
|
||||
return None
|
||||
|
||||
# Create the image details message with text and image content
|
||||
image_content = self._create_image_details_message(state)
|
||||
|
||||
# Create a new human message with mixed content (text + images)
|
||||
human_msg = HumanMessage(content=image_content)
|
||||
|
||||
print("[ViewImageMiddleware] Injecting image details message with images before LLM call")
|
||||
|
||||
# Return state update with the new message
|
||||
return {"messages": [human_msg]}
|
||||
|
||||
@override
|
||||
def before_model(self, state: ViewImageMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
"""Inject image details message before LLM call if view_image tools have completed (sync version).
|
||||
|
||||
This runs before each LLM call, checking if the previous turn included view_image
|
||||
tool calls that have all completed. If so, it injects a human message with the image
|
||||
details so the LLM can see and analyze the images.
|
||||
|
||||
Args:
|
||||
state: Current state
|
||||
runtime: Runtime context (unused but required by interface)
|
||||
|
||||
Returns:
|
||||
State update with additional human message, or None if no update needed
|
||||
"""
|
||||
return self._inject_image_message(state)
|
||||
|
||||
@override
|
||||
async def abefore_model(self, state: ViewImageMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
"""Inject image details message before LLM call if view_image tools have completed (async version).
|
||||
|
||||
This runs before each LLM call, checking if the previous turn included view_image
|
||||
tool calls that have all completed. If so, it injects a human message with the image
|
||||
details so the LLM can see and analyze the images.
|
||||
|
||||
Args:
|
||||
state: Current state
|
||||
runtime: Runtime context (unused but required by interface)
|
||||
|
||||
Returns:
|
||||
State update with additional human message, or None if no update needed
|
||||
"""
|
||||
return self._inject_image_message(state)
|
||||
55
backend/src/agents/thread_state.py
Normal file
55
backend/src/agents/thread_state.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from typing import Annotated, NotRequired, TypedDict
|
||||
|
||||
from langchain.agents import AgentState
|
||||
|
||||
|
||||
class SandboxState(TypedDict):
|
||||
sandbox_id: NotRequired[str | None]
|
||||
|
||||
|
||||
class ThreadDataState(TypedDict):
|
||||
workspace_path: NotRequired[str | None]
|
||||
uploads_path: NotRequired[str | None]
|
||||
outputs_path: NotRequired[str | None]
|
||||
|
||||
|
||||
class ViewedImageData(TypedDict):
|
||||
base64: str
|
||||
mime_type: str
|
||||
|
||||
|
||||
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
|
||||
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
||||
if existing is None:
|
||||
return new or []
|
||||
if new is None:
|
||||
return existing
|
||||
# Use dict.fromkeys to deduplicate while preserving order
|
||||
return list(dict.fromkeys(existing + new))
|
||||
|
||||
|
||||
def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[str, ViewedImageData] | None) -> dict[str, ViewedImageData]:
|
||||
"""Reducer for viewed_images dict - merges image dictionaries.
|
||||
|
||||
Special case: If new is an empty dict {}, it clears the existing images.
|
||||
This allows middlewares to clear the viewed_images state after processing.
|
||||
"""
|
||||
if existing is None:
|
||||
return new or {}
|
||||
if new is None:
|
||||
return existing
|
||||
# Special case: empty dict means clear all viewed images
|
||||
if len(new) == 0:
|
||||
return {}
|
||||
# Merge dictionaries, new values override existing ones for same keys
|
||||
return {**existing, **new}
|
||||
|
||||
|
||||
class ThreadState(AgentState):
|
||||
sandbox: NotRequired[SandboxState | None]
|
||||
thread_data: NotRequired[ThreadDataState | None]
|
||||
title: NotRequired[str | None]
|
||||
artifacts: Annotated[list[str], merge_artifacts]
|
||||
todos: NotRequired[list | None]
|
||||
uploaded_files: NotRequired[list[dict] | None]
|
||||
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> {base64, mime_type}
|
||||
19
backend/src/community/aio_sandbox/__init__.py
Normal file
19
backend/src/community/aio_sandbox/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from .aio_sandbox import AioSandbox
|
||||
from .aio_sandbox_provider import AioSandboxProvider
|
||||
from .backend import SandboxBackend
|
||||
from .file_state_store import FileSandboxStateStore
|
||||
from .local_backend import LocalContainerBackend
|
||||
from .remote_backend import RemoteSandboxBackend
|
||||
from .sandbox_info import SandboxInfo
|
||||
from .state_store import SandboxStateStore
|
||||
|
||||
__all__ = [
|
||||
"AioSandbox",
|
||||
"AioSandboxProvider",
|
||||
"FileSandboxStateStore",
|
||||
"LocalContainerBackend",
|
||||
"RemoteSandboxBackend",
|
||||
"SandboxBackend",
|
||||
"SandboxInfo",
|
||||
"SandboxStateStore",
|
||||
]
|
||||
128
backend/src/community/aio_sandbox/aio_sandbox.py
Normal file
128
backend/src/community/aio_sandbox/aio_sandbox.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from agent_sandbox import Sandbox as AioSandboxClient
|
||||
|
||||
from src.sandbox.sandbox import Sandbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AioSandbox(Sandbox):
|
||||
"""Sandbox implementation using the agent-infra/sandbox Docker container.
|
||||
|
||||
This sandbox connects to a running AIO sandbox container via HTTP API.
|
||||
"""
|
||||
|
||||
def __init__(self, id: str, base_url: str, home_dir: str | None = None):
|
||||
"""Initialize the AIO sandbox.
|
||||
|
||||
Args:
|
||||
id: Unique identifier for this sandbox instance.
|
||||
base_url: URL of the sandbox API (e.g., http://localhost:8080).
|
||||
home_dir: Home directory inside the sandbox. If None, will be fetched from the sandbox.
|
||||
"""
|
||||
super().__init__(id)
|
||||
self._base_url = base_url
|
||||
self._client = AioSandboxClient(base_url=base_url, timeout=600)
|
||||
self._home_dir = home_dir
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return self._base_url
|
||||
|
||||
@property
|
||||
def home_dir(self) -> str:
|
||||
"""Get the home directory inside the sandbox."""
|
||||
if self._home_dir is None:
|
||||
context = self._client.sandbox.get_context()
|
||||
self._home_dir = context.home_dir
|
||||
return self._home_dir
|
||||
|
||||
def execute_command(self, command: str) -> str:
|
||||
"""Execute a shell command in the sandbox.
|
||||
|
||||
Args:
|
||||
command: The command to execute.
|
||||
|
||||
Returns:
|
||||
The output of the command.
|
||||
"""
|
||||
try:
|
||||
result = self._client.shell.exec_command(command=command)
|
||||
output = result.data.output if result.data else ""
|
||||
return output if output else "(no output)"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to execute command in sandbox: {e}")
|
||||
return f"Error: {e}"
|
||||
|
||||
def read_file(self, path: str) -> str:
|
||||
"""Read the content of a file in the sandbox.
|
||||
|
||||
Args:
|
||||
path: The absolute path of the file to read.
|
||||
|
||||
Returns:
|
||||
The content of the file.
|
||||
"""
|
||||
try:
|
||||
result = self._client.file.read_file(file=path)
|
||||
return result.data.content if result.data else ""
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read file in sandbox: {e}")
|
||||
return f"Error: {e}"
|
||||
|
||||
def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
|
||||
"""List the contents of a directory in the sandbox.
|
||||
|
||||
Args:
|
||||
path: The absolute path of the directory to list.
|
||||
max_depth: The maximum depth to traverse. Default is 2.
|
||||
|
||||
Returns:
|
||||
The contents of the directory.
|
||||
"""
|
||||
try:
|
||||
# Use shell command to list directory with depth limit
|
||||
# The -L flag limits the depth for the tree command
|
||||
result = self._client.shell.exec_command(command=f"find {path} -maxdepth {max_depth} -type f -o -type d 2>/dev/null | head -500")
|
||||
output = result.data.output if result.data else ""
|
||||
if output:
|
||||
return [line.strip() for line in output.strip().split("\n") if line.strip()]
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list directory in sandbox: {e}")
|
||||
return []
|
||||
|
||||
def write_file(self, path: str, content: str, append: bool = False) -> None:
|
||||
"""Write content to a file in the sandbox.
|
||||
|
||||
Args:
|
||||
path: The absolute path of the file to write to.
|
||||
content: The text content to write to the file.
|
||||
append: Whether to append the content to the file.
|
||||
"""
|
||||
try:
|
||||
if append:
|
||||
# Read existing content first and append
|
||||
existing = self.read_file(path)
|
||||
if not existing.startswith("Error:"):
|
||||
content = existing + content
|
||||
self._client.file.write_file(file=path, content=content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write file in sandbox: {e}")
|
||||
raise
|
||||
|
||||
def update_file(self, path: str, content: bytes) -> None:
|
||||
"""Update a file with binary content in the sandbox.
|
||||
|
||||
Args:
|
||||
path: The absolute path of the file to update.
|
||||
content: The binary content to write to the file.
|
||||
"""
|
||||
try:
|
||||
base64_content = base64.b64encode(content).decode("utf-8")
|
||||
self._client.file.write_file(file=path, content=base64_content, encoding="base64")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update file in sandbox: {e}")
|
||||
raise
|
||||
497
backend/src/community/aio_sandbox/aio_sandbox_provider.py
Normal file
497
backend/src/community/aio_sandbox/aio_sandbox_provider.py
Normal file
@@ -0,0 +1,497 @@
|
||||
"""AIO Sandbox Provider — orchestrates sandbox lifecycle with pluggable backends.
|
||||
|
||||
This provider composes two abstractions:
|
||||
- SandboxBackend: how sandboxes are provisioned (local container vs remote/K8s)
|
||||
- SandboxStateStore: how thread→sandbox mappings are persisted (file vs Redis)
|
||||
|
||||
The provider itself handles:
|
||||
- In-process caching for fast repeated access
|
||||
- Thread-safe locking (in-process + cross-process via state store)
|
||||
- Idle timeout management
|
||||
- Graceful shutdown with signal handling
|
||||
- Mount computation (thread-specific, skills)
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from src.config import get_app_config
|
||||
from src.sandbox.consts import THREAD_DATA_BASE_DIR, VIRTUAL_PATH_PREFIX
|
||||
from src.sandbox.sandbox import Sandbox
|
||||
from src.sandbox.sandbox_provider import SandboxProvider
|
||||
|
||||
from .aio_sandbox import AioSandbox
|
||||
from .backend import SandboxBackend, wait_for_sandbox_ready
|
||||
from .file_state_store import FileSandboxStateStore
|
||||
from .local_backend import LocalContainerBackend
|
||||
from .remote_backend import RemoteSandboxBackend
|
||||
from .sandbox_info import SandboxInfo
|
||||
from .state_store import SandboxStateStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_IMAGE = "enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest"
|
||||
DEFAULT_PORT = 8080
|
||||
DEFAULT_CONTAINER_PREFIX = "deer-flow-sandbox"
|
||||
DEFAULT_IDLE_TIMEOUT = 600 # 10 minutes in seconds
|
||||
IDLE_CHECK_INTERVAL = 60 # Check every 60 seconds
|
||||
|
||||
|
||||
class AioSandboxProvider(SandboxProvider):
|
||||
"""Sandbox provider that manages containers running the AIO sandbox.
|
||||
|
||||
Architecture:
|
||||
This provider composes a SandboxBackend (how to provision) and a
|
||||
SandboxStateStore (how to persist state), enabling:
|
||||
- Local Docker/Apple Container mode (auto-start containers)
|
||||
- Remote/K8s mode (connect to pre-existing sandbox URL)
|
||||
- Cross-process consistency via file-based or Redis state stores
|
||||
|
||||
Configuration options in config.yaml under sandbox:
|
||||
use: src.community.aio_sandbox:AioSandboxProvider
|
||||
image: <container image>
|
||||
port: 8080 # Base port for local containers
|
||||
base_url: http://... # If set, uses remote backend (K8s/external)
|
||||
auto_start: true # Whether to auto-start local containers
|
||||
container_prefix: deer-flow-sandbox
|
||||
idle_timeout: 600 # Idle timeout in seconds (0 to disable)
|
||||
mounts: # Volume mounts for local containers
|
||||
- host_path: /path/on/host
|
||||
container_path: /path/in/container
|
||||
read_only: false
|
||||
environment: # Environment variables for containers
|
||||
NODE_ENV: production
|
||||
API_KEY: $MY_API_KEY
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._sandboxes: dict[str, AioSandbox] = {} # sandbox_id -> AioSandbox instance
|
||||
self._sandbox_infos: dict[str, SandboxInfo] = {} # sandbox_id -> SandboxInfo (for destroy)
|
||||
self._thread_sandboxes: dict[str, str] = {} # thread_id -> sandbox_id
|
||||
self._thread_locks: dict[str, threading.Lock] = {} # thread_id -> in-process lock
|
||||
self._last_activity: dict[str, float] = {} # sandbox_id -> last activity timestamp
|
||||
self._shutdown_called = False
|
||||
self._idle_checker_stop = threading.Event()
|
||||
self._idle_checker_thread: threading.Thread | None = None
|
||||
|
||||
self._config = self._load_config()
|
||||
self._backend: SandboxBackend = self._create_backend()
|
||||
self._state_store: SandboxStateStore = self._create_state_store()
|
||||
|
||||
# Register shutdown handler
|
||||
atexit.register(self.shutdown)
|
||||
self._register_signal_handlers()
|
||||
|
||||
# Start idle checker if enabled
|
||||
if self._config.get("idle_timeout", DEFAULT_IDLE_TIMEOUT) > 0:
|
||||
self._start_idle_checker()
|
||||
|
||||
# ── Factory methods ──────────────────────────────────────────────────
|
||||
|
||||
def _create_backend(self) -> SandboxBackend:
|
||||
"""Create the appropriate backend based on configuration.
|
||||
|
||||
Selection logic (checked in order):
|
||||
1. ``provisioner_url`` set → RemoteSandboxBackend (provisioner mode)
|
||||
Provisioner dynamically creates Pods + Services in k3s.
|
||||
2. ``auto_start`` → LocalContainerBackend (Docker / Apple Container)
|
||||
"""
|
||||
provisioner_url = self._config.get("provisioner_url")
|
||||
if provisioner_url:
|
||||
logger.info(f"Using remote sandbox backend with provisioner at {provisioner_url}")
|
||||
return RemoteSandboxBackend(provisioner_url=provisioner_url)
|
||||
|
||||
if not self._config.get("auto_start", True):
|
||||
raise RuntimeError("auto_start is disabled and no base_url is configured")
|
||||
|
||||
logger.info("Using local container sandbox backend")
|
||||
return LocalContainerBackend(
|
||||
image=self._config["image"],
|
||||
base_port=self._config["port"],
|
||||
container_prefix=self._config["container_prefix"],
|
||||
config_mounts=self._config["mounts"],
|
||||
environment=self._config["environment"],
|
||||
)
|
||||
|
||||
def _create_state_store(self) -> SandboxStateStore:
|
||||
"""Create the state store for cross-process sandbox mapping persistence.
|
||||
|
||||
Currently uses file-based store. For distributed multi-host deployments,
|
||||
a Redis-based store can be plugged in here.
|
||||
"""
|
||||
# TODO: Support RedisSandboxStateStore for distributed deployments.
|
||||
# Configuration would be:
|
||||
# sandbox:
|
||||
# state_store: redis
|
||||
# redis_url: redis://localhost:6379/0
|
||||
# This would enable cross-host sandbox discovery (e.g., multiple K8s pods
|
||||
# without shared PVC, or multi-node Docker Swarm).
|
||||
return FileSandboxStateStore(base_dir=os.getcwd())
|
||||
|
||||
# ── Configuration ────────────────────────────────────────────────────
|
||||
|
||||
def _load_config(self) -> dict:
|
||||
"""Load sandbox configuration from app config."""
|
||||
config = get_app_config()
|
||||
sandbox_config = config.sandbox
|
||||
|
||||
return {
|
||||
"image": sandbox_config.image or DEFAULT_IMAGE,
|
||||
"port": sandbox_config.port or DEFAULT_PORT,
|
||||
"base_url": sandbox_config.base_url,
|
||||
"auto_start": sandbox_config.auto_start if sandbox_config.auto_start is not None else True,
|
||||
"container_prefix": sandbox_config.container_prefix or DEFAULT_CONTAINER_PREFIX,
|
||||
"idle_timeout": getattr(sandbox_config, "idle_timeout", None) or DEFAULT_IDLE_TIMEOUT,
|
||||
"mounts": sandbox_config.mounts or [],
|
||||
"environment": self._resolve_env_vars(sandbox_config.environment or {}),
|
||||
# provisioner URL for dynamic pod management (e.g. http://provisioner:8002)
|
||||
"provisioner_url": getattr(sandbox_config, "provisioner_url", None) or "",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_env_vars(env_config: dict[str, str]) -> dict[str, str]:
|
||||
"""Resolve environment variable references (values starting with $)."""
|
||||
resolved = {}
|
||||
for key, value in env_config.items():
|
||||
if isinstance(value, str) and value.startswith("$"):
|
||||
env_name = value[1:]
|
||||
resolved[key] = os.environ.get(env_name, "")
|
||||
else:
|
||||
resolved[key] = str(value)
|
||||
return resolved
|
||||
|
||||
# ── Deterministic ID ─────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _deterministic_sandbox_id(thread_id: str) -> str:
|
||||
"""Generate a deterministic sandbox ID from a thread ID.
|
||||
|
||||
Ensures all processes derive the same sandbox_id for a given thread,
|
||||
enabling cross-process sandbox discovery without shared memory.
|
||||
"""
|
||||
return hashlib.sha256(thread_id.encode()).hexdigest()[:8]
|
||||
|
||||
# ── Mount helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _get_extra_mounts(self, thread_id: str | None) -> list[tuple[str, str, bool]]:
|
||||
"""Collect all extra mounts for a sandbox (thread-specific + skills)."""
|
||||
mounts: list[tuple[str, str, bool]] = []
|
||||
|
||||
if thread_id:
|
||||
mounts.extend(self._get_thread_mounts(thread_id))
|
||||
logger.info(f"Adding thread mounts for thread {thread_id}: {mounts}")
|
||||
|
||||
skills_mount = self._get_skills_mount()
|
||||
if skills_mount:
|
||||
mounts.append(skills_mount)
|
||||
logger.info(f"Adding skills mount: {skills_mount}")
|
||||
|
||||
return mounts
|
||||
|
||||
@staticmethod
|
||||
def _get_thread_mounts(thread_id: str) -> list[tuple[str, str, bool]]:
|
||||
"""Get volume mounts for a thread's data directories.
|
||||
|
||||
Creates directories if they don't exist (lazy initialization).
|
||||
"""
|
||||
base_dir = os.getcwd()
|
||||
thread_dir = Path(base_dir) / THREAD_DATA_BASE_DIR / thread_id / "user-data"
|
||||
|
||||
mounts = [
|
||||
(str(thread_dir / "workspace"), f"{VIRTUAL_PATH_PREFIX}/workspace", False),
|
||||
(str(thread_dir / "uploads"), f"{VIRTUAL_PATH_PREFIX}/uploads", False),
|
||||
(str(thread_dir / "outputs"), f"{VIRTUAL_PATH_PREFIX}/outputs", False),
|
||||
]
|
||||
|
||||
for host_path, _, _ in mounts:
|
||||
os.makedirs(host_path, exist_ok=True)
|
||||
|
||||
return mounts
|
||||
|
||||
@staticmethod
|
||||
def _get_skills_mount() -> tuple[str, str, bool] | None:
|
||||
"""Get the skills directory mount configuration."""
|
||||
try:
|
||||
config = get_app_config()
|
||||
skills_path = config.skills.get_skills_path()
|
||||
container_path = config.skills.container_path
|
||||
|
||||
if skills_path.exists():
|
||||
return (str(skills_path), container_path, True) # Read-only for security
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not setup skills mount: {e}")
|
||||
return None
|
||||
|
||||
# ── Idle timeout management ──────────────────────────────────────────
|
||||
|
||||
def _start_idle_checker(self) -> None:
|
||||
"""Start the background thread that checks for idle sandboxes."""
|
||||
self._idle_checker_thread = threading.Thread(
|
||||
target=self._idle_checker_loop,
|
||||
name="sandbox-idle-checker",
|
||||
daemon=True,
|
||||
)
|
||||
self._idle_checker_thread.start()
|
||||
logger.info(f"Started idle checker thread (timeout: {self._config.get('idle_timeout', DEFAULT_IDLE_TIMEOUT)}s)")
|
||||
|
||||
def _idle_checker_loop(self) -> None:
|
||||
idle_timeout = self._config.get("idle_timeout", DEFAULT_IDLE_TIMEOUT)
|
||||
while not self._idle_checker_stop.wait(timeout=IDLE_CHECK_INTERVAL):
|
||||
try:
|
||||
self._cleanup_idle_sandboxes(idle_timeout)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in idle checker loop: {e}")
|
||||
|
||||
def _cleanup_idle_sandboxes(self, idle_timeout: float) -> None:
|
||||
current_time = time.time()
|
||||
sandboxes_to_release = []
|
||||
|
||||
with self._lock:
|
||||
for sandbox_id, last_activity in self._last_activity.items():
|
||||
idle_duration = current_time - last_activity
|
||||
if idle_duration > idle_timeout:
|
||||
sandboxes_to_release.append(sandbox_id)
|
||||
logger.info(f"Sandbox {sandbox_id} idle for {idle_duration:.1f}s, marking for release")
|
||||
|
||||
for sandbox_id in sandboxes_to_release:
|
||||
try:
|
||||
logger.info(f"Releasing idle sandbox {sandbox_id}")
|
||||
self.release(sandbox_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to release idle sandbox {sandbox_id}: {e}")
|
||||
|
||||
# ── Signal handling ──────────────────────────────────────────────────
|
||||
|
||||
def _register_signal_handlers(self) -> None:
|
||||
"""Register signal handlers for graceful shutdown."""
|
||||
self._original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||
self._original_sigint = signal.getsignal(signal.SIGINT)
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
self.shutdown()
|
||||
original = self._original_sigterm if signum == signal.SIGTERM else self._original_sigint
|
||||
if callable(original):
|
||||
original(signum, frame)
|
||||
elif original == signal.SIG_DFL:
|
||||
signal.signal(signum, signal.SIG_DFL)
|
||||
signal.raise_signal(signum)
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
except ValueError:
|
||||
logger.debug("Could not register signal handlers (not main thread)")
|
||||
|
||||
# ── Thread locking (in-process) ──────────────────────────────────────
|
||||
|
||||
def _get_thread_lock(self, thread_id: str) -> threading.Lock:
|
||||
"""Get or create an in-process lock for a specific thread_id."""
|
||||
with self._lock:
|
||||
if thread_id not in self._thread_locks:
|
||||
self._thread_locks[thread_id] = threading.Lock()
|
||||
return self._thread_locks[thread_id]
|
||||
|
||||
# ── Core: acquire / get / release / shutdown ─────────────────────────
|
||||
|
||||
def acquire(self, thread_id: str | None = None) -> str:
|
||||
"""Acquire a sandbox environment and return its ID.
|
||||
|
||||
For the same thread_id, this method will return the same sandbox_id
|
||||
across multiple turns, multiple processes, and (with shared storage)
|
||||
multiple pods.
|
||||
|
||||
Thread-safe with both in-process and cross-process locking.
|
||||
|
||||
Args:
|
||||
thread_id: Optional thread ID for thread-specific configurations.
|
||||
|
||||
Returns:
|
||||
The ID of the acquired sandbox environment.
|
||||
"""
|
||||
if thread_id:
|
||||
thread_lock = self._get_thread_lock(thread_id)
|
||||
with thread_lock:
|
||||
return self._acquire_internal(thread_id)
|
||||
else:
|
||||
return self._acquire_internal(thread_id)
|
||||
|
||||
def _acquire_internal(self, thread_id: str | None) -> str:
|
||||
"""Internal sandbox acquisition with three-layer consistency.
|
||||
|
||||
Layer 1: In-process cache (fastest, covers same-process repeated access)
|
||||
Layer 2: Cross-process state store + file lock (covers multi-process)
|
||||
Layer 3: Backend discovery (covers containers started by other processes)
|
||||
"""
|
||||
# ── Layer 1: In-process cache (fast path) ──
|
||||
if thread_id:
|
||||
with self._lock:
|
||||
if thread_id in self._thread_sandboxes:
|
||||
existing_id = self._thread_sandboxes[thread_id]
|
||||
if existing_id in self._sandboxes:
|
||||
logger.info(f"Reusing in-process sandbox {existing_id} for thread {thread_id}")
|
||||
self._last_activity[existing_id] = time.time()
|
||||
return existing_id
|
||||
else:
|
||||
del self._thread_sandboxes[thread_id]
|
||||
|
||||
# Deterministic ID for thread-specific, random for anonymous
|
||||
sandbox_id = self._deterministic_sandbox_id(thread_id) if thread_id else str(uuid.uuid4())[:8]
|
||||
|
||||
# ── Layer 2 & 3: Cross-process recovery + creation ──
|
||||
if thread_id:
|
||||
with self._state_store.lock(thread_id):
|
||||
# Try to recover from persisted state or discover existing container
|
||||
recovered_id = self._try_recover(thread_id)
|
||||
if recovered_id is not None:
|
||||
return recovered_id
|
||||
# Nothing to recover — create new sandbox (still under cross-process lock)
|
||||
return self._create_sandbox(thread_id, sandbox_id)
|
||||
else:
|
||||
return self._create_sandbox(thread_id, sandbox_id)
|
||||
|
||||
def _try_recover(self, thread_id: str) -> str | None:
|
||||
"""Try to recover a sandbox from persisted state or backend discovery.
|
||||
|
||||
Called under cross-process lock for the given thread_id.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
|
||||
Returns:
|
||||
The sandbox_id if recovery succeeded, None otherwise.
|
||||
"""
|
||||
info = self._state_store.load(thread_id)
|
||||
if info is None:
|
||||
return None
|
||||
|
||||
# Re-discover: verifies sandbox is alive and gets current connection info
|
||||
# (handles cases like port changes after container restart)
|
||||
discovered = self._backend.discover(info.sandbox_id)
|
||||
if discovered is None:
|
||||
logger.info(f"Persisted sandbox {info.sandbox_id} for thread {thread_id} could not be recovered")
|
||||
self._state_store.remove(thread_id)
|
||||
return None
|
||||
|
||||
# Adopt into this process's memory
|
||||
sandbox = AioSandbox(id=discovered.sandbox_id, base_url=discovered.sandbox_url)
|
||||
with self._lock:
|
||||
self._sandboxes[discovered.sandbox_id] = sandbox
|
||||
self._sandbox_infos[discovered.sandbox_id] = discovered
|
||||
self._last_activity[discovered.sandbox_id] = time.time()
|
||||
self._thread_sandboxes[thread_id] = discovered.sandbox_id
|
||||
|
||||
# Update state if connection info changed
|
||||
if discovered.sandbox_url != info.sandbox_url:
|
||||
self._state_store.save(thread_id, discovered)
|
||||
|
||||
logger.info(f"Recovered sandbox {discovered.sandbox_id} for thread {thread_id} at {discovered.sandbox_url}")
|
||||
return discovered.sandbox_id
|
||||
|
||||
def _create_sandbox(self, thread_id: str | None, sandbox_id: str) -> str:
|
||||
"""Create a new sandbox via the backend.
|
||||
|
||||
Args:
|
||||
thread_id: Optional thread ID.
|
||||
sandbox_id: The sandbox ID to use.
|
||||
|
||||
Returns:
|
||||
The sandbox_id.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If sandbox creation or readiness check fails.
|
||||
"""
|
||||
extra_mounts = self._get_extra_mounts(thread_id)
|
||||
|
||||
info = self._backend.create(thread_id, sandbox_id, extra_mounts=extra_mounts or None)
|
||||
|
||||
# Wait for sandbox to be ready
|
||||
if not wait_for_sandbox_ready(info.sandbox_url, timeout=60):
|
||||
self._backend.destroy(info)
|
||||
raise RuntimeError(f"Sandbox {sandbox_id} failed to become ready within timeout at {info.sandbox_url}")
|
||||
|
||||
sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url)
|
||||
with self._lock:
|
||||
self._sandboxes[sandbox_id] = sandbox
|
||||
self._sandbox_infos[sandbox_id] = info
|
||||
self._last_activity[sandbox_id] = time.time()
|
||||
if thread_id:
|
||||
self._thread_sandboxes[thread_id] = sandbox_id
|
||||
|
||||
# Persist for cross-process discovery
|
||||
if thread_id:
|
||||
self._state_store.save(thread_id, info)
|
||||
|
||||
logger.info(f"Created sandbox {sandbox_id} for thread {thread_id} at {info.sandbox_url}")
|
||||
return sandbox_id
|
||||
|
||||
def get(self, sandbox_id: str) -> Sandbox | None:
|
||||
"""Get a sandbox by ID. Updates last activity timestamp.
|
||||
|
||||
Args:
|
||||
sandbox_id: The ID of the sandbox.
|
||||
|
||||
Returns:
|
||||
The sandbox instance if found, None otherwise.
|
||||
"""
|
||||
with self._lock:
|
||||
sandbox = self._sandboxes.get(sandbox_id)
|
||||
if sandbox is not None:
|
||||
self._last_activity[sandbox_id] = time.time()
|
||||
return sandbox
|
||||
|
||||
def release(self, sandbox_id: str) -> None:
|
||||
"""Release a sandbox: clean up in-memory state, persisted state, and backend resources.
|
||||
|
||||
Args:
|
||||
sandbox_id: The ID of the sandbox to release.
|
||||
"""
|
||||
info = None
|
||||
thread_ids_to_remove: list[str] = []
|
||||
|
||||
with self._lock:
|
||||
self._sandboxes.pop(sandbox_id, None)
|
||||
info = self._sandbox_infos.pop(sandbox_id, None)
|
||||
thread_ids_to_remove = [tid for tid, sid in self._thread_sandboxes.items() if sid == sandbox_id]
|
||||
for tid in thread_ids_to_remove:
|
||||
del self._thread_sandboxes[tid]
|
||||
self._last_activity.pop(sandbox_id, None)
|
||||
|
||||
# Clean up persisted state (outside lock, involves file I/O)
|
||||
for tid in thread_ids_to_remove:
|
||||
self._state_store.remove(tid)
|
||||
|
||||
# Destroy backend resources (stop container, release port, etc.)
|
||||
if info:
|
||||
self._backend.destroy(info)
|
||||
logger.info(f"Released sandbox {sandbox_id}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shutdown all sandboxes. Thread-safe and idempotent."""
|
||||
with self._lock:
|
||||
if self._shutdown_called:
|
||||
return
|
||||
self._shutdown_called = True
|
||||
sandbox_ids = list(self._sandboxes.keys())
|
||||
|
||||
# Stop idle checker
|
||||
self._idle_checker_stop.set()
|
||||
if self._idle_checker_thread is not None and self._idle_checker_thread.is_alive():
|
||||
self._idle_checker_thread.join(timeout=5)
|
||||
logger.info("Stopped idle checker thread")
|
||||
|
||||
logger.info(f"Shutting down {len(sandbox_ids)} sandbox(es)")
|
||||
|
||||
for sandbox_id in sandbox_ids:
|
||||
try:
|
||||
self.release(sandbox_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to release sandbox {sandbox_id} during shutdown: {e}")
|
||||
98
backend/src/community/aio_sandbox/backend.py
Normal file
98
backend/src/community/aio_sandbox/backend.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Abstract base class for sandbox provisioning backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import requests
|
||||
|
||||
from .sandbox_info import SandboxInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def wait_for_sandbox_ready(sandbox_url: str, timeout: int = 30) -> bool:
|
||||
"""Poll sandbox health endpoint until ready or timeout.
|
||||
|
||||
Args:
|
||||
sandbox_url: URL of the sandbox (e.g. http://k3s:30001).
|
||||
timeout: Maximum time to wait in seconds.
|
||||
|
||||
Returns:
|
||||
True if sandbox is ready, False otherwise.
|
||||
"""
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
response = requests.get(f"{sandbox_url}/v1/sandbox", timeout=5)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
class SandboxBackend(ABC):
|
||||
"""Abstract base for sandbox provisioning backends.
|
||||
|
||||
Two implementations:
|
||||
- LocalContainerBackend: starts Docker/Apple Container locally, manages ports
|
||||
- RemoteSandboxBackend: connects to a pre-existing URL (K8s service, external)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create(self, thread_id: str, sandbox_id: str, extra_mounts: list[tuple[str, str, bool]] | None = None) -> SandboxInfo:
|
||||
"""Create/provision a new sandbox.
|
||||
|
||||
Args:
|
||||
thread_id: Thread ID for which the sandbox is being created. Useful for backends that want to organize sandboxes by thread.
|
||||
sandbox_id: Deterministic sandbox identifier.
|
||||
extra_mounts: Additional volume mounts as (host_path, container_path, read_only) tuples.
|
||||
Ignored by backends that don't manage containers (e.g., remote).
|
||||
|
||||
Returns:
|
||||
SandboxInfo with connection details.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def destroy(self, info: SandboxInfo) -> None:
|
||||
"""Destroy/cleanup a sandbox and release its resources.
|
||||
|
||||
Args:
|
||||
info: The sandbox metadata to destroy.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def is_alive(self, info: SandboxInfo) -> bool:
|
||||
"""Quick check whether a sandbox is still alive.
|
||||
|
||||
This should be a lightweight check (e.g., container inspect)
|
||||
rather than a full health check.
|
||||
|
||||
Args:
|
||||
info: The sandbox metadata to check.
|
||||
|
||||
Returns:
|
||||
True if the sandbox appears to be alive.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def discover(self, sandbox_id: str) -> SandboxInfo | None:
|
||||
"""Try to discover an existing sandbox by its deterministic ID.
|
||||
|
||||
Used for cross-process recovery: when another process started a sandbox,
|
||||
this process can discover it by the deterministic container name or URL.
|
||||
|
||||
Args:
|
||||
sandbox_id: The deterministic sandbox ID to look for.
|
||||
|
||||
Returns:
|
||||
SandboxInfo if found and healthy, None otherwise.
|
||||
"""
|
||||
...
|
||||
102
backend/src/community/aio_sandbox/file_state_store.py
Normal file
102
backend/src/community/aio_sandbox/file_state_store.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""File-based sandbox state store.
|
||||
|
||||
Uses JSON files for persistence and fcntl file locking for cross-process
|
||||
mutual exclusion. Works across processes on the same machine or across
|
||||
K8s pods with a shared PVC mount.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from .sandbox_info import SandboxInfo
|
||||
from .state_store import SandboxStateStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SANDBOX_STATE_FILE = "sandbox.json"
|
||||
SANDBOX_LOCK_FILE = "sandbox.lock"
|
||||
|
||||
|
||||
class FileSandboxStateStore(SandboxStateStore):
|
||||
"""File-based state store using JSON files and fcntl file locking.
|
||||
|
||||
State is stored at: {base_dir}/{threads_subdir}/{thread_id}/sandbox.json
|
||||
Lock files at: {base_dir}/{threads_subdir}/{thread_id}/sandbox.lock
|
||||
|
||||
This works across processes on the same machine sharing a filesystem.
|
||||
For K8s multi-pod scenarios, requires a shared PVC mount at base_dir.
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: str, threads_subdir: str = ".deer-flow/threads"):
|
||||
"""Initialize the file-based state store.
|
||||
|
||||
Args:
|
||||
base_dir: Root directory for state files (typically the project root / cwd).
|
||||
threads_subdir: Subdirectory path for thread state (default: ".deer-flow/threads").
|
||||
"""
|
||||
self._base_dir = Path(base_dir)
|
||||
self._threads_subdir = threads_subdir
|
||||
|
||||
def _thread_dir(self, thread_id: str) -> Path:
|
||||
"""Get the directory for a thread's state files."""
|
||||
return self._base_dir / self._threads_subdir / thread_id
|
||||
|
||||
def save(self, thread_id: str, info: SandboxInfo) -> None:
|
||||
thread_dir = self._thread_dir(thread_id)
|
||||
os.makedirs(thread_dir, exist_ok=True)
|
||||
state_file = thread_dir / SANDBOX_STATE_FILE
|
||||
try:
|
||||
state_file.write_text(json.dumps(info.to_dict()))
|
||||
logger.info(f"Saved sandbox state for thread {thread_id}: {info.sandbox_id}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to save sandbox state for thread {thread_id}: {e}")
|
||||
|
||||
def load(self, thread_id: str) -> SandboxInfo | None:
|
||||
state_file = self._thread_dir(thread_id) / SANDBOX_STATE_FILE
|
||||
if not state_file.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(state_file.read_text())
|
||||
return SandboxInfo.from_dict(data)
|
||||
except (OSError, json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning(f"Failed to load sandbox state for thread {thread_id}: {e}")
|
||||
return None
|
||||
|
||||
def remove(self, thread_id: str) -> None:
|
||||
state_file = self._thread_dir(thread_id) / SANDBOX_STATE_FILE
|
||||
try:
|
||||
if state_file.exists():
|
||||
state_file.unlink()
|
||||
logger.info(f"Removed sandbox state for thread {thread_id}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to remove sandbox state for thread {thread_id}: {e}")
|
||||
|
||||
@contextmanager
|
||||
def lock(self, thread_id: str) -> Generator[None, None, None]:
|
||||
"""Acquire a cross-process file lock using fcntl.flock.
|
||||
|
||||
The lock is held for the duration of the context manager.
|
||||
Only one process can hold the lock at a time for a given thread_id.
|
||||
|
||||
Note: fcntl.flock is available on macOS and Linux.
|
||||
"""
|
||||
thread_dir = self._thread_dir(thread_id)
|
||||
os.makedirs(thread_dir, exist_ok=True)
|
||||
lock_path = thread_dir / SANDBOX_LOCK_FILE
|
||||
lock_file = open(lock_path, "w")
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
lock_file.close()
|
||||
except OSError:
|
||||
pass
|
||||
294
backend/src/community/aio_sandbox/local_backend.py
Normal file
294
backend/src/community/aio_sandbox/local_backend.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Local container backend for sandbox provisioning.
|
||||
|
||||
Manages sandbox containers using Docker or Apple Container on the local machine.
|
||||
Handles container lifecycle, port allocation, and cross-process container discovery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from src.utils.network import get_free_port, release_port
|
||||
|
||||
from .backend import SandboxBackend, wait_for_sandbox_ready
|
||||
from .sandbox_info import SandboxInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalContainerBackend(SandboxBackend):
|
||||
"""Backend that manages sandbox containers locally using Docker or Apple Container.
|
||||
|
||||
On macOS, automatically prefers Apple Container if available, otherwise falls back to Docker.
|
||||
On other platforms, uses Docker.
|
||||
|
||||
Features:
|
||||
- Deterministic container naming for cross-process discovery
|
||||
- Port allocation with thread-safe utilities
|
||||
- Container lifecycle management (start/stop with --rm)
|
||||
- Support for volume mounts and environment variables
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
image: str,
|
||||
base_port: int,
|
||||
container_prefix: str,
|
||||
config_mounts: list,
|
||||
environment: dict[str, str],
|
||||
):
|
||||
"""Initialize the local container backend.
|
||||
|
||||
Args:
|
||||
image: Container image to use.
|
||||
base_port: Base port number to start searching for free ports.
|
||||
container_prefix: Prefix for container names (e.g., "deer-flow-sandbox").
|
||||
config_mounts: Volume mount configurations from config (list of VolumeMountConfig).
|
||||
environment: Environment variables to inject into containers.
|
||||
"""
|
||||
self._image = image
|
||||
self._base_port = base_port
|
||||
self._container_prefix = container_prefix
|
||||
self._config_mounts = config_mounts
|
||||
self._environment = environment
|
||||
self._runtime = self._detect_runtime()
|
||||
|
||||
@property
|
||||
def runtime(self) -> str:
|
||||
"""The detected container runtime ("docker" or "container")."""
|
||||
return self._runtime
|
||||
|
||||
def _detect_runtime(self) -> str:
|
||||
"""Detect which container runtime to use.
|
||||
|
||||
On macOS, prefer Apple Container if available, otherwise fall back to Docker.
|
||||
On other platforms, use Docker.
|
||||
|
||||
Returns:
|
||||
"container" for Apple Container, "docker" for Docker.
|
||||
"""
|
||||
import platform
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["container", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=5,
|
||||
)
|
||||
logger.info(f"Detected Apple Container: {result.stdout.strip()}")
|
||||
return "container"
|
||||
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
logger.info("Apple Container not available, falling back to Docker")
|
||||
|
||||
return "docker"
|
||||
|
||||
# ── SandboxBackend interface ──────────────────────────────────────────
|
||||
|
||||
def create(self, thread_id: str, sandbox_id: str, extra_mounts: list[tuple[str, str, bool]] | None = None) -> SandboxInfo:
|
||||
"""Start a new container and return its connection info.
|
||||
|
||||
Args:
|
||||
thread_id: Thread ID for which the sandbox is being created. Useful for backends that want to organize sandboxes by thread.
|
||||
sandbox_id: Deterministic sandbox identifier (used in container name).
|
||||
extra_mounts: Additional volume mounts as (host_path, container_path, read_only) tuples.
|
||||
|
||||
Returns:
|
||||
SandboxInfo with container details.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the container fails to start.
|
||||
"""
|
||||
container_name = f"{self._container_prefix}-{sandbox_id}"
|
||||
port = get_free_port(start_port=self._base_port)
|
||||
try:
|
||||
container_id = self._start_container(container_name, port, extra_mounts)
|
||||
except Exception:
|
||||
release_port(port)
|
||||
raise
|
||||
|
||||
return SandboxInfo(
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url=f"http://localhost:{port}",
|
||||
container_name=container_name,
|
||||
container_id=container_id,
|
||||
)
|
||||
|
||||
def destroy(self, info: SandboxInfo) -> None:
|
||||
"""Stop the container and release its port."""
|
||||
if info.container_id:
|
||||
self._stop_container(info.container_id)
|
||||
# Extract port from sandbox_url for release
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
port = urlparse(info.sandbox_url).port
|
||||
if port:
|
||||
release_port(port)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_alive(self, info: SandboxInfo) -> bool:
|
||||
"""Check if the container is still running (lightweight, no HTTP)."""
|
||||
if info.container_name:
|
||||
return self._is_container_running(info.container_name)
|
||||
return False
|
||||
|
||||
def discover(self, sandbox_id: str) -> SandboxInfo | None:
|
||||
"""Discover an existing container by its deterministic name.
|
||||
|
||||
Checks if a container with the expected name is running, retrieves its
|
||||
port, and verifies it responds to health checks.
|
||||
|
||||
Args:
|
||||
sandbox_id: The deterministic sandbox ID (determines container name).
|
||||
|
||||
Returns:
|
||||
SandboxInfo if container found and healthy, None otherwise.
|
||||
"""
|
||||
container_name = f"{self._container_prefix}-{sandbox_id}"
|
||||
|
||||
if not self._is_container_running(container_name):
|
||||
return None
|
||||
|
||||
port = self._get_container_port(container_name)
|
||||
if port is None:
|
||||
return None
|
||||
|
||||
sandbox_url = f"http://localhost:{port}"
|
||||
if not wait_for_sandbox_ready(sandbox_url, timeout=5):
|
||||
return None
|
||||
|
||||
return SandboxInfo(
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url=sandbox_url,
|
||||
container_name=container_name,
|
||||
)
|
||||
|
||||
# ── Container operations ─────────────────────────────────────────────
|
||||
|
||||
def _start_container(
|
||||
self,
|
||||
container_name: str,
|
||||
port: int,
|
||||
extra_mounts: list[tuple[str, str, bool]] | None = None,
|
||||
) -> str:
|
||||
"""Start a new container.
|
||||
|
||||
Args:
|
||||
container_name: Name for the container.
|
||||
port: Host port to map to container port 8080.
|
||||
extra_mounts: Additional volume mounts.
|
||||
|
||||
Returns:
|
||||
The container ID.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If container fails to start.
|
||||
"""
|
||||
cmd = [self._runtime, "run"]
|
||||
|
||||
# Docker-specific security options
|
||||
if self._runtime == "docker":
|
||||
cmd.extend(["--security-opt", "seccomp=unconfined"])
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"--rm",
|
||||
"-d",
|
||||
"-p",
|
||||
f"{port}:8080",
|
||||
"--name",
|
||||
container_name,
|
||||
]
|
||||
)
|
||||
|
||||
# Environment variables
|
||||
for key, value in self._environment.items():
|
||||
cmd.extend(["-e", f"{key}={value}"])
|
||||
|
||||
# Config-level volume mounts
|
||||
for mount in self._config_mounts:
|
||||
mount_spec = f"{mount.host_path}:{mount.container_path}"
|
||||
if mount.read_only:
|
||||
mount_spec += ":ro"
|
||||
cmd.extend(["-v", mount_spec])
|
||||
|
||||
# Extra mounts (thread-specific, skills, etc.)
|
||||
if extra_mounts:
|
||||
for host_path, container_path, read_only in extra_mounts:
|
||||
mount_spec = f"{host_path}:{container_path}"
|
||||
if read_only:
|
||||
mount_spec += ":ro"
|
||||
cmd.extend(["-v", mount_spec])
|
||||
|
||||
cmd.append(self._image)
|
||||
|
||||
logger.info(f"Starting container using {self._runtime}: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
container_id = result.stdout.strip()
|
||||
logger.info(f"Started container {container_name} (ID: {container_id}) using {self._runtime}")
|
||||
return container_id
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Failed to start container using {self._runtime}: {e.stderr}")
|
||||
raise RuntimeError(f"Failed to start sandbox container: {e.stderr}")
|
||||
|
||||
def _stop_container(self, container_id: str) -> None:
|
||||
"""Stop a container (--rm ensures automatic removal)."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[self._runtime, "stop", container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
logger.info(f"Stopped container {container_id} using {self._runtime}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning(f"Failed to stop container {container_id}: {e.stderr}")
|
||||
|
||||
def _is_container_running(self, container_name: str) -> bool:
|
||||
"""Check if a named container is currently running.
|
||||
|
||||
This enables cross-process container discovery — any process can detect
|
||||
containers started by another process via the deterministic container name.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self._runtime, "inspect", "-f", "{{.State.Running}}", container_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0 and result.stdout.strip().lower() == "true"
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
def _get_container_port(self, container_name: str) -> int | None:
|
||||
"""Get the host port of a running container.
|
||||
|
||||
Args:
|
||||
container_name: The container name to inspect.
|
||||
|
||||
Returns:
|
||||
The host port mapped to container port 8080, or None if not found.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self._runtime, "port", container_name, "8080"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
# Output format: "0.0.0.0:PORT" or ":::PORT"
|
||||
port_str = result.stdout.strip().split(":")[-1]
|
||||
return int(port_str)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError):
|
||||
pass
|
||||
return None
|
||||
157
backend/src/community/aio_sandbox/remote_backend.py
Normal file
157
backend/src/community/aio_sandbox/remote_backend.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Remote sandbox backend — delegates Pod lifecycle to the provisioner service.
|
||||
|
||||
The provisioner dynamically creates per-sandbox-id Pods + NodePort Services
|
||||
in k3s. The backend accesses sandbox pods directly via ``k3s:{NodePort}``.
|
||||
|
||||
Architecture:
|
||||
┌────────────┐ HTTP ┌─────────────┐ K8s API ┌──────────┐
|
||||
│ this file │ ──────▸ │ provisioner │ ────────▸ │ k3s │
|
||||
│ (backend) │ │ :8002 │ │ :6443 │
|
||||
└────────────┘ └─────────────┘ └─────┬────┘
|
||||
│ creates
|
||||
┌─────────────┐ ┌─────▼──────┐
|
||||
│ backend │ ────────▸ │ sandbox │
|
||||
│ │ direct │ Pod(s) │
|
||||
└─────────────┘ k3s:NPort └────────────┘
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
from .backend import SandboxBackend
|
||||
from .sandbox_info import SandboxInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemoteSandboxBackend(SandboxBackend):
|
||||
"""Backend that delegates sandbox lifecycle to the provisioner service.
|
||||
|
||||
All Pod creation, destruction, and discovery are handled by the
|
||||
provisioner. This backend is a thin HTTP client.
|
||||
|
||||
Typical config.yaml::
|
||||
|
||||
sandbox:
|
||||
use: src.community.aio_sandbox:AioSandboxProvider
|
||||
provisioner_url: http://provisioner:8002
|
||||
"""
|
||||
|
||||
def __init__(self, provisioner_url: str):
|
||||
"""Initialize with the provisioner service URL.
|
||||
|
||||
Args:
|
||||
provisioner_url: URL of the provisioner service
|
||||
(e.g., ``http://provisioner:8002``).
|
||||
"""
|
||||
self._provisioner_url = provisioner_url.rstrip("/")
|
||||
|
||||
@property
|
||||
def provisioner_url(self) -> str:
|
||||
return self._provisioner_url
|
||||
|
||||
# ── SandboxBackend interface ──────────────────────────────────────────
|
||||
|
||||
def create(
|
||||
self,
|
||||
thread_id: str,
|
||||
sandbox_id: str,
|
||||
extra_mounts: list[tuple[str, str, bool]] | None = None,
|
||||
) -> SandboxInfo:
|
||||
"""Create a sandbox Pod + Service via the provisioner.
|
||||
|
||||
Calls ``POST /api/sandboxes`` which creates a dedicated Pod +
|
||||
NodePort Service in k3s.
|
||||
"""
|
||||
return self._provisioner_create(thread_id, sandbox_id, extra_mounts)
|
||||
|
||||
def destroy(self, info: SandboxInfo) -> None:
|
||||
"""Destroy a sandbox Pod + Service via the provisioner."""
|
||||
self._provisioner_destroy(info.sandbox_id)
|
||||
|
||||
def is_alive(self, info: SandboxInfo) -> bool:
|
||||
"""Check whether the sandbox Pod is running."""
|
||||
return self._provisioner_is_alive(info.sandbox_id)
|
||||
|
||||
def discover(self, sandbox_id: str) -> SandboxInfo | None:
|
||||
"""Discover an existing sandbox via the provisioner.
|
||||
|
||||
Calls ``GET /api/sandboxes/{sandbox_id}`` and returns info if
|
||||
the Pod exists.
|
||||
"""
|
||||
return self._provisioner_discover(sandbox_id)
|
||||
|
||||
# ── Provisioner API calls ─────────────────────────────────────────────
|
||||
|
||||
def _provisioner_create(self, thread_id: str, sandbox_id: str, extra_mounts: list[tuple[str, str, bool]] | None = None) -> SandboxInfo:
|
||||
"""POST /api/sandboxes → create Pod + Service."""
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self._provisioner_url}/api/sandboxes",
|
||||
json={
|
||||
"sandbox_id": sandbox_id,
|
||||
"thread_id": thread_id,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
logger.info(f"Provisioner created sandbox {sandbox_id}: sandbox_url={data['sandbox_url']}")
|
||||
return SandboxInfo(
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url=data["sandbox_url"],
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
logger.error(f"Provisioner create failed for {sandbox_id}: {exc}")
|
||||
raise RuntimeError(f"Provisioner create failed: {exc}") from exc
|
||||
|
||||
def _provisioner_destroy(self, sandbox_id: str) -> None:
|
||||
"""DELETE /api/sandboxes/{sandbox_id} → destroy Pod + Service."""
|
||||
try:
|
||||
resp = requests.delete(
|
||||
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
|
||||
timeout=15,
|
||||
)
|
||||
if resp.ok:
|
||||
logger.info(f"Provisioner destroyed sandbox {sandbox_id}")
|
||||
else:
|
||||
logger.warning(f"Provisioner destroy returned {resp.status_code}: {resp.text}")
|
||||
except requests.RequestException as exc:
|
||||
logger.warning(f"Provisioner destroy failed for {sandbox_id}: {exc}")
|
||||
|
||||
def _provisioner_is_alive(self, sandbox_id: str) -> bool:
|
||||
"""GET /api/sandboxes/{sandbox_id} → check Pod phase."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
return data.get("status") == "Running"
|
||||
return False
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
def _provisioner_discover(self, sandbox_id: str) -> SandboxInfo | None:
|
||||
"""GET /api/sandboxes/{sandbox_id} → discover existing sandbox."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return SandboxInfo(
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_url=data["sandbox_url"],
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
logger.debug(f"Provisioner discover failed for {sandbox_id}: {exc}")
|
||||
return None
|
||||
41
backend/src/community/aio_sandbox/sandbox_info.py
Normal file
41
backend/src/community/aio_sandbox/sandbox_info.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Sandbox metadata for cross-process discovery and state persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxInfo:
|
||||
"""Persisted sandbox metadata that enables cross-process discovery.
|
||||
|
||||
This dataclass holds all the information needed to reconnect to an
|
||||
existing sandbox from a different process (e.g., gateway vs langgraph,
|
||||
multiple workers, or across K8s pods with shared storage).
|
||||
"""
|
||||
|
||||
sandbox_id: str
|
||||
sandbox_url: str # e.g. http://localhost:8080 or http://k3s:30001
|
||||
container_name: str | None = None # Only for local container backend
|
||||
container_id: str | None = None # Only for local container backend
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"sandbox_id": self.sandbox_id,
|
||||
"sandbox_url": self.sandbox_url,
|
||||
"container_name": self.container_name,
|
||||
"container_id": self.container_id,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> SandboxInfo:
|
||||
return cls(
|
||||
sandbox_id=data["sandbox_id"],
|
||||
sandbox_url=data.get("sandbox_url", data.get("base_url", "")),
|
||||
container_name=data.get("container_name"),
|
||||
container_id=data.get("container_id"),
|
||||
created_at=data.get("created_at", time.time()),
|
||||
)
|
||||
70
backend/src/community/aio_sandbox/state_store.py
Normal file
70
backend/src/community/aio_sandbox/state_store.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Abstract base class for sandbox state persistence.
|
||||
|
||||
The state store handles cross-process persistence of thread_id → sandbox mappings,
|
||||
enabling different processes (gateway, langgraph, multiple workers) to find the same
|
||||
sandbox for a given thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .sandbox_info import SandboxInfo
|
||||
|
||||
|
||||
class SandboxStateStore(ABC):
|
||||
"""Abstract base for persisting thread_id → sandbox mappings across processes.
|
||||
|
||||
Implementations:
|
||||
- FileSandboxStateStore: JSON files + fcntl file locking (single-host)
|
||||
- TODO: RedisSandboxStateStore: Redis-based for distributed multi-host deployments
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def save(self, thread_id: str, info: SandboxInfo) -> None:
|
||||
"""Save sandbox state for a thread.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
info: Sandbox metadata to persist.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def load(self, thread_id: str) -> SandboxInfo | None:
|
||||
"""Load sandbox state for a thread.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
|
||||
Returns:
|
||||
SandboxInfo if found, None otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def remove(self, thread_id: str) -> None:
|
||||
"""Remove sandbox state for a thread.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
@contextmanager
|
||||
def lock(self, thread_id: str) -> Generator[None, None, None]:
|
||||
"""Acquire a cross-process lock for a thread's sandbox operations.
|
||||
|
||||
Ensures only one process can create/modify a sandbox for a given
|
||||
thread_id at a time, preventing duplicate sandbox creation.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID to lock.
|
||||
|
||||
Yields:
|
||||
None — use as a context manager.
|
||||
"""
|
||||
...
|
||||
73
backend/src/community/firecrawl/tools.py
Normal file
73
backend/src/community/firecrawl/tools.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
|
||||
from firecrawl import FirecrawlApp
|
||||
from langchain.tools import tool
|
||||
|
||||
from src.config import get_app_config
|
||||
|
||||
|
||||
def _get_firecrawl_client() -> FirecrawlApp:
|
||||
config = get_app_config().get_tool_config("web_search")
|
||||
api_key = None
|
||||
if config is not None:
|
||||
api_key = config.model_extra.get("api_key")
|
||||
return FirecrawlApp(api_key=api_key) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@tool("web_search", parse_docstring=True)
|
||||
def web_search_tool(query: str) -> str:
|
||||
"""Search the web.
|
||||
|
||||
Args:
|
||||
query: The query to search for.
|
||||
"""
|
||||
try:
|
||||
config = get_app_config().get_tool_config("web_search")
|
||||
max_results = 5
|
||||
if config is not None:
|
||||
max_results = config.model_extra.get("max_results", max_results)
|
||||
|
||||
client = _get_firecrawl_client()
|
||||
result = client.search(query, limit=max_results)
|
||||
|
||||
# result.web contains list of SearchResultWeb objects
|
||||
web_results = result.web or []
|
||||
normalized_results = [
|
||||
{
|
||||
"title": getattr(item, "title", "") or "",
|
||||
"url": getattr(item, "url", "") or "",
|
||||
"snippet": getattr(item, "description", "") or "",
|
||||
}
|
||||
for item in web_results
|
||||
]
|
||||
json_results = json.dumps(normalized_results, indent=2, ensure_ascii=False)
|
||||
return json_results
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
@tool("web_fetch", parse_docstring=True)
|
||||
def web_fetch_tool(url: str) -> str:
|
||||
"""Fetch the contents of a web page at a given URL.
|
||||
Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools.
|
||||
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
|
||||
Do NOT add www. to URLs that do NOT have them.
|
||||
URLs must include the schema: https://example.com is a valid URL while example.com is an invalid URL.
|
||||
|
||||
Args:
|
||||
url: The URL to fetch the contents of.
|
||||
"""
|
||||
try:
|
||||
client = _get_firecrawl_client()
|
||||
result = client.scrape(url, formats=["markdown"])
|
||||
|
||||
markdown_content = result.markdown or ""
|
||||
metadata = result.metadata
|
||||
title = metadata.title if metadata and metadata.title else "Untitled"
|
||||
|
||||
if not markdown_content:
|
||||
return "Error: No content found"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
return f"# {title}\n\n{markdown_content[:4096]}"
|
||||
3
backend/src/community/image_search/__init__.py
Normal file
3
backend/src/community/image_search/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .tools import image_search_tool
|
||||
|
||||
__all__ = ["image_search_tool"]
|
||||
135
backend/src/community/image_search/tools.py
Normal file
135
backend/src/community/image_search/tools.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Image Search Tool - Search images using DuckDuckGo for reference in image generation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from langchain.tools import tool
|
||||
|
||||
from src.config import get_app_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _search_images(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
region: str = "wt-wt",
|
||||
safesearch: str = "moderate",
|
||||
size: str | None = None,
|
||||
color: str | None = None,
|
||||
type_image: str | None = None,
|
||||
layout: str | None = None,
|
||||
license_image: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Execute image search using DuckDuckGo.
|
||||
|
||||
Args:
|
||||
query: Search keywords
|
||||
max_results: Maximum number of results
|
||||
region: Search region
|
||||
safesearch: Safe search level
|
||||
size: Image size (Small/Medium/Large/Wallpaper)
|
||||
color: Color filter
|
||||
type_image: Image type (photo/clipart/gif/transparent/line)
|
||||
layout: Layout (Square/Tall/Wide)
|
||||
license_image: License filter
|
||||
|
||||
Returns:
|
||||
List of search results
|
||||
"""
|
||||
try:
|
||||
from ddgs import DDGS
|
||||
except ImportError:
|
||||
logger.error("ddgs library not installed. Run: pip install ddgs")
|
||||
return []
|
||||
|
||||
ddgs = DDGS(timeout=30)
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
"region": region,
|
||||
"safesearch": safesearch,
|
||||
"max_results": max_results,
|
||||
}
|
||||
|
||||
if size:
|
||||
kwargs["size"] = size
|
||||
if color:
|
||||
kwargs["color"] = color
|
||||
if type_image:
|
||||
kwargs["type_image"] = type_image
|
||||
if layout:
|
||||
kwargs["layout"] = layout
|
||||
if license_image:
|
||||
kwargs["license_image"] = license_image
|
||||
|
||||
results = ddgs.images(query, **kwargs)
|
||||
return list(results) if results else []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to search images: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@tool("image_search", parse_docstring=True)
|
||||
def image_search_tool(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
size: str | None = None,
|
||||
type_image: str | None = None,
|
||||
layout: str | None = None,
|
||||
) -> str:
|
||||
"""Search for images online. Use this tool BEFORE image generation to find reference images for characters, portraits, objects, scenes, or any content requiring visual accuracy.
|
||||
|
||||
**When to use:**
|
||||
- Before generating character/portrait images: search for similar poses, expressions, styles
|
||||
- Before generating specific objects/products: search for accurate visual references
|
||||
- Before generating scenes/locations: search for architectural or environmental references
|
||||
- Before generating fashion/clothing: search for style and detail references
|
||||
|
||||
The returned image URLs can be used as reference images in image generation to significantly improve quality.
|
||||
|
||||
Args:
|
||||
query: Search keywords describing the images you want to find. Be specific for better results (e.g., "Japanese woman street photography 1990s" instead of just "woman").
|
||||
max_results: Maximum number of images to return. Default is 5.
|
||||
size: Image size filter. Options: "Small", "Medium", "Large", "Wallpaper". Use "Large" for reference images.
|
||||
type_image: Image type filter. Options: "photo", "clipart", "gif", "transparent", "line". Use "photo" for realistic references.
|
||||
layout: Layout filter. Options: "Square", "Tall", "Wide". Choose based on your generation needs.
|
||||
"""
|
||||
config = get_app_config().get_tool_config("image_search")
|
||||
|
||||
# Override max_results from config if set
|
||||
if config is not None and "max_results" in config.model_extra:
|
||||
max_results = config.model_extra.get("max_results", max_results)
|
||||
|
||||
results = _search_images(
|
||||
query=query,
|
||||
max_results=max_results,
|
||||
size=size,
|
||||
type_image=type_image,
|
||||
layout=layout,
|
||||
)
|
||||
|
||||
if not results:
|
||||
return json.dumps({"error": "No images found", "query": query}, ensure_ascii=False)
|
||||
|
||||
normalized_results = [
|
||||
{
|
||||
"title": r.get("title", ""),
|
||||
"image_url": r.get("thumbnail", ""),
|
||||
"thumbnail_url": r.get("thumbnail", ""),
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
output = {
|
||||
"query": query,
|
||||
"total_results": len(normalized_results),
|
||||
"results": normalized_results,
|
||||
"usage_hint": "Use the 'image_url' values as reference images in image generation. Download them first if needed.",
|
||||
}
|
||||
|
||||
return json.dumps(output, indent=2, ensure_ascii=False)
|
||||
38
backend/src/community/jina_ai/jina_client.py
Normal file
38
backend/src/community/jina_ai/jina_client.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JinaClient:
|
||||
def crawl(self, url: str, return_format: str = "html", timeout: int = 10) -> str:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Return-Format": return_format,
|
||||
"X-Timeout": str(timeout),
|
||||
}
|
||||
if os.getenv("JINA_API_KEY"):
|
||||
headers["Authorization"] = f"Bearer {os.getenv('JINA_API_KEY')}"
|
||||
else:
|
||||
logger.warning("Jina API key is not set. Provide your own key to access a higher rate limit. See https://jina.ai/reader for more information.")
|
||||
data = {"url": url}
|
||||
try:
|
||||
response = requests.post("https://r.jina.ai/", headers=headers, json=data)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_message = f"Jina API returned status {response.status_code}: {response.text}"
|
||||
logger.error(error_message)
|
||||
return f"Error: {error_message}"
|
||||
|
||||
if not response.text or not response.text.strip():
|
||||
error_message = "Jina API returned empty response"
|
||||
logger.error(error_message)
|
||||
return f"Error: {error_message}"
|
||||
|
||||
return response.text
|
||||
except Exception as e:
|
||||
error_message = f"Request to Jina API failed: {str(e)}"
|
||||
logger.error(error_message)
|
||||
return f"Error: {error_message}"
|
||||
28
backend/src/community/jina_ai/tools.py
Normal file
28
backend/src/community/jina_ai/tools.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from langchain.tools import tool
|
||||
|
||||
from src.community.jina_ai.jina_client import JinaClient
|
||||
from src.config import get_app_config
|
||||
from src.utils.readability import ReadabilityExtractor
|
||||
|
||||
readability_extractor = ReadabilityExtractor()
|
||||
|
||||
|
||||
@tool("web_fetch", parse_docstring=True)
|
||||
def web_fetch_tool(url: str) -> str:
|
||||
"""Fetch the contents of a web page at a given URL.
|
||||
Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools.
|
||||
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
|
||||
Do NOT add www. to URLs that do NOT have them.
|
||||
URLs must include the schema: https://example.com is a valid URL while example.com is an invalid URL.
|
||||
|
||||
Args:
|
||||
url: The URL to fetch the contents of.
|
||||
"""
|
||||
jina_client = JinaClient()
|
||||
timeout = 10
|
||||
config = get_app_config().get_tool_config("web_fetch")
|
||||
if config is not None and "timeout" in config.model_extra:
|
||||
timeout = config.model_extra.get("timeout")
|
||||
html_content = jina_client.crawl(url, return_format="html", timeout=timeout)
|
||||
article = readability_extractor.extract_article(html_content)
|
||||
return article.to_markdown()[:4096]
|
||||
62
backend/src/community/tavily/tools.py
Normal file
62
backend/src/community/tavily/tools.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import json
|
||||
|
||||
from langchain.tools import tool
|
||||
from tavily import TavilyClient
|
||||
|
||||
from src.config import get_app_config
|
||||
|
||||
|
||||
def _get_tavily_client() -> TavilyClient:
|
||||
config = get_app_config().get_tool_config("web_search")
|
||||
api_key = None
|
||||
if config is not None and "api_key" in config.model_extra:
|
||||
api_key = config.model_extra.get("api_key")
|
||||
return TavilyClient(api_key=api_key)
|
||||
|
||||
|
||||
@tool("web_search", parse_docstring=True)
|
||||
def web_search_tool(query: str) -> str:
|
||||
"""Search the web.
|
||||
|
||||
Args:
|
||||
query: The query to search for.
|
||||
"""
|
||||
config = get_app_config().get_tool_config("web_search")
|
||||
max_results = 5
|
||||
if config is not None and "max_results" in config.model_extra:
|
||||
max_results = config.model_extra.get("max_results")
|
||||
|
||||
client = _get_tavily_client()
|
||||
res = client.search(query, max_results=max_results)
|
||||
normalized_results = [
|
||||
{
|
||||
"title": result["title"],
|
||||
"url": result["url"],
|
||||
"snippet": result["content"],
|
||||
}
|
||||
for result in res["results"]
|
||||
]
|
||||
json_results = json.dumps(normalized_results, indent=2, ensure_ascii=False)
|
||||
return json_results
|
||||
|
||||
|
||||
@tool("web_fetch", parse_docstring=True)
|
||||
def web_fetch_tool(url: str) -> str:
|
||||
"""Fetch the contents of a web page at a given URL.
|
||||
Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools.
|
||||
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
|
||||
Do NOT add www. to URLs that do NOT have them.
|
||||
URLs must include the schema: https://example.com is a valid URL while example.com is an invalid URL.
|
||||
|
||||
Args:
|
||||
url: The URL to fetch the contents of.
|
||||
"""
|
||||
client = _get_tavily_client()
|
||||
res = client.extract([url])
|
||||
if "failed_results" in res and len(res["failed_results"]) > 0:
|
||||
return f"Error: {res['failed_results'][0]['error']}"
|
||||
elif "results" in res and len(res["results"]) > 0:
|
||||
result = res["results"][0]
|
||||
return f"# {result['title']}\n\n{result['raw_content'][:4096]}"
|
||||
else:
|
||||
return "Error: No results found"
|
||||
13
backend/src/config/__init__.py
Normal file
13
backend/src/config/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from .app_config import get_app_config
|
||||
from .extensions_config import ExtensionsConfig, get_extensions_config
|
||||
from .memory_config import MemoryConfig, get_memory_config
|
||||
from .skills_config import SkillsConfig
|
||||
|
||||
__all__ = [
|
||||
"get_app_config",
|
||||
"SkillsConfig",
|
||||
"ExtensionsConfig",
|
||||
"get_extensions_config",
|
||||
"MemoryConfig",
|
||||
"get_memory_config",
|
||||
]
|
||||
206
backend/src/config/app_config.py
Normal file
206
backend/src/config/app_config.py
Normal file
@@ -0,0 +1,206 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from src.config.extensions_config import ExtensionsConfig
|
||||
from src.config.memory_config import load_memory_config_from_dict
|
||||
from src.config.model_config import ModelConfig
|
||||
from src.config.sandbox_config import SandboxConfig
|
||||
from src.config.skills_config import SkillsConfig
|
||||
from src.config.summarization_config import load_summarization_config_from_dict
|
||||
from src.config.title_config import load_title_config_from_dict
|
||||
from src.config.tool_config import ToolConfig, ToolGroupConfig
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
"""Config for the DeerFlow application"""
|
||||
|
||||
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
|
||||
sandbox: SandboxConfig = Field(description="Sandbox configuration")
|
||||
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
|
||||
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
|
||||
skills: SkillsConfig = Field(default_factory=SkillsConfig, description="Skills configuration")
|
||||
extensions: ExtensionsConfig = Field(default_factory=ExtensionsConfig, description="Extensions configuration (MCP servers and skills state)")
|
||||
model_config = ConfigDict(extra="allow", frozen=False)
|
||||
|
||||
@classmethod
|
||||
def resolve_config_path(cls, config_path: str | None = None) -> Path:
|
||||
"""Resolve the config file path.
|
||||
|
||||
Priority:
|
||||
1. If provided `config_path` argument, use it.
|
||||
2. If provided `DEER_FLOW_CONFIG_PATH` environment variable, use it.
|
||||
3. Otherwise, first check the `config.yaml` in the current directory, then fallback to `config.yaml` in the parent directory.
|
||||
"""
|
||||
if config_path:
|
||||
path = Path(config_path)
|
||||
if not Path.exists(path):
|
||||
raise FileNotFoundError(f"Config file specified by param `config_path` not found at {path}")
|
||||
return path
|
||||
elif os.getenv("DEER_FLOW_CONFIG_PATH"):
|
||||
path = Path(os.getenv("DEER_FLOW_CONFIG_PATH"))
|
||||
if not Path.exists(path):
|
||||
raise FileNotFoundError(f"Config file specified by environment variable `DEER_FLOW_CONFIG_PATH` not found at {path}")
|
||||
return path
|
||||
else:
|
||||
# Check if the config.yaml is in the current directory
|
||||
path = Path(os.getcwd()) / "config.yaml"
|
||||
if not path.exists():
|
||||
# Check if the config.yaml is in the parent directory of CWD
|
||||
path = Path(os.getcwd()).parent / "config.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError("`config.yaml` file not found at the current directory nor its parent directory")
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, config_path: str | None = None) -> Self:
|
||||
"""Load config from YAML file.
|
||||
|
||||
See `resolve_config_path` for more details.
|
||||
|
||||
Args:
|
||||
config_path: Path to the config file.
|
||||
|
||||
Returns:
|
||||
AppConfig: The loaded config.
|
||||
"""
|
||||
resolved_path = cls.resolve_config_path(config_path)
|
||||
with open(resolved_path) as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
config_data = cls.resolve_env_variables(config_data)
|
||||
|
||||
# Load title config if present
|
||||
if "title" in config_data:
|
||||
load_title_config_from_dict(config_data["title"])
|
||||
|
||||
# Load summarization config if present
|
||||
if "summarization" in config_data:
|
||||
load_summarization_config_from_dict(config_data["summarization"])
|
||||
|
||||
# Load memory config if present
|
||||
if "memory" in config_data:
|
||||
load_memory_config_from_dict(config_data["memory"])
|
||||
|
||||
# Load extensions config separately (it's in a different file)
|
||||
extensions_config = ExtensionsConfig.from_file()
|
||||
config_data["extensions"] = extensions_config.model_dump()
|
||||
|
||||
result = cls.model_validate(config_data)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def resolve_env_variables(cls, config: Any) -> Any:
|
||||
"""Recursively resolve environment variables in the config.
|
||||
|
||||
Environment variables are resolved using the `os.getenv` function. Example: $OPENAI_API_KEY
|
||||
|
||||
Args:
|
||||
config: The config to resolve environment variables in.
|
||||
|
||||
Returns:
|
||||
The config with environment variables resolved.
|
||||
"""
|
||||
if isinstance(config, str):
|
||||
if config.startswith("$"):
|
||||
return os.getenv(config[1:], config)
|
||||
return config
|
||||
elif isinstance(config, dict):
|
||||
return {k: cls.resolve_env_variables(v) for k, v in config.items()}
|
||||
elif isinstance(config, list):
|
||||
return [cls.resolve_env_variables(item) for item in config]
|
||||
return config
|
||||
|
||||
def get_model_config(self, name: str) -> ModelConfig | None:
|
||||
"""Get the model config by name.
|
||||
|
||||
Args:
|
||||
name: The name of the model to get the config for.
|
||||
|
||||
Returns:
|
||||
The model config if found, otherwise None.
|
||||
"""
|
||||
return next((model for model in self.models if model.name == name), None)
|
||||
|
||||
def get_tool_config(self, name: str) -> ToolConfig | None:
|
||||
"""Get the tool config by name.
|
||||
|
||||
Args:
|
||||
name: The name of the tool to get the config for.
|
||||
|
||||
Returns:
|
||||
The tool config if found, otherwise None.
|
||||
"""
|
||||
return next((tool for tool in self.tools if tool.name == name), None)
|
||||
|
||||
def get_tool_group_config(self, name: str) -> ToolGroupConfig | None:
|
||||
"""Get the tool group config by name.
|
||||
|
||||
Args:
|
||||
name: The name of the tool group to get the config for.
|
||||
|
||||
Returns:
|
||||
The tool group config if found, otherwise None.
|
||||
"""
|
||||
return next((group for group in self.tool_groups if group.name == name), None)
|
||||
|
||||
|
||||
_app_config: AppConfig | None = None
|
||||
|
||||
|
||||
def get_app_config() -> AppConfig:
|
||||
"""Get the DeerFlow config instance.
|
||||
|
||||
Returns a cached singleton instance. Use `reload_app_config()` to reload
|
||||
from file, or `reset_app_config()` to clear the cache.
|
||||
"""
|
||||
global _app_config
|
||||
if _app_config is None:
|
||||
_app_config = AppConfig.from_file()
|
||||
return _app_config
|
||||
|
||||
|
||||
def reload_app_config(config_path: str | None = None) -> AppConfig:
|
||||
"""Reload the config from file and update the cached instance.
|
||||
|
||||
This is useful when the config file has been modified and you want
|
||||
to pick up the changes without restarting the application.
|
||||
|
||||
Args:
|
||||
config_path: Optional path to config file. If not provided,
|
||||
uses the default resolution strategy.
|
||||
|
||||
Returns:
|
||||
The newly loaded AppConfig instance.
|
||||
"""
|
||||
global _app_config
|
||||
_app_config = AppConfig.from_file(config_path)
|
||||
return _app_config
|
||||
|
||||
|
||||
def reset_app_config() -> None:
|
||||
"""Reset the cached config instance.
|
||||
|
||||
This clears the singleton cache, causing the next call to
|
||||
`get_app_config()` to reload from file. Useful for testing
|
||||
or when switching between different configurations.
|
||||
"""
|
||||
global _app_config
|
||||
_app_config = None
|
||||
|
||||
|
||||
def set_app_config(config: AppConfig) -> None:
|
||||
"""Set a custom config instance.
|
||||
|
||||
This allows injecting a custom or mock config for testing purposes.
|
||||
|
||||
Args:
|
||||
config: The AppConfig instance to use.
|
||||
"""
|
||||
global _app_config
|
||||
_app_config = config
|
||||
225
backend/src/config/extensions_config.py
Normal file
225
backend/src/config/extensions_config.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Unified extensions configuration for MCP servers and skills."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class McpServerConfig(BaseModel):
|
||||
"""Configuration for a single MCP server."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether this MCP server is enabled")
|
||||
type: str = Field(default="stdio", description="Transport type: 'stdio', 'sse', or 'http'")
|
||||
command: str | None = Field(default=None, description="Command to execute to start the MCP server (for stdio type)")
|
||||
args: list[str] = Field(default_factory=list, description="Arguments to pass to the command (for stdio type)")
|
||||
env: dict[str, str] = Field(default_factory=dict, description="Environment variables for the MCP server")
|
||||
url: str | None = Field(default=None, description="URL of the MCP server (for sse or http type)")
|
||||
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
|
||||
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class SkillStateConfig(BaseModel):
|
||||
"""Configuration for a single skill's state."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
||||
|
||||
|
||||
class ExtensionsConfig(BaseModel):
|
||||
"""Unified configuration for MCP servers and skills."""
|
||||
|
||||
mcp_servers: dict[str, McpServerConfig] = Field(
|
||||
default_factory=dict,
|
||||
description="Map of MCP server name to configuration",
|
||||
alias="mcpServers",
|
||||
)
|
||||
skills: dict[str, SkillStateConfig] = Field(
|
||||
default_factory=dict,
|
||||
description="Map of skill name to state configuration",
|
||||
)
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
@classmethod
|
||||
def resolve_config_path(cls, config_path: str | None = None) -> Path | None:
|
||||
"""Resolve the extensions config file path.
|
||||
|
||||
Priority:
|
||||
1. If provided `config_path` argument, use it.
|
||||
2. If provided `DEER_FLOW_EXTENSIONS_CONFIG_PATH` environment variable, use it.
|
||||
3. Otherwise, check for `extensions_config.json` in the current directory, then in the parent directory.
|
||||
4. For backward compatibility, also check for `mcp_config.json` if `extensions_config.json` is not found.
|
||||
5. If not found, return None (extensions are optional).
|
||||
|
||||
Args:
|
||||
config_path: Optional path to extensions config file.
|
||||
|
||||
Returns:
|
||||
Path to the extensions config file if found, otherwise None.
|
||||
"""
|
||||
if config_path:
|
||||
path = Path(config_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Extensions config file specified by param `config_path` not found at {path}")
|
||||
return path
|
||||
elif os.getenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH"):
|
||||
path = Path(os.getenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH"))
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Extensions config file specified by environment variable `DEER_FLOW_EXTENSIONS_CONFIG_PATH` not found at {path}")
|
||||
return path
|
||||
else:
|
||||
# Check if the extensions_config.json is in the current directory
|
||||
path = Path(os.getcwd()) / "extensions_config.json"
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
# Check if the extensions_config.json is in the parent directory of CWD
|
||||
path = Path(os.getcwd()).parent / "extensions_config.json"
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
# Backward compatibility: check for mcp_config.json
|
||||
path = Path(os.getcwd()) / "mcp_config.json"
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
path = Path(os.getcwd()).parent / "mcp_config.json"
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
# Extensions are optional, so return None if not found
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, config_path: str | None = None) -> "ExtensionsConfig":
|
||||
"""Load extensions config from JSON file.
|
||||
|
||||
See `resolve_config_path` for more details.
|
||||
|
||||
Args:
|
||||
config_path: Path to the extensions config file.
|
||||
|
||||
Returns:
|
||||
ExtensionsConfig: The loaded config, or empty config if file not found.
|
||||
"""
|
||||
resolved_path = cls.resolve_config_path(config_path)
|
||||
if resolved_path is None:
|
||||
# Return empty config if extensions config file is not found
|
||||
return cls(mcp_servers={}, skills={})
|
||||
|
||||
with open(resolved_path) as f:
|
||||
config_data = json.load(f)
|
||||
|
||||
cls.resolve_env_variables(config_data)
|
||||
return cls.model_validate(config_data)
|
||||
|
||||
@classmethod
|
||||
def resolve_env_variables(cls, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively resolve environment variables in the config.
|
||||
|
||||
Environment variables are resolved using the `os.getenv` function. Example: $OPENAI_API_KEY
|
||||
|
||||
Args:
|
||||
config: The config to resolve environment variables in.
|
||||
|
||||
Returns:
|
||||
The config with environment variables resolved.
|
||||
"""
|
||||
for key, value in config.items():
|
||||
if isinstance(value, str):
|
||||
if value.startswith("$"):
|
||||
env_value = os.getenv(value[1:], None)
|
||||
if env_value is not None:
|
||||
config[key] = env_value
|
||||
else:
|
||||
config[key] = value
|
||||
elif isinstance(value, dict):
|
||||
config[key] = cls.resolve_env_variables(value)
|
||||
elif isinstance(value, list):
|
||||
config[key] = [cls.resolve_env_variables(item) if isinstance(item, dict) else item for item in value]
|
||||
return config
|
||||
|
||||
def get_enabled_mcp_servers(self) -> dict[str, McpServerConfig]:
|
||||
"""Get only the enabled MCP servers.
|
||||
|
||||
Returns:
|
||||
Dictionary of enabled MCP servers.
|
||||
"""
|
||||
return {name: config for name, config in self.mcp_servers.items() if config.enabled}
|
||||
|
||||
def is_skill_enabled(self, skill_name: str, skill_category: str) -> bool:
|
||||
"""Check if a skill is enabled.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the skill
|
||||
skill_category: Category of the skill
|
||||
|
||||
Returns:
|
||||
True if enabled, False otherwise
|
||||
"""
|
||||
skill_config = self.skills.get(skill_name)
|
||||
if skill_config is None:
|
||||
# Default to enable for public & custom skill
|
||||
return skill_category in ("public", "custom")
|
||||
return skill_config.enabled
|
||||
|
||||
|
||||
_extensions_config: ExtensionsConfig | None = None
|
||||
|
||||
|
||||
def get_extensions_config() -> ExtensionsConfig:
|
||||
"""Get the extensions config instance.
|
||||
|
||||
Returns a cached singleton instance. Use `reload_extensions_config()` to reload
|
||||
from file, or `reset_extensions_config()` to clear the cache.
|
||||
|
||||
Returns:
|
||||
The cached ExtensionsConfig instance.
|
||||
"""
|
||||
global _extensions_config
|
||||
if _extensions_config is None:
|
||||
_extensions_config = ExtensionsConfig.from_file()
|
||||
return _extensions_config
|
||||
|
||||
|
||||
def reload_extensions_config(config_path: str | None = None) -> ExtensionsConfig:
|
||||
"""Reload the extensions config from file and update the cached instance.
|
||||
|
||||
This is useful when the config file has been modified and you want
|
||||
to pick up the changes without restarting the application.
|
||||
|
||||
Args:
|
||||
config_path: Optional path to extensions config file. If not provided,
|
||||
uses the default resolution strategy.
|
||||
|
||||
Returns:
|
||||
The newly loaded ExtensionsConfig instance.
|
||||
"""
|
||||
global _extensions_config
|
||||
_extensions_config = ExtensionsConfig.from_file(config_path)
|
||||
return _extensions_config
|
||||
|
||||
|
||||
def reset_extensions_config() -> None:
|
||||
"""Reset the cached extensions config instance.
|
||||
|
||||
This clears the singleton cache, causing the next call to
|
||||
`get_extensions_config()` to reload from file. Useful for testing
|
||||
or when switching between different configurations.
|
||||
"""
|
||||
global _extensions_config
|
||||
_extensions_config = None
|
||||
|
||||
|
||||
def set_extensions_config(config: ExtensionsConfig) -> None:
|
||||
"""Set a custom extensions config instance.
|
||||
|
||||
This allows injecting a custom or mock config for testing purposes.
|
||||
|
||||
Args:
|
||||
config: The ExtensionsConfig instance to use.
|
||||
"""
|
||||
global _extensions_config
|
||||
_extensions_config = config
|
||||
69
backend/src/config/memory_config.py
Normal file
69
backend/src/config/memory_config.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Configuration for memory mechanism."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MemoryConfig(BaseModel):
|
||||
"""Configuration for global memory mechanism."""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether to enable memory mechanism",
|
||||
)
|
||||
storage_path: str = Field(
|
||||
default=".deer-flow/memory.json",
|
||||
description="Path to store memory data (relative to backend directory)",
|
||||
)
|
||||
debounce_seconds: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
le=300,
|
||||
description="Seconds to wait before processing queued updates (debounce)",
|
||||
)
|
||||
model_name: str | None = Field(
|
||||
default=None,
|
||||
description="Model name to use for memory updates (None = use default model)",
|
||||
)
|
||||
max_facts: int = Field(
|
||||
default=100,
|
||||
ge=10,
|
||||
le=500,
|
||||
description="Maximum number of facts to store",
|
||||
)
|
||||
fact_confidence_threshold: float = Field(
|
||||
default=0.7,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Minimum confidence threshold for storing facts",
|
||||
)
|
||||
injection_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether to inject memory into system prompt",
|
||||
)
|
||||
max_injection_tokens: int = Field(
|
||||
default=2000,
|
||||
ge=100,
|
||||
le=8000,
|
||||
description="Maximum tokens to use for memory injection",
|
||||
)
|
||||
|
||||
|
||||
# Global configuration instance
|
||||
_memory_config: MemoryConfig = MemoryConfig()
|
||||
|
||||
|
||||
def get_memory_config() -> MemoryConfig:
|
||||
"""Get the current memory configuration."""
|
||||
return _memory_config
|
||||
|
||||
|
||||
def set_memory_config(config: MemoryConfig) -> None:
|
||||
"""Set the memory configuration."""
|
||||
global _memory_config
|
||||
_memory_config = config
|
||||
|
||||
|
||||
def load_memory_config_from_dict(config_dict: dict) -> None:
|
||||
"""Load memory configuration from a dictionary."""
|
||||
global _memory_config
|
||||
_memory_config = MemoryConfig(**config_dict)
|
||||
21
backend/src/config/model_config.py
Normal file
21
backend/src/config/model_config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
"""Config section for a model"""
|
||||
|
||||
name: str = Field(..., description="Unique name for the model")
|
||||
display_name: str | None = Field(..., default_factory=lambda: None, description="Display name for the model")
|
||||
description: str | None = Field(..., default_factory=lambda: None, description="Description for the model")
|
||||
use: str = Field(
|
||||
...,
|
||||
description="Class path of the model provider(e.g. langchain_openai.ChatOpenAI)",
|
||||
)
|
||||
model: str = Field(..., description="Model name")
|
||||
model_config = ConfigDict(extra="allow")
|
||||
supports_thinking: bool = Field(default_factory=lambda: False, description="Whether the model supports thinking")
|
||||
when_thinking_enabled: dict | None = Field(
|
||||
default_factory=lambda: None,
|
||||
description="Extra settings to be passed to the model when thinking is enabled",
|
||||
)
|
||||
supports_vision: bool = Field(default_factory=lambda: False, description="Whether the model supports vision/image inputs")
|
||||
66
backend/src/config/sandbox_config.py
Normal file
66
backend/src/config/sandbox_config.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class VolumeMountConfig(BaseModel):
|
||||
"""Configuration for a volume mount."""
|
||||
|
||||
host_path: str = Field(..., description="Path on the host machine")
|
||||
container_path: str = Field(..., description="Path inside the container")
|
||||
read_only: bool = Field(default=False, description="Whether the mount is read-only")
|
||||
|
||||
|
||||
class SandboxConfig(BaseModel):
|
||||
"""Config section for a sandbox.
|
||||
|
||||
Common options:
|
||||
use: Class path of the sandbox provider (required)
|
||||
|
||||
AioSandboxProvider specific options:
|
||||
image: Docker image to use (default: enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest)
|
||||
port: Base port for sandbox containers (default: 8080)
|
||||
base_url: If set, uses existing sandbox instead of starting new container
|
||||
auto_start: Whether to automatically start Docker container (default: true)
|
||||
container_prefix: Prefix for container names (default: deer-flow-sandbox)
|
||||
idle_timeout: Idle timeout in seconds before sandbox is released (default: 600 = 10 minutes). Set to 0 to disable.
|
||||
mounts: List of volume mounts to share directories with the container
|
||||
environment: Environment variables to inject into the container (values starting with $ are resolved from host env)
|
||||
"""
|
||||
|
||||
use: str = Field(
|
||||
...,
|
||||
description="Class path of the sandbox provider (e.g. src.sandbox.local:LocalSandboxProvider)",
|
||||
)
|
||||
image: str | None = Field(
|
||||
default=None,
|
||||
description="Docker image to use for the sandbox container",
|
||||
)
|
||||
port: int | None = Field(
|
||||
default=None,
|
||||
description="Base port for sandbox containers",
|
||||
)
|
||||
base_url: str | None = Field(
|
||||
default=None,
|
||||
description="If set, uses existing sandbox at this URL instead of starting new container",
|
||||
)
|
||||
auto_start: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to automatically start Docker container",
|
||||
)
|
||||
container_prefix: str | None = Field(
|
||||
default=None,
|
||||
description="Prefix for container names",
|
||||
)
|
||||
idle_timeout: int | None = Field(
|
||||
default=None,
|
||||
description="Idle timeout in seconds before sandbox is released (default: 600 = 10 minutes). Set to 0 to disable.",
|
||||
)
|
||||
mounts: list[VolumeMountConfig] = Field(
|
||||
default_factory=list,
|
||||
description="List of volume mounts to share directories between host and container",
|
||||
)
|
||||
environment: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Environment variables to inject into the sandbox container. Values starting with $ will be resolved from host environment variables.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
49
backend/src/config/skills_config.py
Normal file
49
backend/src/config/skills_config.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SkillsConfig(BaseModel):
|
||||
"""Configuration for skills system"""
|
||||
|
||||
path: str | None = Field(
|
||||
default=None,
|
||||
description="Path to skills directory. If not specified, defaults to ../skills relative to backend directory",
|
||||
)
|
||||
container_path: str = Field(
|
||||
default="/mnt/skills",
|
||||
description="Path where skills are mounted in the sandbox container",
|
||||
)
|
||||
|
||||
def get_skills_path(self) -> Path:
|
||||
"""
|
||||
Get the resolved skills directory path.
|
||||
|
||||
Returns:
|
||||
Path to the skills directory
|
||||
"""
|
||||
if self.path:
|
||||
# Use configured path (can be absolute or relative)
|
||||
path = Path(self.path)
|
||||
if not path.is_absolute():
|
||||
# If relative, resolve from current working directory
|
||||
path = Path.cwd() / path
|
||||
return path.resolve()
|
||||
else:
|
||||
# Default: ../skills relative to backend directory
|
||||
from src.skills.loader import get_skills_root_path
|
||||
|
||||
return get_skills_root_path()
|
||||
|
||||
def get_skill_container_path(self, skill_name: str, category: str = "public") -> str:
|
||||
"""
|
||||
Get the full container path for a specific skill.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the skill (directory name)
|
||||
category: Category of the skill (public or custom)
|
||||
|
||||
Returns:
|
||||
Full path to the skill in the container
|
||||
"""
|
||||
return f"{self.container_path}/{category}/{skill_name}"
|
||||
74
backend/src/config/summarization_config.py
Normal file
74
backend/src/config/summarization_config.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Configuration for conversation summarization."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
ContextSizeType = Literal["fraction", "tokens", "messages"]
|
||||
|
||||
|
||||
class ContextSize(BaseModel):
|
||||
"""Context size specification for trigger or keep parameters."""
|
||||
|
||||
type: ContextSizeType = Field(description="Type of context size specification")
|
||||
value: int | float = Field(description="Value for the context size specification")
|
||||
|
||||
def to_tuple(self) -> tuple[ContextSizeType, int | float]:
|
||||
"""Convert to tuple format expected by SummarizationMiddleware."""
|
||||
return (self.type, self.value)
|
||||
|
||||
|
||||
class SummarizationConfig(BaseModel):
|
||||
"""Configuration for automatic conversation summarization."""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether to enable automatic conversation summarization",
|
||||
)
|
||||
model_name: str | None = Field(
|
||||
default=None,
|
||||
description="Model name to use for summarization (None = use a lightweight model)",
|
||||
)
|
||||
trigger: ContextSize | list[ContextSize] | None = Field(
|
||||
default=None,
|
||||
description="One or more thresholds that trigger summarization. When any threshold is met, summarization runs. "
|
||||
"Examples: {'type': 'messages', 'value': 50} triggers at 50 messages, "
|
||||
"{'type': 'tokens', 'value': 4000} triggers at 4000 tokens, "
|
||||
"{'type': 'fraction', 'value': 0.8} triggers at 80% of model's max input tokens",
|
||||
)
|
||||
keep: ContextSize = Field(
|
||||
default_factory=lambda: ContextSize(type="messages", value=20),
|
||||
description="Context retention policy after summarization. Specifies how much history to preserve. "
|
||||
"Examples: {'type': 'messages', 'value': 20} keeps 20 messages, "
|
||||
"{'type': 'tokens', 'value': 3000} keeps 3000 tokens, "
|
||||
"{'type': 'fraction', 'value': 0.3} keeps 30% of model's max input tokens",
|
||||
)
|
||||
trim_tokens_to_summarize: int | None = Field(
|
||||
default=4000,
|
||||
description="Maximum tokens to keep when preparing messages for summarization. Pass null to skip trimming.",
|
||||
)
|
||||
summary_prompt: str | None = Field(
|
||||
default=None,
|
||||
description="Custom prompt template for generating summaries. If not provided, uses the default LangChain prompt.",
|
||||
)
|
||||
|
||||
|
||||
# Global configuration instance
|
||||
_summarization_config: SummarizationConfig = SummarizationConfig()
|
||||
|
||||
|
||||
def get_summarization_config() -> SummarizationConfig:
|
||||
"""Get the current summarization configuration."""
|
||||
return _summarization_config
|
||||
|
||||
|
||||
def set_summarization_config(config: SummarizationConfig) -> None:
|
||||
"""Set the summarization configuration."""
|
||||
global _summarization_config
|
||||
_summarization_config = config
|
||||
|
||||
|
||||
def load_summarization_config_from_dict(config_dict: dict) -> None:
|
||||
"""Load summarization configuration from a dictionary."""
|
||||
global _summarization_config
|
||||
_summarization_config = SummarizationConfig(**config_dict)
|
||||
53
backend/src/config/title_config.py
Normal file
53
backend/src/config/title_config.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Configuration for automatic thread title generation."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TitleConfig(BaseModel):
|
||||
"""Configuration for automatic thread title generation."""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether to enable automatic title generation",
|
||||
)
|
||||
max_words: int = Field(
|
||||
default=6,
|
||||
ge=1,
|
||||
le=20,
|
||||
description="Maximum number of words in the generated title",
|
||||
)
|
||||
max_chars: int = Field(
|
||||
default=60,
|
||||
ge=10,
|
||||
le=200,
|
||||
description="Maximum number of characters in the generated title",
|
||||
)
|
||||
model_name: str | None = Field(
|
||||
default=None,
|
||||
description="Model name to use for title generation (None = use default model)",
|
||||
)
|
||||
prompt_template: str = Field(
|
||||
default=("Generate a concise title (max {max_words} words) for this conversation.\nUser: {user_msg}\nAssistant: {assistant_msg}\n\nReturn ONLY the title, no quotes, no explanation."),
|
||||
description="Prompt template for title generation",
|
||||
)
|
||||
|
||||
|
||||
# Global configuration instance
|
||||
_title_config: TitleConfig = TitleConfig()
|
||||
|
||||
|
||||
def get_title_config() -> TitleConfig:
|
||||
"""Get the current title configuration."""
|
||||
return _title_config
|
||||
|
||||
|
||||
def set_title_config(config: TitleConfig) -> None:
|
||||
"""Set the title configuration."""
|
||||
global _title_config
|
||||
_title_config = config
|
||||
|
||||
|
||||
def load_title_config_from_dict(config_dict: dict) -> None:
|
||||
"""Load title configuration from a dictionary."""
|
||||
global _title_config
|
||||
_title_config = TitleConfig(**config_dict)
|
||||
20
backend/src/config/tool_config.py
Normal file
20
backend/src/config/tool_config.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ToolGroupConfig(BaseModel):
|
||||
"""Config section for a tool group"""
|
||||
|
||||
name: str = Field(..., description="Unique name for the tool group")
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class ToolConfig(BaseModel):
|
||||
"""Config section for a tool"""
|
||||
|
||||
name: str = Field(..., description="Unique name for the tool")
|
||||
group: str = Field(..., description="Group name for the tool")
|
||||
use: str = Field(
|
||||
...,
|
||||
description="Variable name of the tool provider(e.g. src.sandbox.tools:bash_tool)",
|
||||
)
|
||||
model_config = ConfigDict(extra="allow")
|
||||
4
backend/src/gateway/__init__.py
Normal file
4
backend/src/gateway/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .app import app, create_app
|
||||
from .config import GatewayConfig, get_gateway_config
|
||||
|
||||
__all__ = ["app", "create_app", "GatewayConfig", "get_gateway_config"]
|
||||
134
backend/src/gateway/app.py
Normal file
134
backend/src/gateway/app.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from src.gateway.config import get_gateway_config
|
||||
from src.gateway.routers import artifacts, mcp, memory, models, skills, uploads
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
config = get_gateway_config()
|
||||
logger.info(f"Starting API Gateway on {config.host}:{config.port}")
|
||||
|
||||
# NOTE: MCP tools initialization is NOT done here because:
|
||||
# 1. Gateway doesn't use MCP tools - they are used by Agents in the LangGraph Server
|
||||
# 2. Gateway and LangGraph Server are separate processes with independent caches
|
||||
# MCP tools are lazily initialized in LangGraph Server when first needed
|
||||
|
||||
yield
|
||||
logger.info("Shutting down API Gateway")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application.
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application instance.
|
||||
"""
|
||||
|
||||
app = FastAPI(
|
||||
title="DeerFlow API Gateway",
|
||||
description="""
|
||||
## DeerFlow API Gateway
|
||||
|
||||
API Gateway for DeerFlow - A LangGraph-based AI agent backend with sandbox execution capabilities.
|
||||
|
||||
### Features
|
||||
|
||||
- **Models Management**: Query and retrieve available AI models
|
||||
- **MCP Configuration**: Manage Model Context Protocol (MCP) server configurations
|
||||
- **Memory Management**: Access and manage global memory data for personalized conversations
|
||||
- **Skills Management**: Query and manage skills and their enabled status
|
||||
- **Artifacts**: Access thread artifacts and generated files
|
||||
- **Health Monitoring**: System health check endpoints
|
||||
|
||||
### Architecture
|
||||
|
||||
LangGraph requests are handled by nginx reverse proxy.
|
||||
This gateway provides custom endpoints for models, MCP configuration, skills, and artifacts.
|
||||
""",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
openapi_tags=[
|
||||
{
|
||||
"name": "models",
|
||||
"description": "Operations for querying available AI models and their configurations",
|
||||
},
|
||||
{
|
||||
"name": "mcp",
|
||||
"description": "Manage Model Context Protocol (MCP) server configurations",
|
||||
},
|
||||
{
|
||||
"name": "memory",
|
||||
"description": "Access and manage global memory data for personalized conversations",
|
||||
},
|
||||
{
|
||||
"name": "skills",
|
||||
"description": "Manage skills and their configurations",
|
||||
},
|
||||
{
|
||||
"name": "artifacts",
|
||||
"description": "Access and download thread artifacts and generated files",
|
||||
},
|
||||
{
|
||||
"name": "uploads",
|
||||
"description": "Upload and manage user files for threads",
|
||||
},
|
||||
{
|
||||
"name": "health",
|
||||
"description": "Health check and system status endpoints",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# CORS is handled by nginx - no need for FastAPI middleware
|
||||
|
||||
# Include routers
|
||||
# Models API is mounted at /api/models
|
||||
app.include_router(models.router)
|
||||
|
||||
# MCP API is mounted at /api/mcp
|
||||
app.include_router(mcp.router)
|
||||
|
||||
# Memory API is mounted at /api/memory
|
||||
app.include_router(memory.router)
|
||||
|
||||
# Skills API is mounted at /api/skills
|
||||
app.include_router(skills.router)
|
||||
|
||||
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
|
||||
app.include_router(artifacts.router)
|
||||
|
||||
# Uploads API is mounted at /api/threads/{thread_id}/uploads
|
||||
app.include_router(uploads.router)
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check() -> dict:
|
||||
"""Health check endpoint.
|
||||
|
||||
Returns:
|
||||
Service health status information.
|
||||
"""
|
||||
return {"status": "healthy", "service": "deer-flow-gateway"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Create app instance for uvicorn
|
||||
app = create_app()
|
||||
27
backend/src/gateway/config.py
Normal file
27
backend/src/gateway/config.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GatewayConfig(BaseModel):
|
||||
"""Configuration for the API Gateway."""
|
||||
|
||||
host: str = Field(default="0.0.0.0", description="Host to bind the gateway server")
|
||||
port: int = Field(default=8001, description="Port to bind the gateway server")
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"], description="Allowed CORS origins")
|
||||
|
||||
|
||||
_gateway_config: GatewayConfig | None = None
|
||||
|
||||
|
||||
def get_gateway_config() -> GatewayConfig:
|
||||
"""Get gateway config, loading from environment if available."""
|
||||
global _gateway_config
|
||||
if _gateway_config is None:
|
||||
cors_origins_str = os.getenv("CORS_ORIGINS", "http://localhost:3000")
|
||||
_gateway_config = GatewayConfig(
|
||||
host=os.getenv("GATEWAY_HOST", "0.0.0.0"),
|
||||
port=int(os.getenv("GATEWAY_PORT", "8001")),
|
||||
cors_origins=cors_origins_str.split(","),
|
||||
)
|
||||
return _gateway_config
|
||||
44
backend/src/gateway/path_utils.py
Normal file
44
backend/src/gateway/path_utils.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Shared path resolution for thread virtual paths (e.g. mnt/user-data/outputs/...)."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.agents.middlewares.thread_data_middleware import THREAD_DATA_BASE_DIR
|
||||
|
||||
# Virtual path prefix used in sandbox environments (without leading slash for URL path matching)
|
||||
VIRTUAL_PATH_PREFIX = "mnt/user-data"
|
||||
|
||||
|
||||
def resolve_thread_virtual_path(thread_id: str, virtual_path: str) -> Path:
|
||||
"""Resolve a virtual path to the actual filesystem path under thread user-data.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
virtual_path: The virtual path (e.g., mnt/user-data/outputs/file.txt).
|
||||
Leading slashes are stripped.
|
||||
|
||||
Returns:
|
||||
The resolved filesystem path.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the path is invalid or outside allowed directories.
|
||||
"""
|
||||
virtual_path = virtual_path.lstrip("/")
|
||||
if not virtual_path.startswith(VIRTUAL_PATH_PREFIX):
|
||||
raise HTTPException(status_code=400, detail=f"Path must start with /{VIRTUAL_PATH_PREFIX}")
|
||||
relative_path = virtual_path[len(VIRTUAL_PATH_PREFIX) :].lstrip("/")
|
||||
|
||||
base_dir = Path(os.getcwd()) / THREAD_DATA_BASE_DIR / thread_id / "user-data"
|
||||
actual_path = base_dir / relative_path
|
||||
|
||||
try:
|
||||
actual_path = actual_path.resolve()
|
||||
base_resolved = base_dir.resolve()
|
||||
if not str(actual_path).startswith(str(base_resolved)):
|
||||
raise HTTPException(status_code=403, detail="Access denied: path traversal detected")
|
||||
except (ValueError, RuntimeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
return actual_path
|
||||
3
backend/src/gateway/routers/__init__.py
Normal file
3
backend/src/gateway/routers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import artifacts, mcp, models, skills, uploads
|
||||
|
||||
__all__ = ["artifacts", "mcp", "models", "skills", "uploads"]
|
||||
158
backend/src/gateway/routers/artifacts.py
Normal file
158
backend/src/gateway/routers/artifacts.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
|
||||
|
||||
from src.gateway.path_utils import resolve_thread_virtual_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["artifacts"])
|
||||
|
||||
|
||||
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
|
||||
"""Check if file is text by examining content for null bytes."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
chunk = f.read(sample_size)
|
||||
# Text files shouldn't contain null bytes
|
||||
return b"\x00" not in chunk
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None:
|
||||
"""Extract a file from a .skill ZIP archive.
|
||||
|
||||
Args:
|
||||
zip_path: Path to the .skill file (ZIP archive).
|
||||
internal_path: Path to the file inside the archive (e.g., "SKILL.md").
|
||||
|
||||
Returns:
|
||||
The file content as bytes, or None if not found.
|
||||
"""
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
||||
# List all files in the archive
|
||||
namelist = zip_ref.namelist()
|
||||
|
||||
# Try direct path first
|
||||
if internal_path in namelist:
|
||||
return zip_ref.read(internal_path)
|
||||
|
||||
# Try with any top-level directory prefix (e.g., "skill-name/SKILL.md")
|
||||
for name in namelist:
|
||||
if name.endswith("/" + internal_path) or name == internal_path:
|
||||
return zip_ref.read(name)
|
||||
|
||||
# Not found
|
||||
return None
|
||||
except (zipfile.BadZipFile, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/threads/{thread_id}/artifacts/{path:path}",
|
||||
summary="Get Artifact File",
|
||||
description="Retrieve an artifact file generated by the AI agent. Supports text, HTML, and binary files.",
|
||||
)
|
||||
async def get_artifact(thread_id: str, path: str, request: Request) -> FileResponse:
|
||||
"""Get an artifact file by its path.
|
||||
|
||||
The endpoint automatically detects file types and returns appropriate content types.
|
||||
Use the `?download=true` query parameter to force file download.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
path: The artifact path with virtual prefix (e.g., mnt/user-data/outputs/file.txt).
|
||||
request: FastAPI request object (automatically injected).
|
||||
|
||||
Returns:
|
||||
The file content as a FileResponse with appropriate content type:
|
||||
- HTML files: Rendered as HTML
|
||||
- Text files: Plain text with proper MIME type
|
||||
- Binary files: Inline display with download option
|
||||
|
||||
Raises:
|
||||
HTTPException:
|
||||
- 400 if path is invalid or not a file
|
||||
- 403 if access denied (path traversal detected)
|
||||
- 404 if file not found
|
||||
|
||||
Query Parameters:
|
||||
download (bool): If true, returns file as attachment for download
|
||||
|
||||
Example:
|
||||
- Get HTML file: `/api/threads/abc123/artifacts/mnt/user-data/outputs/index.html`
|
||||
- Download file: `/api/threads/abc123/artifacts/mnt/user-data/outputs/data.csv?download=true`
|
||||
"""
|
||||
# Check if this is a request for a file inside a .skill archive (e.g., xxx.skill/SKILL.md)
|
||||
if ".skill/" in path:
|
||||
# Split the path at ".skill/" to get the ZIP file path and internal path
|
||||
skill_marker = ".skill/"
|
||||
marker_pos = path.find(skill_marker)
|
||||
skill_file_path = path[: marker_pos + len(".skill")] # e.g., "mnt/user-data/outputs/my-skill.skill"
|
||||
internal_path = path[marker_pos + len(skill_marker) :] # e.g., "SKILL.md"
|
||||
|
||||
actual_skill_path = resolve_thread_virtual_path(thread_id, skill_file_path)
|
||||
|
||||
if not actual_skill_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Skill file not found: {skill_file_path}")
|
||||
|
||||
if not actual_skill_path.is_file():
|
||||
raise HTTPException(status_code=400, detail=f"Path is not a file: {skill_file_path}")
|
||||
|
||||
# Extract the file from the .skill archive
|
||||
content = _extract_file_from_skill_archive(actual_skill_path, internal_path)
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail=f"File '{internal_path}' not found in skill archive")
|
||||
|
||||
# Determine MIME type based on the internal file
|
||||
mime_type, _ = mimetypes.guess_type(internal_path)
|
||||
# Add cache headers to avoid repeated ZIP extraction (cache for 5 minutes)
|
||||
cache_headers = {"Cache-Control": "private, max-age=300"}
|
||||
if mime_type and mime_type.startswith("text/"):
|
||||
return PlainTextResponse(content=content.decode("utf-8"), media_type=mime_type, headers=cache_headers)
|
||||
|
||||
# Default to plain text for unknown types that look like text
|
||||
try:
|
||||
return PlainTextResponse(content=content.decode("utf-8"), media_type="text/plain", headers=cache_headers)
|
||||
except UnicodeDecodeError:
|
||||
return Response(content=content, media_type=mime_type or "application/octet-stream", headers=cache_headers)
|
||||
|
||||
actual_path = resolve_thread_virtual_path(thread_id, path)
|
||||
|
||||
logger.info(f"Resolving artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")
|
||||
|
||||
if not actual_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
|
||||
|
||||
if not actual_path.is_file():
|
||||
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(actual_path)
|
||||
|
||||
# Encode filename for Content-Disposition header (RFC 5987)
|
||||
encoded_filename = quote(actual_path.name)
|
||||
|
||||
# if `download` query parameter is true, return the file as a download
|
||||
if request.query_params.get("download"):
|
||||
return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"})
|
||||
|
||||
if mime_type and mime_type == "text/html":
|
||||
return HTMLResponse(content=actual_path.read_text())
|
||||
|
||||
if mime_type and mime_type.startswith("text/"):
|
||||
return PlainTextResponse(content=actual_path.read_text(), media_type=mime_type)
|
||||
|
||||
if is_text_file_by_content(actual_path):
|
||||
return PlainTextResponse(content=actual_path.read_text(), media_type=mime_type)
|
||||
|
||||
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}"})
|
||||
148
backend/src/gateway/routers/mcp.py
Normal file
148
backend/src/gateway/routers/mcp.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["mcp"])
|
||||
|
||||
|
||||
class McpServerConfigResponse(BaseModel):
|
||||
"""Response model for MCP server configuration."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether this MCP server is enabled")
|
||||
type: str = Field(default="stdio", description="Transport type: 'stdio', 'sse', or 'http'")
|
||||
command: str | None = Field(default=None, description="Command to execute to start the MCP server (for stdio type)")
|
||||
args: list[str] = Field(default_factory=list, description="Arguments to pass to the command (for stdio type)")
|
||||
env: dict[str, str] = Field(default_factory=dict, description="Environment variables for the MCP server")
|
||||
url: str | None = Field(default=None, description="URL of the MCP server (for sse or http type)")
|
||||
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
|
||||
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
|
||||
|
||||
|
||||
class McpConfigResponse(BaseModel):
|
||||
"""Response model for MCP configuration."""
|
||||
|
||||
mcp_servers: dict[str, McpServerConfigResponse] = Field(
|
||||
default_factory=dict,
|
||||
description="Map of MCP server name to configuration",
|
||||
)
|
||||
|
||||
|
||||
class McpConfigUpdateRequest(BaseModel):
|
||||
"""Request model for updating MCP configuration."""
|
||||
|
||||
mcp_servers: dict[str, McpServerConfigResponse] = Field(
|
||||
...,
|
||||
description="Map of MCP server name to configuration",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/mcp/config",
|
||||
response_model=McpConfigResponse,
|
||||
summary="Get MCP Configuration",
|
||||
description="Retrieve the current Model Context Protocol (MCP) server configurations.",
|
||||
)
|
||||
async def get_mcp_configuration() -> McpConfigResponse:
|
||||
"""Get the current MCP configuration.
|
||||
|
||||
Returns:
|
||||
The current MCP configuration with all servers.
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"mcp_servers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {"GITHUB_TOKEN": "ghp_xxx"},
|
||||
"description": "GitHub MCP server for repository operations"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
config = get_extensions_config()
|
||||
|
||||
return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in config.mcp_servers.items()})
|
||||
|
||||
|
||||
@router.put(
|
||||
"/mcp/config",
|
||||
response_model=McpConfigResponse,
|
||||
summary="Update MCP Configuration",
|
||||
description="Update Model Context Protocol (MCP) server configurations and save to file.",
|
||||
)
|
||||
async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfigResponse:
|
||||
"""Update the MCP configuration.
|
||||
|
||||
This will:
|
||||
1. Save the new configuration to the mcp_config.json file
|
||||
2. Reload the configuration cache
|
||||
3. Reset MCP tools cache to trigger reinitialization
|
||||
|
||||
Args:
|
||||
request: The new MCP configuration to save.
|
||||
|
||||
Returns:
|
||||
The updated MCP configuration.
|
||||
|
||||
Raises:
|
||||
HTTPException: 500 if the configuration file cannot be written.
|
||||
|
||||
Example Request:
|
||||
```json
|
||||
{
|
||||
"mcp_servers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {"GITHUB_TOKEN": "$GITHUB_TOKEN"},
|
||||
"description": "GitHub MCP server for repository operations"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Get the current config path (or determine where to save it)
|
||||
config_path = ExtensionsConfig.resolve_config_path()
|
||||
|
||||
# If no config file exists, create one in the parent directory (project root)
|
||||
if config_path is None:
|
||||
config_path = Path.cwd().parent / "extensions_config.json"
|
||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||
|
||||
# Load current config to preserve skills configuration
|
||||
current_config = get_extensions_config()
|
||||
|
||||
# Convert request to dict format for JSON serialization
|
||||
config_data = {
|
||||
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()},
|
||||
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
|
||||
}
|
||||
|
||||
# Write the configuration to file
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
|
||||
logger.info(f"MCP configuration updated and saved to: {config_path}")
|
||||
|
||||
# NOTE: No need to reload/reset cache here - LangGraph Server (separate process)
|
||||
# will detect config file changes via mtime and reinitialize MCP tools automatically
|
||||
|
||||
# Reload the configuration and update the global cache
|
||||
reloaded_config = reload_extensions_config()
|
||||
return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in reloaded_config.mcp_servers.items()})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update MCP configuration: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update MCP configuration: {str(e)}")
|
||||
201
backend/src/gateway/routers/memory.py
Normal file
201
backend/src/gateway/routers/memory.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Memory API router for retrieving and managing global memory data."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.agents.memory.updater import get_memory_data, reload_memory_data
|
||||
from src.config.memory_config import get_memory_config
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["memory"])
|
||||
|
||||
|
||||
class ContextSection(BaseModel):
|
||||
"""Model for context sections (user and history)."""
|
||||
|
||||
summary: str = Field(default="", description="Summary content")
|
||||
updatedAt: str = Field(default="", description="Last update timestamp")
|
||||
|
||||
|
||||
class UserContext(BaseModel):
|
||||
"""Model for user context."""
|
||||
|
||||
workContext: ContextSection = Field(default_factory=ContextSection)
|
||||
personalContext: ContextSection = Field(default_factory=ContextSection)
|
||||
topOfMind: ContextSection = Field(default_factory=ContextSection)
|
||||
|
||||
|
||||
class HistoryContext(BaseModel):
|
||||
"""Model for history context."""
|
||||
|
||||
recentMonths: ContextSection = Field(default_factory=ContextSection)
|
||||
earlierContext: ContextSection = Field(default_factory=ContextSection)
|
||||
longTermBackground: ContextSection = Field(default_factory=ContextSection)
|
||||
|
||||
|
||||
class Fact(BaseModel):
|
||||
"""Model for a memory fact."""
|
||||
|
||||
id: str = Field(..., description="Unique identifier for the fact")
|
||||
content: str = Field(..., description="Fact content")
|
||||
category: str = Field(default="context", description="Fact category")
|
||||
confidence: float = Field(default=0.5, description="Confidence score (0-1)")
|
||||
createdAt: str = Field(default="", description="Creation timestamp")
|
||||
source: str = Field(default="unknown", description="Source thread ID")
|
||||
|
||||
|
||||
class MemoryResponse(BaseModel):
|
||||
"""Response model for memory data."""
|
||||
|
||||
version: str = Field(default="1.0", description="Memory schema version")
|
||||
lastUpdated: str = Field(default="", description="Last update timestamp")
|
||||
user: UserContext = Field(default_factory=UserContext)
|
||||
history: HistoryContext = Field(default_factory=HistoryContext)
|
||||
facts: list[Fact] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MemoryConfigResponse(BaseModel):
|
||||
"""Response model for memory configuration."""
|
||||
|
||||
enabled: bool = Field(..., description="Whether memory is enabled")
|
||||
storage_path: str = Field(..., description="Path to memory storage file")
|
||||
debounce_seconds: int = Field(..., description="Debounce time for memory updates")
|
||||
max_facts: int = Field(..., description="Maximum number of facts to store")
|
||||
fact_confidence_threshold: float = Field(..., description="Minimum confidence threshold for facts")
|
||||
injection_enabled: bool = Field(..., description="Whether memory injection is enabled")
|
||||
max_injection_tokens: int = Field(..., description="Maximum tokens for memory injection")
|
||||
|
||||
|
||||
class MemoryStatusResponse(BaseModel):
|
||||
"""Response model for memory status."""
|
||||
|
||||
config: MemoryConfigResponse
|
||||
data: MemoryResponse
|
||||
|
||||
|
||||
@router.get(
|
||||
"/memory",
|
||||
response_model=MemoryResponse,
|
||||
summary="Get Memory Data",
|
||||
description="Retrieve the current global memory data including user context, history, and facts.",
|
||||
)
|
||||
async def get_memory() -> MemoryResponse:
|
||||
"""Get the current global memory data.
|
||||
|
||||
Returns:
|
||||
The current memory data with user context, history, and facts.
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"lastUpdated": "2024-01-15T10:30:00Z",
|
||||
"user": {
|
||||
"workContext": {"summary": "Working on DeerFlow project", "updatedAt": "..."},
|
||||
"personalContext": {"summary": "Prefers concise responses", "updatedAt": "..."},
|
||||
"topOfMind": {"summary": "Building memory API", "updatedAt": "..."}
|
||||
},
|
||||
"history": {
|
||||
"recentMonths": {"summary": "Recent development activities", "updatedAt": "..."},
|
||||
"earlierContext": {"summary": "", "updatedAt": ""},
|
||||
"longTermBackground": {"summary": "", "updatedAt": ""}
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"id": "fact_abc123",
|
||||
"content": "User prefers TypeScript over JavaScript",
|
||||
"category": "preference",
|
||||
"confidence": 0.9,
|
||||
"createdAt": "2024-01-15T10:30:00Z",
|
||||
"source": "thread_xyz"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
memory_data = get_memory_data()
|
||||
return MemoryResponse(**memory_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/memory/reload",
|
||||
response_model=MemoryResponse,
|
||||
summary="Reload Memory Data",
|
||||
description="Reload memory data from the storage file, refreshing the in-memory cache.",
|
||||
)
|
||||
async def reload_memory() -> MemoryResponse:
|
||||
"""Reload memory data from file.
|
||||
|
||||
This forces a reload of the memory data from the storage file,
|
||||
useful when the file has been modified externally.
|
||||
|
||||
Returns:
|
||||
The reloaded memory data.
|
||||
"""
|
||||
memory_data = reload_memory_data()
|
||||
return MemoryResponse(**memory_data)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/memory/config",
|
||||
response_model=MemoryConfigResponse,
|
||||
summary="Get Memory Configuration",
|
||||
description="Retrieve the current memory system configuration.",
|
||||
)
|
||||
async def get_memory_config_endpoint() -> MemoryConfigResponse:
|
||||
"""Get the memory system configuration.
|
||||
|
||||
Returns:
|
||||
The current memory configuration settings.
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"storage_path": ".deer-flow/memory.json",
|
||||
"debounce_seconds": 30,
|
||||
"max_facts": 100,
|
||||
"fact_confidence_threshold": 0.7,
|
||||
"injection_enabled": true,
|
||||
"max_injection_tokens": 2000
|
||||
}
|
||||
```
|
||||
"""
|
||||
config = get_memory_config()
|
||||
return MemoryConfigResponse(
|
||||
enabled=config.enabled,
|
||||
storage_path=config.storage_path,
|
||||
debounce_seconds=config.debounce_seconds,
|
||||
max_facts=config.max_facts,
|
||||
fact_confidence_threshold=config.fact_confidence_threshold,
|
||||
injection_enabled=config.injection_enabled,
|
||||
max_injection_tokens=config.max_injection_tokens,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/memory/status",
|
||||
response_model=MemoryStatusResponse,
|
||||
summary="Get Memory Status",
|
||||
description="Retrieve both memory configuration and current data in a single request.",
|
||||
)
|
||||
async def get_memory_status() -> MemoryStatusResponse:
|
||||
"""Get the memory system status including configuration and data.
|
||||
|
||||
Returns:
|
||||
Combined memory configuration and current data.
|
||||
"""
|
||||
config = get_memory_config()
|
||||
memory_data = get_memory_data()
|
||||
|
||||
return MemoryStatusResponse(
|
||||
config=MemoryConfigResponse(
|
||||
enabled=config.enabled,
|
||||
storage_path=config.storage_path,
|
||||
debounce_seconds=config.debounce_seconds,
|
||||
max_facts=config.max_facts,
|
||||
fact_confidence_threshold=config.fact_confidence_threshold,
|
||||
injection_enabled=config.injection_enabled,
|
||||
max_injection_tokens=config.max_injection_tokens,
|
||||
),
|
||||
data=MemoryResponse(**memory_data),
|
||||
)
|
||||
110
backend/src/gateway/routers/models.py
Normal file
110
backend/src/gateway/routers/models.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.config import get_app_config
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["models"])
|
||||
|
||||
|
||||
class ModelResponse(BaseModel):
|
||||
"""Response model for model information."""
|
||||
|
||||
name: str = Field(..., description="Unique identifier for the model")
|
||||
display_name: str | None = Field(None, description="Human-readable name")
|
||||
description: str | None = Field(None, description="Model description")
|
||||
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
|
||||
|
||||
|
||||
class ModelsListResponse(BaseModel):
|
||||
"""Response model for listing all models."""
|
||||
|
||||
models: list[ModelResponse]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/models",
|
||||
response_model=ModelsListResponse,
|
||||
summary="List All Models",
|
||||
description="Retrieve a list of all available AI models configured in the system.",
|
||||
)
|
||||
async def list_models() -> ModelsListResponse:
|
||||
"""List all available models from configuration.
|
||||
|
||||
Returns model information suitable for frontend display,
|
||||
excluding sensitive fields like API keys and internal configuration.
|
||||
|
||||
Returns:
|
||||
A list of all configured models with their metadata.
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "gpt-4",
|
||||
"display_name": "GPT-4",
|
||||
"description": "OpenAI GPT-4 model",
|
||||
"supports_thinking": false
|
||||
},
|
||||
{
|
||||
"name": "claude-3-opus",
|
||||
"display_name": "Claude 3 Opus",
|
||||
"description": "Anthropic Claude 3 Opus model",
|
||||
"supports_thinking": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
config = get_app_config()
|
||||
models = [
|
||||
ModelResponse(
|
||||
name=model.name,
|
||||
display_name=model.display_name,
|
||||
description=model.description,
|
||||
supports_thinking=model.supports_thinking,
|
||||
)
|
||||
for model in config.models
|
||||
]
|
||||
return ModelsListResponse(models=models)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/models/{model_name}",
|
||||
response_model=ModelResponse,
|
||||
summary="Get Model Details",
|
||||
description="Retrieve detailed information about a specific AI model by its name.",
|
||||
)
|
||||
async def get_model(model_name: str) -> ModelResponse:
|
||||
"""Get a specific model by name.
|
||||
|
||||
Args:
|
||||
model_name: The unique name of the model to retrieve.
|
||||
|
||||
Returns:
|
||||
Model information if found.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if model not found.
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"name": "gpt-4",
|
||||
"display_name": "GPT-4",
|
||||
"description": "OpenAI GPT-4 model",
|
||||
"supports_thinking": false
|
||||
}
|
||||
```
|
||||
"""
|
||||
config = get_app_config()
|
||||
model = config.get_model_config(model_name)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
||||
|
||||
return ModelResponse(
|
||||
name=model.name,
|
||||
display_name=model.display_name,
|
||||
description=model.description,
|
||||
supports_thinking=model.supports_thinking,
|
||||
)
|
||||
442
backend/src/gateway/routers/skills.py
Normal file
442
backend/src/gateway/routers/skills.py
Normal file
@@ -0,0 +1,442 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||
from src.gateway.path_utils import resolve_thread_virtual_path
|
||||
from src.skills import Skill, load_skills
|
||||
from src.skills.loader import get_skills_root_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["skills"])
|
||||
|
||||
|
||||
class SkillResponse(BaseModel):
|
||||
"""Response model for skill information."""
|
||||
|
||||
name: str = Field(..., description="Name of the skill")
|
||||
description: str = Field(..., description="Description of what the skill does")
|
||||
license: str | None = Field(None, description="License information")
|
||||
category: str = Field(..., description="Category of the skill (public or custom)")
|
||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
||||
|
||||
|
||||
class SkillsListResponse(BaseModel):
|
||||
"""Response model for listing all skills."""
|
||||
|
||||
skills: list[SkillResponse]
|
||||
|
||||
|
||||
class SkillUpdateRequest(BaseModel):
|
||||
"""Request model for updating a skill."""
|
||||
|
||||
enabled: bool = Field(..., description="Whether to enable or disable the skill")
|
||||
|
||||
|
||||
class SkillInstallRequest(BaseModel):
|
||||
"""Request model for installing a skill from a .skill file."""
|
||||
|
||||
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
|
||||
path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
|
||||
|
||||
|
||||
class SkillInstallResponse(BaseModel):
|
||||
"""Response model for skill installation."""
|
||||
|
||||
success: bool = Field(..., description="Whether the installation was successful")
|
||||
skill_name: str = Field(..., description="Name of the installed skill")
|
||||
message: str = Field(..., description="Installation result message")
|
||||
|
||||
|
||||
# Allowed properties in SKILL.md frontmatter
|
||||
ALLOWED_FRONTMATTER_PROPERTIES = {"name", "description", "license", "allowed-tools", "metadata"}
|
||||
|
||||
|
||||
def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]:
|
||||
"""Validate a skill directory's SKILL.md frontmatter.
|
||||
|
||||
Args:
|
||||
skill_dir: Path to the skill directory containing SKILL.md.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, message, skill_name).
|
||||
"""
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
return False, "SKILL.md not found", None
|
||||
|
||||
content = skill_md.read_text()
|
||||
if not content.startswith("---"):
|
||||
return False, "No YAML frontmatter found", None
|
||||
|
||||
# Extract frontmatter
|
||||
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||
if not match:
|
||||
return False, "Invalid frontmatter format", None
|
||||
|
||||
frontmatter_text = match.group(1)
|
||||
|
||||
# Parse YAML frontmatter
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_text)
|
||||
if not isinstance(frontmatter, dict):
|
||||
return False, "Frontmatter must be a YAML dictionary", None
|
||||
except yaml.YAMLError as e:
|
||||
return False, f"Invalid YAML in frontmatter: {e}", None
|
||||
|
||||
# Check for unexpected properties
|
||||
unexpected_keys = set(frontmatter.keys()) - ALLOWED_FRONTMATTER_PROPERTIES
|
||||
if unexpected_keys:
|
||||
return False, f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}", None
|
||||
|
||||
# Check required fields
|
||||
if "name" not in frontmatter:
|
||||
return False, "Missing 'name' in frontmatter", None
|
||||
if "description" not in frontmatter:
|
||||
return False, "Missing 'description' in frontmatter", None
|
||||
|
||||
# Validate name
|
||||
name = frontmatter.get("name", "")
|
||||
if not isinstance(name, str):
|
||||
return False, f"Name must be a string, got {type(name).__name__}", None
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return False, "Name cannot be empty", None
|
||||
|
||||
# Check naming convention (hyphen-case: lowercase with hyphens)
|
||||
if not re.match(r"^[a-z0-9-]+$", name):
|
||||
return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", None
|
||||
if name.startswith("-") or name.endswith("-") or "--" in name:
|
||||
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", None
|
||||
if len(name) > 64:
|
||||
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters.", None
|
||||
|
||||
# Validate description
|
||||
description = frontmatter.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
return False, f"Description must be a string, got {type(description).__name__}", None
|
||||
description = description.strip()
|
||||
if description:
|
||||
if "<" in description or ">" in description:
|
||||
return False, "Description cannot contain angle brackets (< or >)", None
|
||||
if len(description) > 1024:
|
||||
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", None
|
||||
|
||||
return True, "Skill is valid!", name
|
||||
|
||||
|
||||
def _skill_to_response(skill: Skill) -> SkillResponse:
|
||||
"""Convert a Skill object to a SkillResponse."""
|
||||
return SkillResponse(
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
license=skill.license,
|
||||
category=skill.category,
|
||||
enabled=skill.enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/skills",
|
||||
response_model=SkillsListResponse,
|
||||
summary="List All Skills",
|
||||
description="Retrieve a list of all available skills from both public and custom directories.",
|
||||
)
|
||||
async def list_skills() -> SkillsListResponse:
|
||||
"""List all available skills.
|
||||
|
||||
Returns all skills regardless of their enabled status.
|
||||
|
||||
Returns:
|
||||
A list of all skills with their metadata.
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "PDF Processing",
|
||||
"description": "Extract and analyze PDF content",
|
||||
"license": "MIT",
|
||||
"category": "public",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "Frontend Design",
|
||||
"description": "Generate frontend designs and components",
|
||||
"license": null,
|
||||
"category": "custom",
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Load all skills (including disabled ones)
|
||||
skills = load_skills(enabled_only=False)
|
||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/skills/{skill_name}",
|
||||
response_model=SkillResponse,
|
||||
summary="Get Skill Details",
|
||||
description="Retrieve detailed information about a specific skill by its name.",
|
||||
)
|
||||
async def get_skill(skill_name: str) -> SkillResponse:
|
||||
"""Get a specific skill by name.
|
||||
|
||||
Args:
|
||||
skill_name: The name of the skill to retrieve.
|
||||
|
||||
Returns:
|
||||
Skill information if found.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if skill not found.
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"name": "PDF Processing",
|
||||
"description": "Extract and analyze PDF content",
|
||||
"license": "MIT",
|
||||
"category": "public",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
skills = load_skills(enabled_only=False)
|
||||
skill = next((s for s in skills if s.name == skill_name), None)
|
||||
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||
|
||||
return _skill_to_response(skill)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/skills/{skill_name}",
|
||||
response_model=SkillResponse,
|
||||
summary="Update Skill",
|
||||
description="Update a skill's enabled status by modifying the skills_state_config.json file.",
|
||||
)
|
||||
async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillResponse:
|
||||
"""Update a skill's enabled status.
|
||||
|
||||
This will modify the skills_state_config.json file to update the enabled state.
|
||||
The SKILL.md file itself is not modified.
|
||||
|
||||
Args:
|
||||
skill_name: The name of the skill to update.
|
||||
request: The update request containing the new enabled status.
|
||||
|
||||
Returns:
|
||||
The updated skill information.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if skill not found, 500 if update fails.
|
||||
|
||||
Example Request:
|
||||
```json
|
||||
{
|
||||
"enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"name": "PDF Processing",
|
||||
"description": "Extract and analyze PDF content",
|
||||
"license": "MIT",
|
||||
"category": "public",
|
||||
"enabled": false
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Find the skill to verify it exists
|
||||
skills = load_skills(enabled_only=False)
|
||||
skill = next((s for s in skills if s.name == skill_name), None)
|
||||
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||
|
||||
# Get or create config path
|
||||
config_path = ExtensionsConfig.resolve_config_path()
|
||||
if config_path is None:
|
||||
# Create new config file in parent directory (project root)
|
||||
config_path = Path.cwd().parent / "extensions_config.json"
|
||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||
|
||||
# Load current configuration
|
||||
extensions_config = get_extensions_config()
|
||||
|
||||
# Update the skill's enabled status
|
||||
extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled)
|
||||
|
||||
# Convert to JSON format (preserve MCP servers config)
|
||||
config_data = {
|
||||
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
|
||||
"skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()},
|
||||
}
|
||||
|
||||
# Write the configuration to file
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
|
||||
logger.info(f"Skills configuration updated and saved to: {config_path}")
|
||||
|
||||
# Reload the extensions config to update the global cache
|
||||
reload_extensions_config()
|
||||
|
||||
# Reload the skills to get the updated status (for API response)
|
||||
skills = load_skills(enabled_only=False)
|
||||
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
||||
|
||||
if updated_skill is None:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
|
||||
|
||||
logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}")
|
||||
return _skill_to_response(updated_skill)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/install",
|
||||
response_model=SkillInstallResponse,
|
||||
summary="Install Skill",
|
||||
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
||||
)
|
||||
async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse:
|
||||
"""Install a skill from a .skill file.
|
||||
|
||||
The .skill file is a ZIP archive containing a skill directory with SKILL.md
|
||||
and optional resources (scripts, references, assets).
|
||||
|
||||
Args:
|
||||
request: The install request containing thread_id and virtual path to .skill file.
|
||||
|
||||
Returns:
|
||||
Installation result with skill name and status message.
|
||||
|
||||
Raises:
|
||||
HTTPException:
|
||||
- 400 if path is invalid or file is not a valid .skill file
|
||||
- 403 if access denied (path traversal detected)
|
||||
- 404 if file not found
|
||||
- 409 if skill already exists
|
||||
- 500 if installation fails
|
||||
|
||||
Example Request:
|
||||
```json
|
||||
{
|
||||
"thread_id": "abc123-def456",
|
||||
"path": "/mnt/user-data/outputs/my-skill.skill"
|
||||
}
|
||||
```
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"skill_name": "my-skill",
|
||||
"message": "Skill 'my-skill' installed successfully"
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Resolve the virtual path to actual file path
|
||||
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
||||
|
||||
# Check if file exists
|
||||
if not skill_file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Skill file not found: {request.path}")
|
||||
|
||||
# Check if it's a file
|
||||
if not skill_file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail=f"Path is not a file: {request.path}")
|
||||
|
||||
# Check file extension
|
||||
if not skill_file_path.suffix == ".skill":
|
||||
raise HTTPException(status_code=400, detail="File must have .skill extension")
|
||||
|
||||
# Verify it's a valid ZIP file
|
||||
if not zipfile.is_zipfile(skill_file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a valid ZIP archive")
|
||||
|
||||
# Get the custom skills directory
|
||||
skills_root = get_skills_root_path()
|
||||
custom_skills_dir = skills_root / "custom"
|
||||
|
||||
# Create custom directory if it doesn't exist
|
||||
custom_skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Extract to a temporary directory first for validation
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Extract the .skill file
|
||||
with zipfile.ZipFile(skill_file_path, "r") as zip_ref:
|
||||
zip_ref.extractall(temp_path)
|
||||
|
||||
# Find the skill directory (should be the only top-level directory)
|
||||
extracted_items = list(temp_path.iterdir())
|
||||
if len(extracted_items) == 0:
|
||||
raise HTTPException(status_code=400, detail="Skill archive is empty")
|
||||
|
||||
# Handle both cases: single directory or files directly in root
|
||||
if len(extracted_items) == 1 and extracted_items[0].is_dir():
|
||||
skill_dir = extracted_items[0]
|
||||
else:
|
||||
# Files are directly in the archive root
|
||||
skill_dir = temp_path
|
||||
|
||||
# Validate the skill
|
||||
is_valid, message, skill_name = _validate_skill_frontmatter(skill_dir)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid skill: {message}")
|
||||
|
||||
if not skill_name:
|
||||
raise HTTPException(status_code=400, detail="Could not determine skill name")
|
||||
|
||||
# Check if skill already exists
|
||||
target_dir = custom_skills_dir / skill_name
|
||||
if target_dir.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Skill '{skill_name}' already exists. Please remove it first or use a different name.")
|
||||
|
||||
# Move the skill directory to the custom skills directory
|
||||
shutil.copytree(skill_dir, target_dir)
|
||||
|
||||
logger.info(f"Skill '{skill_name}' installed successfully to {target_dir}")
|
||||
return SkillInstallResponse(success=True, skill_name=skill_name, message=f"Skill '{skill_name}' installed successfully")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to install skill: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
|
||||
216
backend/src/gateway/routers/uploads.py
Normal file
216
backend/src/gateway/routers/uploads.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Upload router for handling file uploads."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.agents.middlewares.thread_data_middleware import THREAD_DATA_BASE_DIR
|
||||
from src.sandbox.sandbox_provider import get_sandbox_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/threads/{thread_id}/uploads", tags=["uploads"])
|
||||
|
||||
# File extensions that should be converted to markdown
|
||||
CONVERTIBLE_EXTENSIONS = {
|
||||
".pdf",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".doc",
|
||||
".docx",
|
||||
}
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
"""Response model for file upload."""
|
||||
|
||||
success: bool
|
||||
files: list[dict[str, str]]
|
||||
message: str
|
||||
|
||||
|
||||
def get_uploads_dir(thread_id: str) -> Path:
|
||||
"""Get the uploads directory for a thread.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
|
||||
Returns:
|
||||
Path to the uploads directory.
|
||||
"""
|
||||
base_dir = Path(os.getcwd()) / THREAD_DATA_BASE_DIR / thread_id / "user-data" / "uploads"
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
return base_dir
|
||||
|
||||
|
||||
async def convert_file_to_markdown(file_path: Path) -> Path | None:
|
||||
"""Convert a file to markdown using markitdown.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to convert.
|
||||
|
||||
Returns:
|
||||
Path to the markdown file if conversion was successful, None otherwise.
|
||||
"""
|
||||
try:
|
||||
from markitdown import MarkItDown
|
||||
|
||||
md = MarkItDown()
|
||||
result = md.convert(str(file_path))
|
||||
|
||||
# Save as .md file with same name
|
||||
md_path = file_path.with_suffix(".md")
|
||||
md_path.write_text(result.text_content, encoding="utf-8")
|
||||
|
||||
logger.info(f"Converted {file_path.name} to markdown: {md_path.name}")
|
||||
return md_path
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to convert {file_path.name} to markdown: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@router.post("", response_model=UploadResponse)
|
||||
async def upload_files(
|
||||
thread_id: str,
|
||||
files: list[UploadFile] = File(...),
|
||||
) -> UploadResponse:
|
||||
"""Upload multiple files to a thread's uploads directory.
|
||||
|
||||
For PDF, PPT, Excel, and Word files, they will be converted to markdown using markitdown.
|
||||
All files (original and converted) are saved to /mnt/user-data/uploads.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID to upload files to.
|
||||
files: List of files to upload.
|
||||
|
||||
Returns:
|
||||
Upload response with success status and file information.
|
||||
"""
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="No files provided")
|
||||
|
||||
uploads_dir = get_uploads_dir(thread_id)
|
||||
uploaded_files = []
|
||||
|
||||
sandbox_provider = get_sandbox_provider()
|
||||
sandbox_id = sandbox_provider.acquire(thread_id)
|
||||
sandbox = sandbox_provider.get(sandbox_id)
|
||||
|
||||
for file in files:
|
||||
if not file.filename:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Save the original file
|
||||
file_path = uploads_dir / file.filename
|
||||
content = await file.read()
|
||||
|
||||
# Build relative path from backend root
|
||||
relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{file.filename}"
|
||||
virtual_path = f"/mnt/user-data/uploads/{file.filename}"
|
||||
sandbox.update_file(virtual_path, content)
|
||||
|
||||
file_info = {
|
||||
"filename": file.filename,
|
||||
"size": str(len(content)),
|
||||
"path": relative_path, # Actual filesystem path (relative to backend/)
|
||||
"virtual_path": virtual_path, # Path for Agent in sandbox
|
||||
"artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{file.filename}", # HTTP URL
|
||||
}
|
||||
|
||||
logger.info(f"Saved file: {file.filename} ({len(content)} bytes) to {relative_path}")
|
||||
|
||||
# Check if file should be converted to markdown
|
||||
file_ext = file_path.suffix.lower()
|
||||
if file_ext in CONVERTIBLE_EXTENSIONS:
|
||||
md_path = await convert_file_to_markdown(file_path)
|
||||
if md_path:
|
||||
md_relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{md_path.name}"
|
||||
file_info["markdown_file"] = md_path.name
|
||||
file_info["markdown_path"] = md_relative_path
|
||||
file_info["markdown_virtual_path"] = f"/mnt/user-data/uploads/{md_path.name}"
|
||||
file_info["markdown_artifact_url"] = f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{md_path.name}"
|
||||
|
||||
uploaded_files.append(file_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload {file.filename}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to upload {file.filename}: {str(e)}")
|
||||
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
files=uploaded_files,
|
||||
message=f"Successfully uploaded {len(uploaded_files)} file(s)",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=dict)
|
||||
async def list_uploaded_files(thread_id: str) -> dict:
|
||||
"""List all files in a thread's uploads directory.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID to list files for.
|
||||
|
||||
Returns:
|
||||
Dictionary containing list of files with their metadata.
|
||||
"""
|
||||
uploads_dir = get_uploads_dir(thread_id)
|
||||
|
||||
if not uploads_dir.exists():
|
||||
return {"files": [], "count": 0}
|
||||
|
||||
files = []
|
||||
for file_path in sorted(uploads_dir.iterdir()):
|
||||
if file_path.is_file():
|
||||
stat = file_path.stat()
|
||||
relative_path = f".deer-flow/threads/{thread_id}/user-data/uploads/{file_path.name}"
|
||||
files.append(
|
||||
{
|
||||
"filename": file_path.name,
|
||||
"size": stat.st_size,
|
||||
"path": relative_path, # Actual filesystem path (relative to backend/)
|
||||
"virtual_path": f"/mnt/user-data/uploads/{file_path.name}", # Path for Agent in sandbox
|
||||
"artifact_url": f"/api/threads/{thread_id}/artifacts/mnt/user-data/uploads/{file_path.name}", # HTTP URL
|
||||
"extension": file_path.suffix,
|
||||
"modified": stat.st_mtime,
|
||||
}
|
||||
)
|
||||
|
||||
return {"files": files, "count": len(files)}
|
||||
|
||||
|
||||
@router.delete("/{filename}")
|
||||
async def delete_uploaded_file(thread_id: str, filename: str) -> dict:
|
||||
"""Delete a file from a thread's uploads directory.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
filename: The filename to delete.
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
"""
|
||||
uploads_dir = get_uploads_dir(thread_id)
|
||||
file_path = uploads_dir / filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
|
||||
|
||||
# Security check: ensure the path is within the uploads directory
|
||||
try:
|
||||
file_path.resolve().relative_to(uploads_dir.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
try:
|
||||
file_path.unlink()
|
||||
logger.info(f"Deleted file: {filename}")
|
||||
return {"success": True, "message": f"Deleted {filename}"}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete {filename}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete {filename}: {str(e)}")
|
||||
14
backend/src/mcp/__init__.py
Normal file
14
backend/src/mcp/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""MCP (Model Context Protocol) integration using langchain-mcp-adapters."""
|
||||
|
||||
from .cache import get_cached_mcp_tools, initialize_mcp_tools, reset_mcp_tools_cache
|
||||
from .client import build_server_params, build_servers_config
|
||||
from .tools import get_mcp_tools
|
||||
|
||||
__all__ = [
|
||||
"build_server_params",
|
||||
"build_servers_config",
|
||||
"get_mcp_tools",
|
||||
"initialize_mcp_tools",
|
||||
"get_cached_mcp_tools",
|
||||
"reset_mcp_tools_cache",
|
||||
]
|
||||
138
backend/src/mcp/cache.py
Normal file
138
backend/src/mcp/cache.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Cache for MCP tools to avoid repeated loading."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_mcp_tools_cache: list[BaseTool] | None = None
|
||||
_cache_initialized = False
|
||||
_initialization_lock = asyncio.Lock()
|
||||
_config_mtime: float | None = None # Track config file modification time
|
||||
|
||||
|
||||
def _get_config_mtime() -> float | None:
|
||||
"""Get the modification time of the extensions config file.
|
||||
|
||||
Returns:
|
||||
The modification time as a float, or None if the file doesn't exist.
|
||||
"""
|
||||
from src.config.extensions_config import ExtensionsConfig
|
||||
|
||||
config_path = ExtensionsConfig.resolve_config_path()
|
||||
if config_path and config_path.exists():
|
||||
return os.path.getmtime(config_path)
|
||||
return None
|
||||
|
||||
|
||||
def _is_cache_stale() -> bool:
|
||||
"""Check if the cache is stale due to config file changes.
|
||||
|
||||
Returns:
|
||||
True if the cache should be invalidated, False otherwise.
|
||||
"""
|
||||
global _config_mtime
|
||||
|
||||
if not _cache_initialized:
|
||||
return False # Not initialized yet, not stale
|
||||
|
||||
current_mtime = _get_config_mtime()
|
||||
|
||||
# If we couldn't get mtime before or now, assume not stale
|
||||
if _config_mtime is None or current_mtime is None:
|
||||
return False
|
||||
|
||||
# If the config file has been modified since we cached, it's stale
|
||||
if current_mtime > _config_mtime:
|
||||
logger.info(f"MCP config file has been modified (mtime: {_config_mtime} -> {current_mtime}), cache is stale")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def initialize_mcp_tools() -> list[BaseTool]:
|
||||
"""Initialize and cache MCP tools.
|
||||
|
||||
This should be called once at application startup.
|
||||
|
||||
Returns:
|
||||
List of LangChain tools from all enabled MCP servers.
|
||||
"""
|
||||
global _mcp_tools_cache, _cache_initialized, _config_mtime
|
||||
|
||||
async with _initialization_lock:
|
||||
if _cache_initialized:
|
||||
logger.info("MCP tools already initialized")
|
||||
return _mcp_tools_cache or []
|
||||
|
||||
from src.mcp.tools import get_mcp_tools
|
||||
|
||||
logger.info("Initializing MCP tools...")
|
||||
_mcp_tools_cache = await get_mcp_tools()
|
||||
_cache_initialized = True
|
||||
_config_mtime = _get_config_mtime() # Record config file mtime
|
||||
logger.info(f"MCP tools initialized: {len(_mcp_tools_cache)} tool(s) loaded (config mtime: {_config_mtime})")
|
||||
|
||||
return _mcp_tools_cache
|
||||
|
||||
|
||||
def get_cached_mcp_tools() -> list[BaseTool]:
|
||||
"""Get cached MCP tools with lazy initialization.
|
||||
|
||||
If tools are not initialized, automatically initializes them.
|
||||
This ensures MCP tools work in both FastAPI and LangGraph Studio contexts.
|
||||
|
||||
Also checks if the config file has been modified since last initialization,
|
||||
and re-initializes if needed. This ensures that changes made through the
|
||||
Gateway API (which runs in a separate process) are reflected in the
|
||||
LangGraph Server.
|
||||
|
||||
Returns:
|
||||
List of cached MCP tools.
|
||||
"""
|
||||
global _cache_initialized
|
||||
|
||||
# Check if cache is stale due to config file changes
|
||||
if _is_cache_stale():
|
||||
logger.info("MCP cache is stale, resetting for re-initialization...")
|
||||
reset_mcp_tools_cache()
|
||||
|
||||
if not _cache_initialized:
|
||||
logger.info("MCP tools not initialized, performing lazy initialization...")
|
||||
try:
|
||||
# Try to initialize in the current event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# If loop is already running (e.g., in LangGraph Studio),
|
||||
# we need to create a new loop in a thread
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, initialize_mcp_tools())
|
||||
future.result()
|
||||
else:
|
||||
# If no loop is running, we can use the current loop
|
||||
loop.run_until_complete(initialize_mcp_tools())
|
||||
except RuntimeError:
|
||||
# No event loop exists, create one
|
||||
asyncio.run(initialize_mcp_tools())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to lazy-initialize MCP tools: {e}")
|
||||
return []
|
||||
|
||||
return _mcp_tools_cache or []
|
||||
|
||||
|
||||
def reset_mcp_tools_cache() -> None:
|
||||
"""Reset the MCP tools cache.
|
||||
|
||||
This is useful for testing or when you want to reload MCP tools.
|
||||
"""
|
||||
global _mcp_tools_cache, _cache_initialized, _config_mtime
|
||||
_mcp_tools_cache = None
|
||||
_cache_initialized = False
|
||||
_config_mtime = None
|
||||
logger.info("MCP tools cache reset")
|
||||
68
backend/src/mcp/client.py
Normal file
68
backend/src/mcp/client.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""MCP client using langchain-mcp-adapters."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from src.config.extensions_config import ExtensionsConfig, McpServerConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_server_params(server_name: str, config: McpServerConfig) -> dict[str, Any]:
|
||||
"""Build server parameters for MultiServerMCPClient.
|
||||
|
||||
Args:
|
||||
server_name: Name of the MCP server.
|
||||
config: Configuration for the MCP server.
|
||||
|
||||
Returns:
|
||||
Dictionary of server parameters for langchain-mcp-adapters.
|
||||
"""
|
||||
transport_type = config.type or "stdio"
|
||||
params: dict[str, Any] = {"transport": transport_type}
|
||||
|
||||
if transport_type == "stdio":
|
||||
if not config.command:
|
||||
raise ValueError(f"MCP server '{server_name}' with stdio transport requires 'command' field")
|
||||
params["command"] = config.command
|
||||
params["args"] = config.args
|
||||
# Add environment variables if present
|
||||
if config.env:
|
||||
params["env"] = config.env
|
||||
elif transport_type in ("sse", "http"):
|
||||
if not config.url:
|
||||
raise ValueError(f"MCP server '{server_name}' with {transport_type} transport requires 'url' field")
|
||||
params["url"] = config.url
|
||||
# Add headers if present
|
||||
if config.headers:
|
||||
params["headers"] = config.headers
|
||||
else:
|
||||
raise ValueError(f"MCP server '{server_name}' has unsupported transport type: {transport_type}")
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def build_servers_config(extensions_config: ExtensionsConfig) -> dict[str, dict[str, Any]]:
|
||||
"""Build servers configuration for MultiServerMCPClient.
|
||||
|
||||
Args:
|
||||
extensions_config: Extensions configuration containing all MCP servers.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping server names to their parameters.
|
||||
"""
|
||||
enabled_servers = extensions_config.get_enabled_mcp_servers()
|
||||
|
||||
if not enabled_servers:
|
||||
logger.info("No enabled MCP servers found")
|
||||
return {}
|
||||
|
||||
servers_config = {}
|
||||
for server_name, server_config in enabled_servers.items():
|
||||
try:
|
||||
servers_config[server_name] = build_server_params(server_name, server_config)
|
||||
logger.info(f"Configured MCP server: {server_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure MCP server '{server_name}': {e}")
|
||||
|
||||
return servers_config
|
||||
49
backend/src/mcp/tools.py
Normal file
49
backend/src/mcp/tools.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Load MCP tools using langchain-mcp-adapters."""
|
||||
|
||||
import logging
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from src.config.extensions_config import ExtensionsConfig
|
||||
from src.mcp.client import build_servers_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_mcp_tools() -> list[BaseTool]:
|
||||
"""Get all tools from enabled MCP servers.
|
||||
|
||||
Returns:
|
||||
List of LangChain tools from all enabled MCP servers.
|
||||
"""
|
||||
try:
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
except ImportError:
|
||||
logger.warning("langchain-mcp-adapters not installed. Install it to enable MCP tools: pip install langchain-mcp-adapters")
|
||||
return []
|
||||
|
||||
# NOTE: We use ExtensionsConfig.from_file() instead of get_extensions_config()
|
||||
# to always read the latest configuration from disk. This ensures that changes
|
||||
# made through the Gateway API (which runs in a separate process) are immediately
|
||||
# reflected when initializing MCP tools.
|
||||
extensions_config = ExtensionsConfig.from_file()
|
||||
servers_config = build_servers_config(extensions_config)
|
||||
|
||||
if not servers_config:
|
||||
logger.info("No enabled MCP servers configured")
|
||||
return []
|
||||
|
||||
try:
|
||||
# Create the multi-server MCP client
|
||||
logger.info(f"Initializing MCP client with {len(servers_config)} server(s)")
|
||||
client = MultiServerMCPClient(servers_config)
|
||||
|
||||
# Get all tools from all servers
|
||||
tools = await client.get_tools()
|
||||
logger.info(f"Successfully loaded {len(tools)} tool(s) from MCP servers")
|
||||
|
||||
return tools
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load MCP tools: {e}", exc_info=True)
|
||||
return []
|
||||
3
backend/src/models/__init__.py
Normal file
3
backend/src/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .factory import create_chat_model
|
||||
|
||||
__all__ = ["create_chat_model"]
|
||||
40
backend/src/models/factory.py
Normal file
40
backend/src/models/factory.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from langchain.chat_models import BaseChatModel
|
||||
|
||||
from src.config import get_app_config
|
||||
from src.reflection import resolve_class
|
||||
|
||||
|
||||
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, **kwargs) -> BaseChatModel:
|
||||
"""Create a chat model instance from the config.
|
||||
|
||||
Args:
|
||||
name: The name of the model to create. If None, the first model in the config will be used.
|
||||
|
||||
Returns:
|
||||
A chat model instance.
|
||||
"""
|
||||
config = get_app_config()
|
||||
if name is None:
|
||||
name = config.models[0].name
|
||||
model_config = config.get_model_config(name)
|
||||
if model_config is None:
|
||||
raise ValueError(f"Model {name} not found in config") from None
|
||||
model_class = resolve_class(model_config.use, BaseChatModel)
|
||||
model_settings_from_config = model_config.model_dump(
|
||||
exclude_none=True,
|
||||
exclude={
|
||||
"use",
|
||||
"name",
|
||||
"display_name",
|
||||
"description",
|
||||
"supports_thinking",
|
||||
"when_thinking_enabled",
|
||||
"supports_vision",
|
||||
},
|
||||
)
|
||||
if thinking_enabled and model_config.when_thinking_enabled is not None:
|
||||
if not model_config.supports_thinking:
|
||||
raise ValueError(f"Model {name} does not support thinking. Set `supports_thinking` to true in the `config.yaml` to enable thinking.") from None
|
||||
model_settings_from_config.update(model_config.when_thinking_enabled)
|
||||
model_instance = model_class(**kwargs, **model_settings_from_config)
|
||||
return model_instance
|
||||
65
backend/src/models/patched_deepseek.py
Normal file
65
backend/src/models/patched_deepseek.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Patched ChatDeepSeek that preserves reasoning_content in multi-turn conversations.
|
||||
|
||||
This module provides a patched version of ChatDeepSeek that properly handles
|
||||
reasoning_content when sending messages back to the API. The original implementation
|
||||
stores reasoning_content in additional_kwargs but doesn't include it when making
|
||||
subsequent API calls, which causes errors with APIs that require reasoning_content
|
||||
on all assistant messages when thinking mode is enabled.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
|
||||
class PatchedChatDeepSeek(ChatDeepSeek):
|
||||
"""ChatDeepSeek with proper reasoning_content preservation.
|
||||
|
||||
When using thinking/reasoning enabled models, the API expects reasoning_content
|
||||
to be present on ALL assistant messages in multi-turn conversations. This patched
|
||||
version ensures reasoning_content from additional_kwargs is included in the
|
||||
request payload.
|
||||
"""
|
||||
|
||||
def _get_request_payload(
|
||||
self,
|
||||
input_: LanguageModelInput,
|
||||
*,
|
||||
stop: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict:
|
||||
"""Get request payload with reasoning_content preserved.
|
||||
|
||||
Overrides the parent method to inject reasoning_content from
|
||||
additional_kwargs into assistant messages in the payload.
|
||||
"""
|
||||
# Get the original messages before conversion
|
||||
original_messages = self._convert_input(input_).to_messages()
|
||||
|
||||
# Call parent to get the base payload
|
||||
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
||||
|
||||
# Match payload messages with original messages to restore reasoning_content
|
||||
payload_messages = payload.get("messages", [])
|
||||
|
||||
# The payload messages and original messages should be in the same order
|
||||
# Iterate through both and match by position
|
||||
if len(payload_messages) == len(original_messages):
|
||||
for payload_msg, orig_msg in zip(payload_messages, original_messages):
|
||||
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
|
||||
reasoning_content = orig_msg.additional_kwargs.get("reasoning_content")
|
||||
if reasoning_content is not None:
|
||||
payload_msg["reasoning_content"] = reasoning_content
|
||||
else:
|
||||
# Fallback: match by counting assistant messages
|
||||
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
|
||||
assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"]
|
||||
|
||||
for (idx, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
|
||||
reasoning_content = ai_msg.additional_kwargs.get("reasoning_content")
|
||||
if reasoning_content is not None:
|
||||
payload_messages[idx]["reasoning_content"] = reasoning_content
|
||||
|
||||
return payload
|
||||
3
backend/src/reflection/__init__.py
Normal file
3
backend/src/reflection/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .resolvers import resolve_class, resolve_variable
|
||||
|
||||
__all__ = ["resolve_class", "resolve_variable"]
|
||||
71
backend/src/reflection/resolvers.py
Normal file
71
backend/src/reflection/resolvers.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from importlib import import_module
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def resolve_variable[T](
|
||||
variable_path: str,
|
||||
expected_type: type[T] | tuple[type, ...] | None = None,
|
||||
) -> T:
|
||||
"""Resolve a variable from a path.
|
||||
|
||||
Args:
|
||||
variable_path: The path to the variable (e.g. "parent_package_name.sub_package_name.module_name:variable_name").
|
||||
expected_type: Optional type or tuple of types to validate the resolved variable against.
|
||||
If provided, uses isinstance() to check if the variable is an instance of the expected type(s).
|
||||
|
||||
Returns:
|
||||
The resolved variable.
|
||||
|
||||
Raises:
|
||||
ImportError: If the module path is invalid or the attribute doesn't exist.
|
||||
ValueError: If the resolved variable doesn't pass the validation checks.
|
||||
"""
|
||||
try:
|
||||
module_path, variable_name = variable_path.rsplit(":", 1)
|
||||
except ValueError as err:
|
||||
raise ImportError(f"{variable_path} doesn't look like a variable path. Example: parent_package_name.sub_package_name.module_name:variable_name") from err
|
||||
|
||||
try:
|
||||
module = import_module(module_path)
|
||||
except ImportError as err:
|
||||
raise ImportError(f"Could not import module {module_path}") from err
|
||||
|
||||
try:
|
||||
variable = getattr(module, variable_name)
|
||||
except AttributeError as err:
|
||||
raise ImportError(f"Module {module_path} does not define a {variable_name} attribute/class") from err
|
||||
|
||||
# Type validation
|
||||
if expected_type is not None:
|
||||
if not isinstance(variable, expected_type):
|
||||
type_name = expected_type.__name__ if isinstance(expected_type, type) else " or ".join(t.__name__ for t in expected_type)
|
||||
raise ValueError(f"{variable_path} is not an instance of {type_name}, got {type(variable).__name__}")
|
||||
|
||||
return variable
|
||||
|
||||
|
||||
def resolve_class[T](class_path: str, base_class: type[T] | None = None) -> type[T]:
|
||||
"""Resolve a class from a module path and class name.
|
||||
|
||||
Args:
|
||||
class_path: The path to the class (e.g. "langchain_openai:ChatOpenAI").
|
||||
base_class: The base class to check if the resolved class is a subclass of.
|
||||
|
||||
Returns:
|
||||
The resolved class.
|
||||
|
||||
Raises:
|
||||
ImportError: If the module path is invalid or the attribute doesn't exist.
|
||||
ValueError: If the resolved object is not a class or not a subclass of base_class.
|
||||
"""
|
||||
model_class = resolve_variable(class_path, expected_type=type)
|
||||
|
||||
if not isinstance(model_class, type):
|
||||
raise ValueError(f"{class_path} is not a valid class")
|
||||
|
||||
if base_class is not None and not issubclass(model_class, base_class):
|
||||
raise ValueError(f"{class_path} is not a subclass of {base_class.__name__}")
|
||||
|
||||
return model_class
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user