Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7052978a43 | |||
| d9f7f658be | |||
| a55de566b9 | |||
| 9dc25987e0 | |||
| 8a044142cb | |||
| 410f0c48b5 | |||
| 1f59e945af | |||
| f394c0d8c8 | |||
| 950821cb9b | |||
| 2bb1a2dfa2 | |||
| b970993425 | |||
| ec8a8cae38 | |||
| d78ed5c8f2 | |||
| f9ff3a698d | |||
| c2332bb790 | |||
| 3a61126824 | |||
| 11f557a2c6 | |||
| e8572b9d0c | |||
| 80a7446fd6 | |||
| cd12821134 | |||
| 30d619de08 | |||
| 4e72410154 | |||
| c42ae3af79 | |||
| bd35cd39aa | |||
| b90f219bd1 | |||
| 96d00f6073 | |||
| c43c803f66 | |||
| dbd777fe62 | |||
| 1ca2621285 | |||
| 5ba1dacf25 | |||
| 085c13edc7 | |||
| ef04174194 | |||
| 6dce26a52e | |||
| fc94e90f6c | |||
| f2013f47aa | |||
| 4be857f64b | |||
| c99865f53d | |||
| 05f1da03e5 | |||
| a62ca5dd47 | |||
| f514e35a36 | |||
| 7c87dc5bca | |||
| 80e210f5bb | |||
| 5656f90792 | |||
| 55474011c9 | |||
| 24fe5fbd8c | |||
| be4663505a | |||
| aa6098e6a4 | |||
| 1221448029 | |||
| 3b91df2b18 | |||
| ca1b7d5f48 | |||
| c6b0423558 | |||
| 898f4e8ac2 | |||
| 259a6844bf | |||
| a664d2f5c4 | |||
| 105db00987 | |||
| 0e16a7fe55 | |||
| 4d3038a7b6 | |||
| 2176b2bbfc | |||
| 8e3591312a | |||
| 242c654075 | |||
| 0c21cbf01f | |||
| 772538ddba | |||
| 35fb3dd65a | |||
| 692f79452d | |||
| 8760937439 | |||
| 4ba3167f48 | |||
| e4f896e90d | |||
| 07fc25d285 | |||
| 55bc09ac33 | |||
| c43a45ea40 | |||
| 9cf7153b1d | |||
| c91785dd68 | |||
| 053e18e1a6 | |||
| a7e7c6d667 | |||
| f4c17c66ce | |||
| 1df389b9d0 | |||
| 5db71cb68c | |||
| 4efc8d404f | |||
| 4d4ddb3d3f | |||
| 979a461af5 | |||
| ac04f2704f | |||
| c4d273a68a | |||
| dc50a7fdfb | |||
| 5b633449f8 | |||
| 02569136df | |||
| 024ac0e464 | |||
| 19030928e0 | |||
| 092bf13f5e | |||
| fe2595a05c | |||
| 718dddde75 | |||
| 679ca657ee | |||
| fa96acdf4b | |||
| 90299e2710 | |||
| 7dc0c7d01f | |||
| 809b341350 | |||
| b1aabe88b8 | |||
| 654354c624 | |||
| eef0a6e2da | |||
| b107444878 | |||
| 16aa51c9b3 | |||
| 133ffe7174 | |||
| f88970985a | |||
| 6572fa5b75 | |||
| 194bab4691 | |||
| 35f141fc48 | |||
| 0b6fa8b9e1 | |||
| 140907ce1d | |||
| 52718b0f23 | |||
| 563383c60f | |||
| 1b74d84590 | |||
| 823f3af98c | |||
| 13664e99e7 | |||
| 60e0abfdb8 | |||
| 616caa92b1 | |||
| 31a3c9a3de | |||
| ad6d934a5f | |||
| 5350b2fb24 | |||
| 29817c3b34 | |||
| e5b149068c | |||
| 85b7ed3cec | |||
| 24805200f0 | |||
| 722a9c4753 | |||
| d1baf7212b | |||
| 0948c7a4e1 | |||
| c3170f22da | |||
| 1193ac64dc | |||
| ab41de2961 | |||
| 3b3e8e1b0b | |||
| 4004fb849f | |||
| f467e613b6 | |||
| f0dd8cb0d2 | |||
| 7643a46fca | |||
| c4da0e8ca9 |
@@ -0,0 +1,181 @@
|
|||||||
|
---
|
||||||
|
name: smoke-test
|
||||||
|
description: End-to-end smoke test skill for DeerFlow. Guides through: 1) Pulling latest code, 2) Docker OR Local installation and deployment (user preference, default to Local if Docker network issues), 3) Service availability verification, 4) Health check, 5) Final test report. Use when the user says "run smoke test", "smoke test deployment", "verify installation", "test service availability", "end-to-end test", or similar.
|
||||||
|
---
|
||||||
|
|
||||||
|
# DeerFlow Smoke Test Skill
|
||||||
|
|
||||||
|
This skill guides the Agent through DeerFlow's full end-to-end smoke test workflow, including code updates, deployment (supporting both Docker and local installation modes), service availability verification, and health checks.
|
||||||
|
|
||||||
|
## Deployment Mode Selection
|
||||||
|
|
||||||
|
This skill supports two deployment modes:
|
||||||
|
- **Local installation mode** (recommended, especially when network issues occur) - Run all services directly on the local machine
|
||||||
|
- **Docker mode** - Run all services inside Docker containers
|
||||||
|
|
||||||
|
**Selection strategy**:
|
||||||
|
- If the user explicitly asks for Docker mode, use Docker
|
||||||
|
- If network issues occur (such as slow image pulls), automatically switch to local mode
|
||||||
|
- Default to local mode whenever possible
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
smoke-test/
|
||||||
|
├── SKILL.md ← You are here - core workflow and logic
|
||||||
|
├── scripts/
|
||||||
|
│ ├── check_docker.sh ← Check the Docker environment
|
||||||
|
│ ├── check_local_env.sh ← Check local environment dependencies
|
||||||
|
│ ├── frontend_check.sh ← Frontend page smoke check
|
||||||
|
│ ├── pull_code.sh ← Pull the latest code
|
||||||
|
│ ├── deploy_docker.sh ← Docker deployment
|
||||||
|
│ ├── deploy_local.sh ← Local deployment
|
||||||
|
│ └── health_check.sh ← Service health check
|
||||||
|
├── references/
|
||||||
|
│ ├── SOP.md ← Standard operating procedure
|
||||||
|
│ └── troubleshooting.md ← Troubleshooting guide
|
||||||
|
└── templates/
|
||||||
|
├── report.local.template.md ← Local mode smoke test report template
|
||||||
|
└── report.docker.template.md ← Docker mode smoke test report template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standard Operating Procedure (SOP)
|
||||||
|
|
||||||
|
### Phase 1: Code Update Check
|
||||||
|
|
||||||
|
1. **Confirm current directory** - Verify that the current working directory is the DeerFlow project root
|
||||||
|
2. **Check Git status** - See whether there are uncommitted changes
|
||||||
|
3. **Pull the latest code** - Use `git pull origin main` to get the latest updates
|
||||||
|
4. **Confirm code update** - Verify that the latest code was pulled successfully
|
||||||
|
|
||||||
|
### Phase 2: Deployment Mode Selection and Environment Check
|
||||||
|
|
||||||
|
**Choose deployment mode**:
|
||||||
|
- Ask for user preference, or choose automatically based on network conditions
|
||||||
|
- Default to local installation mode
|
||||||
|
|
||||||
|
**Local mode environment check**:
|
||||||
|
1. **Check Node.js version** - Requires 22+
|
||||||
|
2. **Check pnpm** - Package manager
|
||||||
|
3. **Check uv** - Python package manager
|
||||||
|
4. **Check nginx** - Reverse proxy
|
||||||
|
5. **Check required ports** - Confirm that ports 2026, 3000, 8001, and 2024 are not occupied
|
||||||
|
|
||||||
|
**Docker mode environment check** (if Docker is selected):
|
||||||
|
1. **Check whether Docker is installed** - Run `docker --version`
|
||||||
|
2. **Check Docker daemon status** - Run `docker info`
|
||||||
|
3. **Check Docker Compose availability** - Run `docker compose version`
|
||||||
|
4. **Check required ports** - Confirm that port 2026 is not occupied
|
||||||
|
|
||||||
|
### Phase 3: Configuration Preparation
|
||||||
|
|
||||||
|
1. **Check whether config.yaml exists**
|
||||||
|
- If it does not exist, run `make config` to generate it
|
||||||
|
- If it already exists, check whether it needs an upgrade with `make config-upgrade`
|
||||||
|
2. **Check the .env file**
|
||||||
|
- Verify that required environment variables are configured
|
||||||
|
- Especially model API keys such as `OPENAI_API_KEY`
|
||||||
|
|
||||||
|
### Phase 4: Deployment Execution
|
||||||
|
|
||||||
|
**Local mode deployment**:
|
||||||
|
1. **Check dependencies** - Run `make check`
|
||||||
|
2. **Install dependencies** - Run `make install`
|
||||||
|
3. **(Optional) Pre-pull the sandbox image** - If needed, run `make setup-sandbox`
|
||||||
|
4. **Start services** - Run `make dev-daemon` (background mode, recommended) or `make dev` (foreground mode)
|
||||||
|
5. **Wait for startup** - Give all services enough time to start completely (90-120 seconds recommended)
|
||||||
|
|
||||||
|
**Docker mode deployment** (if Docker is selected):
|
||||||
|
1. **Initialize Docker environment** - Run `make docker-init`
|
||||||
|
2. **Start Docker services** - Run `make docker-start`
|
||||||
|
3. **Wait for startup** - Give all containers enough time to start completely (60 seconds recommended)
|
||||||
|
|
||||||
|
### Phase 5: Service Health Check
|
||||||
|
|
||||||
|
**Local mode health check**:
|
||||||
|
1. **Check process status** - Confirm that LangGraph, Gateway, Frontend, and Nginx processes are all running
|
||||||
|
2. **Check frontend service** - Visit `http://localhost:2026` and verify that the page loads
|
||||||
|
3. **Check API Gateway** - Verify the `http://localhost:2026/health` endpoint
|
||||||
|
4. **Check LangGraph service** - Verify the availability of relevant endpoints
|
||||||
|
5. **Frontend route smoke check** - Run `bash .agent/skills/smoke-test/scripts/frontend_check.sh` to verify key routes under `/workspace`
|
||||||
|
|
||||||
|
**Docker mode health check** (when using Docker):
|
||||||
|
1. **Check container status** - Run `docker ps` and confirm that all containers are running
|
||||||
|
2. **Check frontend service** - Visit `http://localhost:2026` and verify that the page loads
|
||||||
|
3. **Check API Gateway** - Verify the `http://localhost:2026/health` endpoint
|
||||||
|
4. **Check LangGraph service** - Verify the availability of relevant endpoints
|
||||||
|
5. **Frontend route smoke check** - Run `bash .agent/skills/smoke-test/scripts/frontend_check.sh` to verify key routes under `/workspace`
|
||||||
|
|
||||||
|
### Optional Functional Verification
|
||||||
|
|
||||||
|
1. **List available models** - Verify that model configuration loads correctly
|
||||||
|
2. **List available skills** - Verify that the skill directory is mounted correctly
|
||||||
|
3. **Simple chat test** - Send a simple message to verify the end-to-end flow
|
||||||
|
|
||||||
|
### Phase 6: Generate Test Report
|
||||||
|
|
||||||
|
1. **Collect all test results** - Summarize execution status for each phase
|
||||||
|
2. **Record encountered issues** - If anything fails, record the error details
|
||||||
|
3. **Generate the final report** - Use the template that matches the selected deployment mode to create the complete test report, including overall conclusion, detailed key test cases, and explicit frontend page / route results
|
||||||
|
4. **Provide follow-up recommendations** - Offer suggestions based on the test results
|
||||||
|
|
||||||
|
## Execution Rules
|
||||||
|
|
||||||
|
- **Follow the sequence** - Execute strictly in the order described above
|
||||||
|
- **Idempotency** - Every step should be safe to repeat
|
||||||
|
- **Error handling** - If a step fails, stop and report the issue, then provide troubleshooting suggestions
|
||||||
|
- **Detailed logging** - Record the execution result and status of each step
|
||||||
|
- **User confirmation** - Ask for confirmation before potentially risky operations such as overwriting config
|
||||||
|
- **Mode preference** - Prefer local mode to avoid network-related issues
|
||||||
|
- **Template requirement** - The final report must use the matching template under `templates/`; do not output a free-form summary instead of the template-based report
|
||||||
|
- **Report clarity** - The execution summary must include the overall pass/fail conclusion plus per-case result explanations, and frontend smoke check results must be listed explicitly in the report
|
||||||
|
- **Optional phase handling** - If functional verification is not executed, do not present it as a separate skipped phase in the final report
|
||||||
|
|
||||||
|
## Known Acceptable Warnings
|
||||||
|
|
||||||
|
The following warnings can appear during smoke testing and do not block a successful result:
|
||||||
|
- Feishu/Lark SSL errors in Gateway logs (certificate verification failure) can be ignored if that channel is not enabled
|
||||||
|
- Warnings in LangGraph logs about missing methods in the custom checkpointer, such as `adelete_for_runs` or `aprune`, do not affect the core functionality
|
||||||
|
|
||||||
|
## Key Tools
|
||||||
|
|
||||||
|
Use the following tools during execution:
|
||||||
|
|
||||||
|
1. **bash** - Run shell commands
|
||||||
|
2. **present_file** - Show generated reports and important files
|
||||||
|
3. **task_tool** - Organize complex steps with subtasks when needed
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
Smoke test pass criteria (local mode):
|
||||||
|
- [x] Latest code is pulled successfully
|
||||||
|
- [x] Local environment check passes (Node.js 22+, pnpm, uv, nginx)
|
||||||
|
- [x] Configuration files are set up correctly
|
||||||
|
- [x] `make check` passes
|
||||||
|
- [x] `make install` completes successfully
|
||||||
|
- [x] `make dev` starts successfully
|
||||||
|
- [x] All service processes run normally
|
||||||
|
- [x] Frontend page is accessible
|
||||||
|
- [x] Frontend route smoke check passes (`/workspace` key routes)
|
||||||
|
- [x] API Gateway health check passes
|
||||||
|
- [x] Test report is generated completely
|
||||||
|
|
||||||
|
Smoke test pass criteria (Docker mode):
|
||||||
|
- [x] Latest code is pulled successfully
|
||||||
|
- [x] Docker environment check passes
|
||||||
|
- [x] Configuration files are set up correctly
|
||||||
|
- [x] `make docker-init` completes successfully
|
||||||
|
- [x] `make docker-start` completes successfully
|
||||||
|
- [x] All Docker containers run normally
|
||||||
|
- [x] Frontend page is accessible
|
||||||
|
- [x] Frontend route smoke check passes (`/workspace` key routes)
|
||||||
|
- [x] API Gateway health check passes
|
||||||
|
- [x] Test report is generated completely
|
||||||
|
|
||||||
|
## Read Reference Files
|
||||||
|
|
||||||
|
Before starting execution, read the following reference files:
|
||||||
|
1. `references/SOP.md` - Detailed step-by-step operating instructions
|
||||||
|
2. `references/troubleshooting.md` - Common issues and solutions
|
||||||
|
3. `templates/report.local.template.md` - Local mode test report template
|
||||||
|
4. `templates/report.docker.template.md` - Docker mode test report template
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
# DeerFlow Smoke Test Standard Operating Procedure (SOP)
|
||||||
|
|
||||||
|
This document describes the detailed operating steps for each phase of the DeerFlow smoke test.
|
||||||
|
|
||||||
|
## Phase 1: Code Update Check
|
||||||
|
|
||||||
|
### 1.1 Confirm Current Directory
|
||||||
|
|
||||||
|
**Objective**: Verify that the current working directory is the DeerFlow project root.
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `pwd` to view the current working directory
|
||||||
|
2. Check whether the directory contains the following files/directories:
|
||||||
|
- `Makefile`
|
||||||
|
- `backend/`
|
||||||
|
- `frontend/`
|
||||||
|
- `config.example.yaml`
|
||||||
|
|
||||||
|
**Success Criteria**: The current directory contains all of the files/directories listed above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Check Git Status
|
||||||
|
|
||||||
|
**Objective**: Check whether there are uncommitted changes.
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `git status`
|
||||||
|
2. Check whether the output includes "Changes not staged for commit" or "Untracked files"
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- If there are uncommitted changes, recommend that the user commit or stash them first to avoid conflicts while pulling
|
||||||
|
- If the user confirms that they want to continue, this step can be skipped
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Pull the Latest Code
|
||||||
|
|
||||||
|
**Objective**: Fetch the latest code updates.
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `git fetch origin main`
|
||||||
|
2. Run `git pull origin main`
|
||||||
|
|
||||||
|
**Success Criteria**:
|
||||||
|
- The commands succeed without errors
|
||||||
|
- The output shows "Already up to date" or indicates that new commits were pulled successfully
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 Confirm Code Update
|
||||||
|
|
||||||
|
**Objective**: Verify that the latest code was pulled successfully.
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `git log -1 --oneline` to view the latest commit
|
||||||
|
2. Record the commit hash and message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Deployment Mode Selection and Environment Check
|
||||||
|
|
||||||
|
### 2.1 Choose Deployment Mode
|
||||||
|
|
||||||
|
**Objective**: Decide whether to use local mode or Docker mode.
|
||||||
|
|
||||||
|
**Decision Flow**:
|
||||||
|
1. Prefer local mode first to avoid network-related issues
|
||||||
|
2. If the user explicitly requests Docker, use Docker
|
||||||
|
3. If Docker network issues occur, switch to local mode automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Local Mode Environment Check
|
||||||
|
|
||||||
|
**Objective**: Verify that local development environment dependencies are satisfied.
|
||||||
|
|
||||||
|
#### 2.2.1 Check Node.js Version
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. If nvm is used, run `nvm use 22` to switch to Node 22+
|
||||||
|
2. Run `node --version`
|
||||||
|
|
||||||
|
**Success Criteria**: Version >= 22.x
|
||||||
|
|
||||||
|
**Failure Handling**:
|
||||||
|
- If the version is too low, ask the user to install/switch Node.js with nvm:
|
||||||
|
```bash
|
||||||
|
nvm install 22
|
||||||
|
nvm use 22
|
||||||
|
```
|
||||||
|
- Or install it from the official website: https://nodejs.org/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2.2.2 Check pnpm
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `pnpm --version`
|
||||||
|
|
||||||
|
**Success Criteria**: The command returns pnpm version information.
|
||||||
|
|
||||||
|
**Failure Handling**:
|
||||||
|
- If pnpm is not installed, ask the user to install it with `npm install -g pnpm`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2.2.3 Check uv
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `uv --version`
|
||||||
|
|
||||||
|
**Success Criteria**: The command returns uv version information.
|
||||||
|
|
||||||
|
**Failure Handling**:
|
||||||
|
- If uv is not installed, ask the user to install uv
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2.2.4 Check nginx
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `nginx -v`
|
||||||
|
|
||||||
|
**Success Criteria**: The command returns nginx version information.
|
||||||
|
|
||||||
|
**Failure Handling**:
|
||||||
|
- macOS: install with Homebrew using `brew install nginx`
|
||||||
|
- Linux: install using the system package manager
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2.2.5 Check Required Ports
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run the following commands to check ports:
|
||||||
|
```bash
|
||||||
|
lsof -i :2026 # Main port
|
||||||
|
lsof -i :3000 # Frontend
|
||||||
|
lsof -i :8001 # Gateway
|
||||||
|
lsof -i :2024 # LangGraph
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria**: All ports are free, or they are occupied only by DeerFlow-related processes.
|
||||||
|
|
||||||
|
**Failure Handling**:
|
||||||
|
- If a port is occupied, ask the user to stop the related process
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Docker Mode Environment Check (If Docker Is Selected)
|
||||||
|
|
||||||
|
#### 2.3.1 Check Whether Docker Is Installed
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `docker --version`
|
||||||
|
|
||||||
|
**Success Criteria**: The command returns Docker version information, such as "Docker version 24.x.x".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2.3.2 Check Docker Daemon Status
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `docker info`
|
||||||
|
|
||||||
|
**Success Criteria**: The command runs successfully and shows Docker system information.
|
||||||
|
|
||||||
|
**Failure Handling**:
|
||||||
|
- If it fails, ask the user to start Docker Desktop or the Docker service
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2.3.3 Check Docker Compose Availability
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `docker compose version`
|
||||||
|
|
||||||
|
**Success Criteria**: The command returns Docker Compose version information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2.3.4 Check Required Ports
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `lsof -i :2026` (macOS/Linux) or `netstat -ano | findstr :2026` (Windows)
|
||||||
|
|
||||||
|
**Success Criteria**: Port 2026 is free, or it is occupied only by a DeerFlow-related process.
|
||||||
|
|
||||||
|
**Failure Handling**:
|
||||||
|
- If the port is occupied by another process, ask the user to stop that process or change the configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Configuration Preparation
|
||||||
|
|
||||||
|
### 3.1 Check config.yaml
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Check whether `config.yaml` exists
|
||||||
|
2. If it does not exist, run `make config`
|
||||||
|
3. If it already exists, consider running `make config-upgrade` to merge new fields
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
- Check whether at least one model is configured in config.yaml
|
||||||
|
- Check whether the model configuration references the correct environment variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Check the .env File
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Check whether the `.env` file exists
|
||||||
|
2. If it does not exist, copy it from `.env.example`
|
||||||
|
3. Check whether the following environment variables are configured:
|
||||||
|
- `OPENAI_API_KEY` (or other model API keys)
|
||||||
|
- Other required settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Deployment Execution
|
||||||
|
|
||||||
|
### 4.1 Local Mode Deployment
|
||||||
|
|
||||||
|
#### 4.1.1 Check Dependencies
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `make check`
|
||||||
|
|
||||||
|
**Description**: This command validates all required tools (Node.js 22+, pnpm, uv, nginx).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4.1.2 Install Dependencies
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `make install`
|
||||||
|
|
||||||
|
**Description**: This command installs both backend and frontend dependencies.
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- This step may take some time
|
||||||
|
- If network issues cause failures, try using a closer or mirrored package registry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4.1.3 (Optional) Pre-pull the Sandbox Image
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. If Docker / Container sandbox is used, run `make setup-sandbox`
|
||||||
|
|
||||||
|
**Description**: This step is optional and not needed for local sandbox mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4.1.4 Start Services
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `make dev-daemon` (background mode)
|
||||||
|
|
||||||
|
**Description**: This command starts all services (LangGraph, Gateway, Frontend, Nginx).
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- `make dev` runs in the foreground and stops with Ctrl+C
|
||||||
|
- `make dev-daemon` runs in the background
|
||||||
|
- Use `make stop` to stop services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4.1.5 Wait for Services to Start
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Wait 90-120 seconds for all services to start completely
|
||||||
|
2. You can monitor startup progress by checking these log files:
|
||||||
|
- `logs/langgraph.log`
|
||||||
|
- `logs/gateway.log`
|
||||||
|
- `logs/frontend.log`
|
||||||
|
- `logs/nginx.log`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Docker Mode Deployment (If Docker Is Selected)
|
||||||
|
|
||||||
|
#### 4.2.1 Initialize the Docker Environment
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `make docker-init`
|
||||||
|
|
||||||
|
**Description**: This command pulls the sandbox image if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4.2.2 Start Docker Services
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `make docker-start`
|
||||||
|
|
||||||
|
**Description**: This command builds and starts all required Docker containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4.2.3 Wait for Services to Start
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Wait 60-90 seconds for all services to start completely
|
||||||
|
2. You can run `make docker-logs` to monitor startup progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Service Health Check
|
||||||
|
|
||||||
|
### 5.1 Local Mode Health Check
|
||||||
|
|
||||||
|
#### 5.1.1 Check Process Status
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run the following command to check processes:
|
||||||
|
```bash
|
||||||
|
ps aux | grep -E "(langgraph|uvicorn|next|nginx)" | grep -v grep
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria**: Confirm that the following processes are running:
|
||||||
|
- LangGraph (`langgraph dev`)
|
||||||
|
- Gateway (`uvicorn app.gateway.app:app`)
|
||||||
|
- Frontend (`next dev` or `next start`)
|
||||||
|
- Nginx (`nginx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.1.2 Check Frontend Service
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Use curl or a browser to visit `http://localhost:2026`
|
||||||
|
2. Verify that the page loads normally
|
||||||
|
|
||||||
|
**Example curl command**:
|
||||||
|
```bash
|
||||||
|
curl -I http://localhost:2026
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria**: Returns an HTTP 200 status code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.1.3 Check API Gateway
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Visit `http://localhost:2026/health`
|
||||||
|
|
||||||
|
**Example curl command**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:2026/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria**: Returns health status JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.1.4 Check LangGraph Service
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Visit relevant LangGraph endpoints to verify availability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Docker Mode Health Check (When Using Docker)
|
||||||
|
|
||||||
|
#### 5.2.1 Check Container Status
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Run `docker ps`
|
||||||
|
2. Confirm that the following containers are running:
|
||||||
|
- `deer-flow-nginx`
|
||||||
|
- `deer-flow-frontend`
|
||||||
|
- `deer-flow-gateway`
|
||||||
|
- `deer-flow-langgraph` (if not in gateway mode)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.2.2 Check Frontend Service
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Use curl or a browser to visit `http://localhost:2026`
|
||||||
|
2. Verify that the page loads normally
|
||||||
|
|
||||||
|
**Example curl command**:
|
||||||
|
```bash
|
||||||
|
curl -I http://localhost:2026
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria**: Returns an HTTP 200 status code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.2.3 Check API Gateway
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Visit `http://localhost:2026/health`
|
||||||
|
|
||||||
|
**Example curl command**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:2026/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria**: Returns health status JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.2.4 Check LangGraph Service
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Visit relevant LangGraph endpoints to verify availability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional Functional Verification
|
||||||
|
|
||||||
|
### 6.1 List Available Models
|
||||||
|
|
||||||
|
**Steps**: Verify the model list through the API or UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 List Available Skills
|
||||||
|
|
||||||
|
**Steps**: Verify the skill list through the API or UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.3 Simple Chat Test
|
||||||
|
|
||||||
|
**Steps**: Send a simple message to test the complete workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Generate the Test Report
|
||||||
|
|
||||||
|
### 6.1 Collect Test Results
|
||||||
|
|
||||||
|
Summarize the execution status of each phase and record successful and failed items.
|
||||||
|
|
||||||
|
### 6.2 Record Issues
|
||||||
|
|
||||||
|
If anything fails, record detailed error information.
|
||||||
|
|
||||||
|
### 6.3 Generate the Report
|
||||||
|
|
||||||
|
Use the template to create a complete test report.
|
||||||
|
|
||||||
|
### 6.4 Provide Recommendations
|
||||||
|
|
||||||
|
Provide follow-up recommendations based on the test results.
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
# Troubleshooting Guide
|
||||||
|
|
||||||
|
This document lists common issues encountered during DeerFlow smoke testing and how to resolve them.
|
||||||
|
|
||||||
|
## Code Update Issues
|
||||||
|
|
||||||
|
### Issue: `git pull` Fails with a Merge Conflict Warning
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```
|
||||||
|
error: Your local changes to the following files would be overwritten by merge
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Option A: Commit local changes first
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Save local changes"
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Option B: Stash local changes
|
||||||
|
```bash
|
||||||
|
git stash
|
||||||
|
git pull origin main
|
||||||
|
git stash pop # Restore changes later if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Option C: Discard local changes (use with caution)
|
||||||
|
```bash
|
||||||
|
git reset --hard HEAD
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Mode Environment Issues
|
||||||
|
|
||||||
|
### Issue: Node.js Version Is Too Old
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```
|
||||||
|
Node.js version is too old. Requires 22+, got x.x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Install or upgrade Node.js with nvm:
|
||||||
|
```bash
|
||||||
|
nvm install 22
|
||||||
|
nvm use 22
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Or download and install it from the official website: https://nodejs.org/
|
||||||
|
|
||||||
|
3. Verify the version:
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: pnpm Is Not Installed
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```
|
||||||
|
command not found: pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Install pnpm with npm:
|
||||||
|
```bash
|
||||||
|
npm install -g pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Or use the official installation script:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify the installation:
|
||||||
|
```bash
|
||||||
|
pnpm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: uv Is Not Installed
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```
|
||||||
|
command not found: uv
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Use the official installation script:
|
||||||
|
```bash
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
2. macOS users can also install it with Homebrew:
|
||||||
|
```bash
|
||||||
|
brew install uv
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify the installation:
|
||||||
|
```bash
|
||||||
|
uv --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: nginx Is Not Installed
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```
|
||||||
|
command not found: nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. macOS (Homebrew):
|
||||||
|
```bash
|
||||||
|
brew install nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Ubuntu/Debian:
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
3. CentOS/RHEL:
|
||||||
|
```bash
|
||||||
|
sudo yum install nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Verify the installation:
|
||||||
|
```bash
|
||||||
|
nginx -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Port Is Already in Use
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```
|
||||||
|
Error: listen EADDRINUSE: address already in use :::2026
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Find the process using the port:
|
||||||
|
```bash
|
||||||
|
lsof -i :2026 # macOS/Linux
|
||||||
|
netstat -ano | findstr :2026 # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Stop that process:
|
||||||
|
```bash
|
||||||
|
kill -9 <PID> # macOS/Linux
|
||||||
|
taskkill /PID <PID> /F # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Or stop DeerFlow services first:
|
||||||
|
```bash
|
||||||
|
make stop
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Mode Dependency Installation Issues
|
||||||
|
|
||||||
|
### Issue: `make install` Fails Due to Network Timeout
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
Network timeouts or connection failures occur during dependency installation.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Configure pnpm to use a mirror registry:
|
||||||
|
```bash
|
||||||
|
pnpm config set registry https://registry.npmmirror.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure uv to use a mirror registry:
|
||||||
|
```bash
|
||||||
|
uv pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Retry the installation:
|
||||||
|
```bash
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Python Dependency Installation Fails
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
Errors occur during `uv sync`.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Clean the uv cache:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv cache clean
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Resync dependencies:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
3. View detailed error logs:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv sync --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Frontend Dependency Installation Fails
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
Errors occur during `pnpm install`.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Clean the pnpm cache:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
pnpm store prune
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Remove node_modules and the lock file:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
rm -rf node_modules pnpm-lock.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Reinstall:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Mode Service Startup Issues
|
||||||
|
|
||||||
|
### Issue: Services Exit Immediately After Startup
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
Processes exit quickly after running `make dev-daemon`.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Check log files:
|
||||||
|
```bash
|
||||||
|
tail -f logs/langgraph.log
|
||||||
|
tail -f logs/gateway.log
|
||||||
|
tail -f logs/frontend.log
|
||||||
|
tail -f logs/nginx.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check whether config.yaml is configured correctly
|
||||||
|
3. Check environment variables in the .env file
|
||||||
|
4. Confirm that required ports are not occupied
|
||||||
|
5. Stop all services and restart:
|
||||||
|
```bash
|
||||||
|
make stop
|
||||||
|
make dev-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Nginx Fails to Start Because Temp Directories Do Not Exist
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```
|
||||||
|
nginx: [emerg] mkdir() "/opt/homebrew/var/run/nginx/client_body_temp" failed (2: No such file or directory)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
Add local temp directory configuration to `docker/nginx/nginx.local.conf` so nginx uses the repository's temp directory.
|
||||||
|
|
||||||
|
Add the following at the beginning of the `http` block:
|
||||||
|
```nginx
|
||||||
|
client_body_temp_path temp/client_body_temp;
|
||||||
|
proxy_temp_path temp/proxy_temp;
|
||||||
|
fastcgi_temp_path temp/fastcgi_temp;
|
||||||
|
uwsgi_temp_path temp/uwsgi_temp;
|
||||||
|
scgi_temp_path temp/scgi_temp;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `temp/` directory under the repository root is created automatically by `make dev` or `make dev-daemon`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Nginx Fails to Start (General)
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
The nginx process fails to start or reports an error.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Check the nginx configuration:
|
||||||
|
```bash
|
||||||
|
nginx -t -c docker/nginx/nginx.local.conf -p .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check nginx logs:
|
||||||
|
```bash
|
||||||
|
tail -f logs/nginx.log
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Ensure no other nginx process is running:
|
||||||
|
```bash
|
||||||
|
ps aux | grep nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
4. If needed, stop existing nginx processes:
|
||||||
|
```bash
|
||||||
|
pkill -9 nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Frontend Compilation Fails
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
Compilation errors appear in `frontend.log`.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Check frontend logs:
|
||||||
|
```bash
|
||||||
|
tail -f logs/frontend.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check whether Node.js version is 22+
|
||||||
|
3. Reinstall frontend dependencies:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
rm -rf node_modules .next
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Restart services:
|
||||||
|
```bash
|
||||||
|
make stop
|
||||||
|
make dev-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Gateway Fails to Start
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
Errors appear in `gateway.log`.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Check gateway logs:
|
||||||
|
```bash
|
||||||
|
tail -f logs/gateway.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check whether config.yaml exists and has valid formatting
|
||||||
|
3. Check whether Python dependencies are complete:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Confirm that the LangGraph service is running normally (if not in gateway mode)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: LangGraph Fails to Start
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
Errors appear in `langgraph.log`.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Check LangGraph logs:
|
||||||
|
```bash
|
||||||
|
tail -f logs/langgraph.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check config.yaml
|
||||||
|
3. Check whether Python dependencies are complete
|
||||||
|
4. Confirm that port 2024 is not occupied
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker-Related Issues
|
||||||
|
|
||||||
|
### Issue: Docker Commands Cannot Run
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```
|
||||||
|
Cannot connect to the Docker daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Confirm that Docker Desktop is running
|
||||||
|
2. macOS: check whether the Docker icon appears in the top menu bar
|
||||||
|
3. Linux: run `sudo systemctl start docker`
|
||||||
|
4. Run `docker info` again to verify
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: `make docker-init` Fails to Pull the Image
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```
|
||||||
|
Error pulling image: connection refused
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Check network connectivity
|
||||||
|
2. Configure a Docker image mirror if needed
|
||||||
|
3. Check whether a proxy is required
|
||||||
|
4. Switch to local installation mode if necessary (recommended)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration File Issues
|
||||||
|
|
||||||
|
### Issue: config.yaml Is Missing or Invalid
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
```
|
||||||
|
Error: could not read config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Regenerate the configuration file:
|
||||||
|
```bash
|
||||||
|
make config
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check YAML syntax:
|
||||||
|
- Make sure indentation is correct (use 2 spaces)
|
||||||
|
- Make sure there are no tab characters
|
||||||
|
- Check that there is a space after each colon
|
||||||
|
|
||||||
|
3. Use a YAML validation tool to check the format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Model API Key Is Not Configured
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
After services start, API requests fail with authentication errors.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Edit the .env file and add the API key:
|
||||||
|
```bash
|
||||||
|
OPENAI_API_KEY=your-actual-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Restart services (local mode):
|
||||||
|
```bash
|
||||||
|
make stop
|
||||||
|
make dev-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Restart services (Docker mode):
|
||||||
|
```bash
|
||||||
|
make docker-stop
|
||||||
|
make docker-start
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Confirm that the model configuration in config.yaml references the environment variable correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Health Check Issues
|
||||||
|
|
||||||
|
### Issue: Frontend Page Is Not Accessible
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
The browser shows a connection failure when visiting http://localhost:2026.
|
||||||
|
|
||||||
|
**Solutions** (local mode):
|
||||||
|
1. Confirm that the nginx process is running:
|
||||||
|
```bash
|
||||||
|
ps aux | grep nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check nginx logs:
|
||||||
|
```bash
|
||||||
|
tail -f logs/nginx.log
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check firewall settings
|
||||||
|
|
||||||
|
**Solutions** (Docker mode):
|
||||||
|
1. Confirm that the nginx container is running:
|
||||||
|
```bash
|
||||||
|
docker ps | grep nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check nginx logs:
|
||||||
|
```bash
|
||||||
|
cd docker && docker compose -p deer-flow-dev -f docker-compose-dev.yaml logs nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check firewall settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: API Gateway Health Check Fails
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
Accessing `/health` returns an error or times out.
|
||||||
|
|
||||||
|
**Solutions** (local mode):
|
||||||
|
1. Check gateway logs:
|
||||||
|
```bash
|
||||||
|
tail -f logs/gateway.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Confirm that config.yaml exists and has valid formatting
|
||||||
|
3. Check whether Python dependencies are complete
|
||||||
|
4. Confirm that the LangGraph service is running normally
|
||||||
|
|
||||||
|
**Solutions** (Docker mode):
|
||||||
|
1. Check gateway container logs:
|
||||||
|
```bash
|
||||||
|
make docker-logs-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Confirm that config.yaml is mounted correctly
|
||||||
|
3. Check whether Python dependencies are complete
|
||||||
|
4. Confirm that the LangGraph service is running normally
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Diagnostic Commands
|
||||||
|
|
||||||
|
### Local Mode Diagnostics
|
||||||
|
|
||||||
|
#### View All Service Processes
|
||||||
|
```bash
|
||||||
|
ps aux | grep -E "(langgraph|uvicorn|next|nginx)" | grep -v grep
|
||||||
|
```
|
||||||
|
|
||||||
|
#### View Service Logs
|
||||||
|
```bash
|
||||||
|
# View all logs
|
||||||
|
tail -f logs/*.log
|
||||||
|
|
||||||
|
# View specific service logs
|
||||||
|
tail -f logs/langgraph.log
|
||||||
|
tail -f logs/gateway.log
|
||||||
|
tail -f logs/frontend.log
|
||||||
|
tail -f logs/nginx.log
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stop All Services
|
||||||
|
```bash
|
||||||
|
make stop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fully Reset the Local Environment
|
||||||
|
```bash
|
||||||
|
make stop
|
||||||
|
make clean
|
||||||
|
make config
|
||||||
|
make install
|
||||||
|
make dev-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Docker Mode Diagnostics
|
||||||
|
|
||||||
|
#### View All Container Status
|
||||||
|
```bash
|
||||||
|
docker ps -a
|
||||||
|
```
|
||||||
|
|
||||||
|
#### View Container Resource Usage
|
||||||
|
```bash
|
||||||
|
docker stats
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Enter a Container for Debugging
|
||||||
|
```bash
|
||||||
|
docker exec -it deer-flow-gateway sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Clean Up All DeerFlow-Related Containers and Images
|
||||||
|
```bash
|
||||||
|
make docker-stop
|
||||||
|
cd docker && docker compose -p deer-flow-dev -f docker-compose-dev.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fully Reset the Docker Environment
|
||||||
|
```bash
|
||||||
|
make docker-stop
|
||||||
|
make clean
|
||||||
|
make config
|
||||||
|
make docker-init
|
||||||
|
make docker-start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Get More Help
|
||||||
|
|
||||||
|
If the solutions above do not resolve the issue:
|
||||||
|
1. Check the GitHub issues for the project: https://github.com/bytedance/deer-flow/issues
|
||||||
|
2. Review the project documentation: README.md and the `backend/docs/` directory
|
||||||
|
3. Open a new issue and include detailed error logs
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Checking Docker Environment"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check whether Docker is installed
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "✓ Docker is installed"
|
||||||
|
docker --version
|
||||||
|
else
|
||||||
|
echo "✗ Docker is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check the Docker daemon
|
||||||
|
if docker info >/dev/null 2>&1; then
|
||||||
|
echo "✓ Docker daemon is running normally"
|
||||||
|
else
|
||||||
|
echo "✗ Docker daemon is not running"
|
||||||
|
echo " Please start Docker Desktop or the Docker service"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check Docker Compose
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
echo "✓ Docker Compose is available"
|
||||||
|
docker compose version
|
||||||
|
else
|
||||||
|
echo "✗ Docker Compose is not available"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check port 2026
|
||||||
|
if ! command -v lsof >/dev/null 2>&1; then
|
||||||
|
echo "✗ lsof is required to check whether port 2026 is available"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
port_2026_usage="$(lsof -nP -iTCP:2026 -sTCP:LISTEN 2>/dev/null || true)"
|
||||||
|
if [ -n "$port_2026_usage" ]; then
|
||||||
|
echo "⚠ Port 2026 is already in use"
|
||||||
|
echo " Occupying process:"
|
||||||
|
echo "$port_2026_usage"
|
||||||
|
|
||||||
|
deerflow_process_found=0
|
||||||
|
while IFS= read -r pid; do
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
process_command="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
||||||
|
case "$process_command" in
|
||||||
|
*[Dd]eer[Ff]low*|*[Dd]eerflow*|*[Nn]ginx*deerflow*|*deerflow/*[Nn]ginx*)
|
||||||
|
deerflow_process_found=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<EOF
|
||||||
|
$(printf '%s\n' "$port_2026_usage" | awk 'NR > 1 {print $2}')
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ "$deerflow_process_found" -eq 1 ]; then
|
||||||
|
echo "✓ Port 2026 is occupied by DeerFlow"
|
||||||
|
else
|
||||||
|
echo "✗ Port 2026 must be free before starting DeerFlow"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✓ Port 2026 is available"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Docker Environment Check Complete"
|
||||||
|
echo "=========================================="
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Checking Local Development Environment"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
all_passed=true
|
||||||
|
|
||||||
|
# Check Node.js
|
||||||
|
echo "1. Checking Node.js..."
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
NODE_VERSION=$(node --version | sed 's/v//')
|
||||||
|
NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1)
|
||||||
|
if [ "$NODE_MAJOR" -ge 22 ]; then
|
||||||
|
echo "✓ Node.js is installed (version: $NODE_VERSION)"
|
||||||
|
else
|
||||||
|
echo "✗ Node.js version is too old (current: $NODE_VERSION, required: 22+)"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✗ Node.js is not installed"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check pnpm
|
||||||
|
echo "2. Checking pnpm..."
|
||||||
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
|
echo "✓ pnpm is installed (version: $(pnpm --version))"
|
||||||
|
else
|
||||||
|
echo "✗ pnpm is not installed"
|
||||||
|
echo " Install command: npm install -g pnpm"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check uv
|
||||||
|
echo "3. Checking uv..."
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
echo "✓ uv is installed (version: $(uv --version))"
|
||||||
|
else
|
||||||
|
echo "✗ uv is not installed"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check nginx
|
||||||
|
echo "4. Checking nginx..."
|
||||||
|
if command -v nginx >/dev/null 2>&1; then
|
||||||
|
echo "✓ nginx is installed (version: $(nginx -v 2>&1))"
|
||||||
|
else
|
||||||
|
echo "✗ nginx is not installed"
|
||||||
|
echo " macOS: brew install nginx"
|
||||||
|
echo " Linux: install it with the system package manager"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check ports
|
||||||
|
echo "5. Checking ports..."
|
||||||
|
if ! command -v lsof >/dev/null 2>&1; then
|
||||||
|
echo "✗ lsof is not installed, so port availability cannot be verified"
|
||||||
|
echo " Install lsof and rerun this check"
|
||||||
|
all_passed=false
|
||||||
|
else
|
||||||
|
for port in 2026 3000 8001 2024; do
|
||||||
|
if lsof -i :$port >/dev/null 2>&1; then
|
||||||
|
echo "⚠ Port $port is already in use:"
|
||||||
|
lsof -i :$port | head -2
|
||||||
|
all_passed=false
|
||||||
|
else
|
||||||
|
echo "✓ Port $port is available"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Environment Check Summary"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
if [ "$all_passed" = true ]; then
|
||||||
|
echo "✅ All environment checks passed!"
|
||||||
|
echo ""
|
||||||
|
echo "Next step: run make install to install dependencies"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "❌ Some checks failed. Please fix the issues above first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Docker Deployment"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check config.yaml
|
||||||
|
if [ ! -f "config.yaml" ]; then
|
||||||
|
echo "config.yaml does not exist. Generating it..."
|
||||||
|
make config
|
||||||
|
echo ""
|
||||||
|
echo "⚠ Please edit config.yaml to configure your models and API keys"
|
||||||
|
echo " Then run this script again"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✓ config.yaml exists"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check the .env file
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo ".env does not exist. Copying it from the example..."
|
||||||
|
if [ -f ".env.example" ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "✓ Created the .env file"
|
||||||
|
else
|
||||||
|
echo "⚠ .env.example does not exist. Please create the .env file manually"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✓ .env file exists"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check the frontend .env file
|
||||||
|
if [ ! -f "frontend/.env" ]; then
|
||||||
|
echo "frontend/.env does not exist. Copying it from the example..."
|
||||||
|
if [ -f "frontend/.env.example" ]; then
|
||||||
|
cp frontend/.env.example frontend/.env
|
||||||
|
echo "✓ Created the frontend/.env file"
|
||||||
|
else
|
||||||
|
echo "⚠ frontend/.env.example does not exist. Please create frontend/.env manually"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✓ frontend/.env file exists"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
# Initialize the Docker environment
|
||||||
|
echo "Initializing the Docker environment..."
|
||||||
|
make docker-init
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Start Docker services
|
||||||
|
echo "Starting Docker services..."
|
||||||
|
make docker-start
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Deployment Complete"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "🌐 Access URL: http://localhost:2026"
|
||||||
|
echo "📋 View logs: make docker-logs"
|
||||||
|
echo "🛑 Stop services: make docker-stop"
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Local Mode Deployment"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check config.yaml
|
||||||
|
if [ ! -f "config.yaml" ]; then
|
||||||
|
echo "config.yaml does not exist. Generating it..."
|
||||||
|
make config
|
||||||
|
echo ""
|
||||||
|
echo "⚠ Please edit config.yaml to configure your models and API keys"
|
||||||
|
echo " Then run this script again"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✓ config.yaml exists"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check the .env file
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo ".env does not exist. Copying it from the example..."
|
||||||
|
if [ -f ".env.example" ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "✓ Created the .env file"
|
||||||
|
else
|
||||||
|
echo "⚠ .env.example does not exist. Please create the .env file manually"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✓ .env file exists"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
echo "Checking dependencies..."
|
||||||
|
make check
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
make install
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
echo "Starting services (background mode)..."
|
||||||
|
make dev-daemon
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Deployment Complete"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "🌐 Access URL: http://localhost:2026"
|
||||||
|
echo "📋 View logs:"
|
||||||
|
echo " - logs/langgraph.log"
|
||||||
|
echo " - logs/gateway.log"
|
||||||
|
echo " - logs/frontend.log"
|
||||||
|
echo " - logs/nginx.log"
|
||||||
|
echo "🛑 Stop services: make stop"
|
||||||
|
echo ""
|
||||||
|
echo "Please wait 90-120 seconds for all services to start completely, then run the health check"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set +e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Frontend Page Smoke Check"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-http://localhost:2026}"
|
||||||
|
DOC_PATH="${DOC_PATH:-/en/docs}"
|
||||||
|
|
||||||
|
all_passed=true
|
||||||
|
|
||||||
|
check_status() {
|
||||||
|
local name="$1"
|
||||||
|
local url="$2"
|
||||||
|
local expected_re="$3"
|
||||||
|
|
||||||
|
local status
|
||||||
|
status="$(curl -s -o /dev/null -w "%{http_code}" -L "$url")"
|
||||||
|
if echo "$status" | grep -Eq "$expected_re"; then
|
||||||
|
echo "✓ $name ($url) -> $status"
|
||||||
|
else
|
||||||
|
echo "✗ $name ($url) -> $status (expected: $expected_re)"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_final_url() {
|
||||||
|
local name="$1"
|
||||||
|
local url="$2"
|
||||||
|
local expected_path_re="$3"
|
||||||
|
|
||||||
|
local effective
|
||||||
|
effective="$(curl -s -o /dev/null -w "%{url_effective}" -L "$url")"
|
||||||
|
if echo "$effective" | grep -Eq "$expected_path_re"; then
|
||||||
|
echo "✓ $name redirect target -> $effective"
|
||||||
|
else
|
||||||
|
echo "✗ $name redirect target -> $effective (expected path: $expected_path_re)"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "1. Checking entry pages..."
|
||||||
|
check_status "Landing page" "${BASE_URL}/" "200"
|
||||||
|
check_status "Workspace redirect" "${BASE_URL}/workspace" "200|301|302|307|308"
|
||||||
|
check_final_url "Workspace redirect" "${BASE_URL}/workspace" "/workspace/chats/"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "2. Checking key workspace routes..."
|
||||||
|
check_status "New chat page" "${BASE_URL}/workspace/chats/new" "200"
|
||||||
|
check_status "Chats list page" "${BASE_URL}/workspace/chats" "200"
|
||||||
|
check_status "Agents gallery page" "${BASE_URL}/workspace/agents" "200"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "3. Checking docs route (optional)..."
|
||||||
|
check_status "Docs page" "${BASE_URL}${DOC_PATH}" "200|404"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Frontend Smoke Check Summary"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
if [ "$all_passed" = true ]; then
|
||||||
|
echo "✅ Frontend smoke checks passed!"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "❌ Frontend smoke checks failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set +e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Service Health Check"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
all_passed=true
|
||||||
|
mode="${SMOKE_TEST_MODE:-auto}"
|
||||||
|
summary_hint="make logs"
|
||||||
|
|
||||||
|
print_step() {
|
||||||
|
echo "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_http_status() {
|
||||||
|
local name="$1"
|
||||||
|
local url="$2"
|
||||||
|
local expected_re="$3"
|
||||||
|
local status
|
||||||
|
|
||||||
|
status="$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null)"
|
||||||
|
if echo "$status" | grep -Eq "$expected_re"; then
|
||||||
|
echo "✓ $name is accessible ($url -> $status)"
|
||||||
|
else
|
||||||
|
echo "✗ $name is not accessible ($url -> ${status:-000})"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_listen_port() {
|
||||||
|
local name="$1"
|
||||||
|
local port="$2"
|
||||||
|
|
||||||
|
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||||
|
echo "✓ $name is listening on port $port"
|
||||||
|
else
|
||||||
|
echo "✗ $name is not listening on port $port"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
docker_available() {
|
||||||
|
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_mode() {
|
||||||
|
case "$mode" in
|
||||||
|
local|docker)
|
||||||
|
echo "$mode"
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if docker_available && docker ps --format "{{.Names}}" | grep -q "deer-flow"; then
|
||||||
|
echo "docker"
|
||||||
|
else
|
||||||
|
echo "local"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
mode="$(detect_mode)"
|
||||||
|
|
||||||
|
echo "Deployment mode: $mode"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$mode" = "docker" ]; then
|
||||||
|
summary_hint="make docker-logs"
|
||||||
|
print_step "1. Checking container status..."
|
||||||
|
if docker ps --format "{{.Names}}" | grep -q "deer-flow"; then
|
||||||
|
echo "✓ Containers are running:"
|
||||||
|
docker ps --format " - {{.Names}} ({{.Status}})"
|
||||||
|
else
|
||||||
|
echo "✗ No DeerFlow-related containers are running"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
summary_hint="logs/{langgraph,gateway,frontend,nginx}.log"
|
||||||
|
print_step "1. Checking local service ports..."
|
||||||
|
check_listen_port "Nginx" 2026
|
||||||
|
check_listen_port "Frontend" 3000
|
||||||
|
check_listen_port "Gateway" 8001
|
||||||
|
check_listen_port "LangGraph" 2024
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "2. Waiting for services to fully start (30 seconds)..."
|
||||||
|
sleep 30
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "3. Checking frontend service..."
|
||||||
|
check_http_status "Frontend service" "http://localhost:2026" "200|301|302|307|308"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "4. Checking API Gateway..."
|
||||||
|
health_response=$(curl -s http://localhost:2026/health 2>/dev/null)
|
||||||
|
if [ $? -eq 0 ] && [ -n "$health_response" ]; then
|
||||||
|
echo "✓ API Gateway health check passed"
|
||||||
|
echo " Response: $health_response"
|
||||||
|
else
|
||||||
|
echo "✗ API Gateway health check failed"
|
||||||
|
all_passed=false
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "5. Checking LangGraph service..."
|
||||||
|
check_http_status "LangGraph service" "http://localhost:2024/" "200|301|302|307|308|404"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Health Check Summary"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
if [ "$all_passed" = true ]; then
|
||||||
|
echo "✅ All checks passed!"
|
||||||
|
echo ""
|
||||||
|
echo "🌐 Application URL: http://localhost:2026"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "❌ Some checks failed"
|
||||||
|
echo ""
|
||||||
|
echo "Please review: $summary_hint"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Pulling the Latest Code"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check whether the current directory is a Git repository
|
||||||
|
if [ ! -d ".git" ]; then
|
||||||
|
echo "✗ The current directory is not a Git repository"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Git status
|
||||||
|
echo "Checking Git status..."
|
||||||
|
if git status --porcelain | grep -q .; then
|
||||||
|
echo "⚠ Uncommitted changes detected:"
|
||||||
|
git status --short
|
||||||
|
echo ""
|
||||||
|
echo "Please commit or stash your changes before continuing"
|
||||||
|
echo "Options:"
|
||||||
|
echo " 1. git add . && git commit -m 'Save changes'"
|
||||||
|
echo " 2. git stash (stash changes and restore them later)"
|
||||||
|
echo " 3. git reset --hard HEAD (discard local changes - use with caution)"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✓ Working tree is clean"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Fetch remote updates
|
||||||
|
echo "Fetching remote updates..."
|
||||||
|
git fetch origin main
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Pull the latest code
|
||||||
|
echo "Pulling the latest code..."
|
||||||
|
git pull origin main
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Show the latest commit
|
||||||
|
echo "Latest commit:"
|
||||||
|
git log -1 --oneline
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Code Update Complete"
|
||||||
|
echo "=========================================="
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# DeerFlow Smoke Test Report
|
||||||
|
|
||||||
|
**Test Date**: {{test_date}}
|
||||||
|
**Test Environment**: {{test_environment}}
|
||||||
|
**Deployment Mode**: Docker
|
||||||
|
**Test Version**: {{git_commit}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Summary
|
||||||
|
|
||||||
|
| Metric | Status |
|
||||||
|
|------|------|
|
||||||
|
| Total Test Phases | 6 |
|
||||||
|
| Passed Phases | {{passed_stages}} |
|
||||||
|
| Failed Phases | {{failed_stages}} |
|
||||||
|
| Overall Conclusion | **{{overall_status}}** |
|
||||||
|
|
||||||
|
### Key Test Cases
|
||||||
|
|
||||||
|
| Case | Result | Details |
|
||||||
|
|------|--------|---------|
|
||||||
|
| Code update check | {{case_code_update}} | {{case_code_update_details}} |
|
||||||
|
| Environment check | {{case_env_check}} | {{case_env_check_details}} |
|
||||||
|
| Configuration preparation | {{case_config_prep}} | {{case_config_prep_details}} |
|
||||||
|
| Deployment | {{case_deploy}} | {{case_deploy_details}} |
|
||||||
|
| Health check | {{case_health_check}} | {{case_health_check_details}} |
|
||||||
|
| Frontend routes | {{case_frontend_routes_overall}} | {{case_frontend_routes_details}} |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Test Results
|
||||||
|
|
||||||
|
### Phase 1: Code Update Check
|
||||||
|
|
||||||
|
- [x] Confirm current directory - {{status_dir_check}}
|
||||||
|
- [x] Check Git status - {{status_git_status}}
|
||||||
|
- [x] Pull latest code - {{status_git_pull}}
|
||||||
|
- [x] Confirm code update - {{status_git_verify}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage1_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Docker Environment Check
|
||||||
|
|
||||||
|
- [x] Docker version - {{status_docker_version}}
|
||||||
|
- [x] Docker daemon - {{status_docker_daemon}}
|
||||||
|
- [x] Docker Compose - {{status_docker_compose}}
|
||||||
|
- [x] Port check - {{status_port_check}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage2_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Configuration Preparation
|
||||||
|
|
||||||
|
- [x] config.yaml - {{status_config_yaml}}
|
||||||
|
- [x] .env file - {{status_env_file}}
|
||||||
|
- [x] Model configuration - {{status_model_config}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage3_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Docker Deployment
|
||||||
|
|
||||||
|
- [x] docker-init - {{status_docker_init}}
|
||||||
|
- [x] docker-start - {{status_docker_start}}
|
||||||
|
- [x] Service startup wait - {{status_wait_startup}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage4_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Service Health Check
|
||||||
|
|
||||||
|
- [x] Container status - {{status_containers}}
|
||||||
|
- [x] Frontend service - {{status_frontend}}
|
||||||
|
- [x] API Gateway - {{status_api_gateway}}
|
||||||
|
- [x] LangGraph service - {{status_langgraph}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage5_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Routes Smoke Results
|
||||||
|
|
||||||
|
| Route | Status | Details |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| Landing `/` | {{landing_status}} | {{landing_details}} |
|
||||||
|
| Workspace redirect `/workspace` | {{workspace_redirect_status}} | target {{workspace_redirect_target}} |
|
||||||
|
| New chat `/workspace/chats/new` | {{new_chat_status}} | {{new_chat_details}} |
|
||||||
|
| Chats list `/workspace/chats` | {{chats_list_status}} | {{chats_list_details}} |
|
||||||
|
| Agents gallery `/workspace/agents` | {{agents_gallery_status}} | {{agents_gallery_details}} |
|
||||||
|
| Docs `{{docs_path}}` | {{docs_status}} | {{docs_details}} |
|
||||||
|
|
||||||
|
**Summary**: {{frontend_routes_summary}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Test Report Generation
|
||||||
|
|
||||||
|
- [x] Result summary - {{status_summary}}
|
||||||
|
- [x] Issue log - {{status_issues}}
|
||||||
|
- [x] Report generation - {{status_report}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage6_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue Log
|
||||||
|
|
||||||
|
### Issue 1
|
||||||
|
**Description**: {{issue1_description}}
|
||||||
|
**Severity**: {{issue1_severity}}
|
||||||
|
**Solution**: {{issue1_solution}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Information
|
||||||
|
|
||||||
|
### Docker Version
|
||||||
|
```text
|
||||||
|
{{docker_version_output}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git Information
|
||||||
|
```text
|
||||||
|
Repository: {{git_repo}}
|
||||||
|
Branch: {{git_branch}}
|
||||||
|
Commit: {{git_commit}}
|
||||||
|
Commit Message: {{git_commit_message}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Summary
|
||||||
|
- config.yaml exists: {{config_exists}}
|
||||||
|
- .env file exists: {{env_exists}}
|
||||||
|
- Number of configured models: {{model_count}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Container Status
|
||||||
|
|
||||||
|
| Container Name | Status | Uptime |
|
||||||
|
|----------|------|----------|
|
||||||
|
| deer-flow-nginx | {{nginx_status}} | {{nginx_uptime}} |
|
||||||
|
| deer-flow-frontend | {{frontend_status}} | {{frontend_uptime}} |
|
||||||
|
| deer-flow-gateway | {{gateway_status}} | {{gateway_uptime}} |
|
||||||
|
| deer-flow-langgraph | {{langgraph_status}} | {{langgraph_uptime}} |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations and Next Steps
|
||||||
|
|
||||||
|
### If the Test Passes
|
||||||
|
1. [ ] Visit http://localhost:2026 to start using DeerFlow
|
||||||
|
2. [ ] Configure your preferred model if it is not configured yet
|
||||||
|
3. [ ] Explore available skills
|
||||||
|
4. [ ] Refer to the documentation to learn more features
|
||||||
|
|
||||||
|
### If the Test Fails
|
||||||
|
1. [ ] Review references/troubleshooting.md for common solutions
|
||||||
|
2. [ ] Check Docker logs: `make docker-logs`
|
||||||
|
3. [ ] Verify configuration file format and content
|
||||||
|
4. [ ] If needed, fully reset the environment: `make clean && make config && make docker-init && make docker-start`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix
|
||||||
|
|
||||||
|
### Full Logs
|
||||||
|
{{full_logs}}
|
||||||
|
|
||||||
|
### Tester
|
||||||
|
{{tester_name}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Report generated at: {{report_time}}*
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# DeerFlow Smoke Test Report
|
||||||
|
|
||||||
|
**Test Date**: {{test_date}}
|
||||||
|
**Test Environment**: {{test_environment}}
|
||||||
|
**Deployment Mode**: Local
|
||||||
|
**Test Version**: {{git_commit}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Summary
|
||||||
|
|
||||||
|
| Metric | Status |
|
||||||
|
|------|------|
|
||||||
|
| Total Test Phases | 6 |
|
||||||
|
| Passed Phases | {{passed_stages}} |
|
||||||
|
| Failed Phases | {{failed_stages}} |
|
||||||
|
| Overall Conclusion | **{{overall_status}}** |
|
||||||
|
|
||||||
|
### Key Test Cases
|
||||||
|
|
||||||
|
| Case | Result | Details |
|
||||||
|
|------|--------|---------|
|
||||||
|
| Code update check | {{case_code_update}} | {{case_code_update_details}} |
|
||||||
|
| Environment check | {{case_env_check}} | {{case_env_check_details}} |
|
||||||
|
| Configuration preparation | {{case_config_prep}} | {{case_config_prep_details}} |
|
||||||
|
| Deployment | {{case_deploy}} | {{case_deploy_details}} |
|
||||||
|
| Health check | {{case_health_check}} | {{case_health_check_details}} |
|
||||||
|
| Frontend routes | {{case_frontend_routes_overall}} | {{case_frontend_routes_details}} |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Test Results
|
||||||
|
|
||||||
|
### Phase 1: Code Update Check
|
||||||
|
|
||||||
|
- [x] Confirm current directory - {{status_dir_check}}
|
||||||
|
- [x] Check Git status - {{status_git_status}}
|
||||||
|
- [x] Pull latest code - {{status_git_pull}}
|
||||||
|
- [x] Confirm code update - {{status_git_verify}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage1_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Local Environment Check
|
||||||
|
|
||||||
|
- [x] Node.js version - {{status_node_version}}
|
||||||
|
- [x] pnpm - {{status_pnpm}}
|
||||||
|
- [x] uv - {{status_uv}}
|
||||||
|
- [x] nginx - {{status_nginx}}
|
||||||
|
- [x] Port check - {{status_port_check}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage2_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Configuration Preparation
|
||||||
|
|
||||||
|
- [x] config.yaml - {{status_config_yaml}}
|
||||||
|
- [x] .env file - {{status_env_file}}
|
||||||
|
- [x] Model configuration - {{status_model_config}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage3_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Local Deployment
|
||||||
|
|
||||||
|
- [x] make check - {{status_make_check}}
|
||||||
|
- [x] make install - {{status_make_install}}
|
||||||
|
- [x] make dev-daemon / make dev - {{status_local_start}}
|
||||||
|
- [x] Service startup wait - {{status_wait_startup}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage4_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Service Health Check
|
||||||
|
|
||||||
|
- [x] Process status - {{status_processes}}
|
||||||
|
- [x] Frontend service - {{status_frontend}}
|
||||||
|
- [x] API Gateway - {{status_api_gateway}}
|
||||||
|
- [x] LangGraph service - {{status_langgraph}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage5_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Routes Smoke Results
|
||||||
|
|
||||||
|
| Route | Status | Details |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| Landing `/` | {{landing_status}} | {{landing_details}} |
|
||||||
|
| Workspace redirect `/workspace` | {{workspace_redirect_status}} | target {{workspace_redirect_target}} |
|
||||||
|
| New chat `/workspace/chats/new` | {{new_chat_status}} | {{new_chat_details}} |
|
||||||
|
| Chats list `/workspace/chats` | {{chats_list_status}} | {{chats_list_details}} |
|
||||||
|
| Agents gallery `/workspace/agents` | {{agents_gallery_status}} | {{agents_gallery_details}} |
|
||||||
|
| Docs `{{docs_path}}` | {{docs_status}} | {{docs_details}} |
|
||||||
|
|
||||||
|
**Summary**: {{frontend_routes_summary}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Test Report Generation
|
||||||
|
|
||||||
|
- [x] Result summary - {{status_summary}}
|
||||||
|
- [x] Issue log - {{status_issues}}
|
||||||
|
- [x] Report generation - {{status_report}}
|
||||||
|
|
||||||
|
**Phase Status**: {{stage6_status}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue Log
|
||||||
|
|
||||||
|
### Issue 1
|
||||||
|
**Description**: {{issue1_description}}
|
||||||
|
**Severity**: {{issue1_severity}}
|
||||||
|
**Solution**: {{issue1_solution}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Information
|
||||||
|
|
||||||
|
### Local Dependency Versions
|
||||||
|
```text
|
||||||
|
Node.js: {{node_version_output}}
|
||||||
|
pnpm: {{pnpm_version_output}}
|
||||||
|
uv: {{uv_version_output}}
|
||||||
|
nginx: {{nginx_version_output}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git Information
|
||||||
|
```text
|
||||||
|
Repository: {{git_repo}}
|
||||||
|
Branch: {{git_branch}}
|
||||||
|
Commit: {{git_commit}}
|
||||||
|
Commit Message: {{git_commit_message}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Summary
|
||||||
|
- config.yaml exists: {{config_exists}}
|
||||||
|
- .env file exists: {{env_exists}}
|
||||||
|
- Number of configured models: {{model_count}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Service Status
|
||||||
|
|
||||||
|
| Service | Status | Endpoint |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| Nginx | {{nginx_status}} | {{nginx_endpoint}} |
|
||||||
|
| Frontend | {{frontend_status}} | {{frontend_endpoint}} |
|
||||||
|
| Gateway | {{gateway_status}} | {{gateway_endpoint}} |
|
||||||
|
| LangGraph | {{langgraph_status}} | {{langgraph_endpoint}} |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations and Next Steps
|
||||||
|
|
||||||
|
### If the Test Passes
|
||||||
|
1. [ ] Visit http://localhost:2026 to start using DeerFlow
|
||||||
|
2. [ ] Configure your preferred model if it is not configured yet
|
||||||
|
3. [ ] Explore available skills
|
||||||
|
4. [ ] Refer to the documentation to learn more features
|
||||||
|
|
||||||
|
### If the Test Fails
|
||||||
|
1. [ ] Review references/troubleshooting.md for common solutions
|
||||||
|
2. [ ] Check local logs: `logs/{langgraph,gateway,frontend,nginx}.log`
|
||||||
|
3. [ ] Verify configuration file format and content
|
||||||
|
4. [ ] If needed, fully reset the environment: `make stop && make clean && make install && make dev-daemon`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix
|
||||||
|
|
||||||
|
### Full Logs
|
||||||
|
{{full_logs}}
|
||||||
|
|
||||||
|
### Tester
|
||||||
|
{{tester_name}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Report generated at: {{report_time}}*
|
||||||
@@ -24,6 +24,7 @@ INFOQUEST_API_KEY=your-infoquest-api-key
|
|||||||
# SLACK_BOT_TOKEN=your-slack-bot-token
|
# SLACK_BOT_TOKEN=your-slack-bot-token
|
||||||
# SLACK_APP_TOKEN=your-slack-app-token
|
# SLACK_APP_TOKEN=your-slack-app-token
|
||||||
# TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
# TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||||
|
# DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||||
|
|
||||||
# Enable LangSmith to monitor and debug your LLM calls, agent runs, and tool executions.
|
# Enable LangSmith to monitor and debug your LLM calls, agent runs, and tool executions.
|
||||||
# LANGSMITH_TRACING=true
|
# LANGSMITH_TRACING=true
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ 'main' ]
|
||||||
|
paths:
|
||||||
|
- 'frontend/**'
|
||||||
|
- '.github/workflows/e2e-tests.yml'
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
paths:
|
||||||
|
- 'frontend/**'
|
||||||
|
- '.github/workflows/e2e-tests.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: e2e-tests-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e-tests:
|
||||||
|
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Use pinned pnpm version
|
||||||
|
run: corepack prepare pnpm@10.26.2 --activate
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
working-directory: frontend
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
working-directory: frontend
|
||||||
|
run: pnpm exec playwright test
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: '1'
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: frontend/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
name: Frontend Unit Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ 'main' ]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: frontend-unit-tests-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend-unit-tests:
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Use pinned pnpm version
|
||||||
|
run: corepack prepare pnpm@10.26.2 --activate
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run unit tests of frontend
|
||||||
|
working-directory: frontend
|
||||||
|
run: make test
|
||||||
@@ -40,6 +40,7 @@ coverage/
|
|||||||
skills/custom/*
|
skills/custom/*
|
||||||
logs/
|
logs/
|
||||||
log/
|
log/
|
||||||
|
debug.log
|
||||||
|
|
||||||
# Local git hooks (keep only on this machine, do not push)
|
# Local git hooks (keep only on this machine, do not push)
|
||||||
.githooks/
|
.githooks/
|
||||||
@@ -55,5 +56,7 @@ web/
|
|||||||
backend/Dockerfile.langgraph
|
backend/Dockerfile.langgraph
|
||||||
config.yaml.bak
|
config.yaml.bak
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
|
/frontend/test-results/
|
||||||
|
/frontend/playwright-report/
|
||||||
.gstack/
|
.gstack/
|
||||||
.worktrees
|
.worktrees
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
repos:
|
||||||
|
# Backend: ruff lint + format via uv (uses the same ruff version as backend deps)
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
name: ruff lint
|
||||||
|
entry: bash -c 'cd backend && uv run ruff check --fix "${@/#backend\//}"' --
|
||||||
|
language: system
|
||||||
|
types_or: [python]
|
||||||
|
files: ^backend/
|
||||||
|
- id: ruff-format
|
||||||
|
name: ruff format
|
||||||
|
entry: bash -c 'cd backend && uv run ruff format "${@/#backend\//}"' --
|
||||||
|
language: system
|
||||||
|
types_or: [python]
|
||||||
|
files: ^backend/
|
||||||
|
|
||||||
|
# Frontend: eslint + prettier (must run from frontend/ for node_modules resolution)
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: frontend-eslint
|
||||||
|
name: eslint (frontend)
|
||||||
|
entry: bash -c 'cd frontend && npx eslint --fix "${@/#frontend\//}"' --
|
||||||
|
language: system
|
||||||
|
types_or: [javascript, tsx, ts]
|
||||||
|
files: ^frontend/
|
||||||
|
|
||||||
|
- id: frontend-prettier
|
||||||
|
name: prettier (frontend)
|
||||||
|
entry: bash -c 'cd frontend && npx prettier --write "${@/#frontend\//}"' --
|
||||||
|
language: system
|
||||||
|
files: ^frontend/
|
||||||
|
types_or: [javascript, tsx, ts, json, css]
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
willem.jiang@gmail.com.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
+24
-7
@@ -77,6 +77,18 @@ export UV_INDEX_URL=https://pypi.org/simple
|
|||||||
export NPM_REGISTRY=https://registry.npmjs.org
|
export NPM_REGISTRY=https://registry.npmjs.org
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Recommended host resources
|
||||||
|
|
||||||
|
Use these as practical starting points for development and review environments:
|
||||||
|
|
||||||
|
| Scenario | Starting point | Recommended | Notes |
|
||||||
|
|---------|-----------|------------|-------|
|
||||||
|
| `make dev` on one machine | 4 vCPU, 8 GB RAM | 8 vCPU, 16 GB RAM | Best when DeerFlow uses hosted model APIs. |
|
||||||
|
| `make docker-start` review environment | 4 vCPU, 8 GB RAM | 8 vCPU, 16 GB RAM | Docker image builds and sandbox containers need extra headroom. |
|
||||||
|
| Shared Linux test server | 8 vCPU, 16 GB RAM | 16 vCPU, 32 GB RAM | Prefer this for heavier multi-agent runs or multiple reviewers. |
|
||||||
|
|
||||||
|
`2 vCPU / 4 GB` environments often fail to start reliably or become unresponsive under normal DeerFlow workloads.
|
||||||
|
|
||||||
#### Linux: Docker daemon permission denied
|
#### Linux: Docker daemon permission denied
|
||||||
|
|
||||||
If `make docker-init`, `make docker-start`, or `make docker-stop` fails on Linux with an error like below, your current user likely does not have permission to access the Docker daemon socket:
|
If `make docker-init`, `make docker-start`, or `make docker-stop` fails on Linux with an error like below, your current user likely does not have permission to access the Docker daemon socket:
|
||||||
@@ -154,7 +166,7 @@ Required tools:
|
|||||||
|
|
||||||
1. **Configure the application** (same as Docker setup above)
|
1. **Configure the application** (same as Docker setup above)
|
||||||
|
|
||||||
2. **Install dependencies**:
|
2. **Install dependencies** (this also sets up pre-commit hooks):
|
||||||
```bash
|
```bash
|
||||||
make install
|
make install
|
||||||
```
|
```
|
||||||
@@ -286,19 +298,24 @@ Nginx (port 2026) ← Unified entry point
|
|||||||
```bash
|
```bash
|
||||||
# Backend tests
|
# Backend tests
|
||||||
cd backend
|
cd backend
|
||||||
uv run pytest
|
make test
|
||||||
|
|
||||||
# Frontend checks
|
# Frontend unit tests
|
||||||
cd frontend
|
cd frontend
|
||||||
pnpm check
|
make test
|
||||||
|
|
||||||
|
# Frontend E2E tests (requires Chromium; builds and auto-starts the Next.js production server)
|
||||||
|
cd frontend
|
||||||
|
make test-e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
### PR Regression Checks
|
### PR Regression Checks
|
||||||
|
|
||||||
Every pull request runs the backend regression workflow at [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml), including:
|
Every pull request triggers the following CI workflows:
|
||||||
|
|
||||||
- `tests/test_provisioner_kubeconfig.py`
|
- **Backend unit tests** — [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml)
|
||||||
- `tests/test_docker_sandbox_mode_detection.py`
|
- **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml)
|
||||||
|
- **Frontend E2E tests** — [.github/workflows/e2e-tests.yml](.github/workflows/e2e-tests.yml) (triggered only when `frontend/` files change)
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
# DeerFlow - Unified Development Environment
|
# DeerFlow - Unified Development Environment
|
||||||
|
|
||||||
.PHONY: help config config-upgrade check install dev dev-pro dev-daemon dev-daemon-pro start start-pro start-daemon start-daemon-pro stop up up-pro down clean docker-init docker-start docker-start-pro docker-stop docker-logs docker-logs-frontend docker-logs-gateway
|
.PHONY: help config config-upgrade check install setup doctor dev dev-pro dev-daemon dev-daemon-pro start start-pro start-daemon start-daemon-pro stop up up-pro down clean docker-init docker-start docker-start-pro docker-stop docker-logs docker-logs-frontend docker-logs-gateway
|
||||||
|
|
||||||
BASH ?= bash
|
BASH ?= bash
|
||||||
|
BACKEND_UV_RUN = cd backend && uv run
|
||||||
|
|
||||||
# Detect OS for Windows compatibility
|
# Detect OS for Windows compatibility
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
SHELL := cmd.exe
|
SHELL := cmd.exe
|
||||||
PYTHON ?= python
|
PYTHON ?= python
|
||||||
|
# Run repo shell scripts through Git Bash when Make is launched from cmd.exe / PowerShell.
|
||||||
|
RUN_WITH_GIT_BASH = call scripts\run-with-git-bash.cmd
|
||||||
else
|
else
|
||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
|
RUN_WITH_GIT_BASH =
|
||||||
endif
|
endif
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "DeerFlow Development Commands:"
|
@echo "DeerFlow Development Commands:"
|
||||||
|
@echo " make setup - Interactive setup wizard (recommended for new users)"
|
||||||
|
@echo " make doctor - Check configuration and system requirements"
|
||||||
@echo " make config - Generate local config files (aborts if config already exists)"
|
@echo " make config - Generate local config files (aborts if config already exists)"
|
||||||
@echo " make config-upgrade - Merge new fields from config.example.yaml into config.yaml"
|
@echo " make config-upgrade - Merge new fields from config.example.yaml into config.yaml"
|
||||||
@echo " make check - Check if all required tools are installed"
|
@echo " make check - Check if all required tools are installed"
|
||||||
@echo " make install - Install all dependencies (frontend + backend)"
|
@echo " make install - Install all dependencies (frontend + backend + pre-commit hooks)"
|
||||||
@echo " make setup-sandbox - Pre-pull sandbox container image (recommended)"
|
@echo " make setup-sandbox - Pre-pull sandbox container image (recommended)"
|
||||||
@echo " make dev - Start all services in development mode (with hot-reloading)"
|
@echo " make dev - Start all services in development mode (with hot-reloading)"
|
||||||
@echo " make dev-pro - Start in dev + Gateway mode (experimental, no LangGraph server)"
|
@echo " make dev-pro - Start in dev + Gateway mode (experimental, no LangGraph server)"
|
||||||
@@ -44,11 +50,18 @@ help:
|
|||||||
@echo " make docker-logs-frontend - View Docker frontend logs"
|
@echo " make docker-logs-frontend - View Docker frontend logs"
|
||||||
@echo " make docker-logs-gateway - View Docker gateway logs"
|
@echo " make docker-logs-gateway - View Docker gateway logs"
|
||||||
|
|
||||||
|
## Setup & Diagnosis
|
||||||
|
setup:
|
||||||
|
@$(BACKEND_UV_RUN) python ../scripts/setup_wizard.py
|
||||||
|
|
||||||
|
doctor:
|
||||||
|
@$(BACKEND_UV_RUN) python ../scripts/doctor.py
|
||||||
|
|
||||||
config:
|
config:
|
||||||
@$(PYTHON) ./scripts/configure.py
|
@$(PYTHON) ./scripts/configure.py
|
||||||
|
|
||||||
config-upgrade:
|
config-upgrade:
|
||||||
@./scripts/config-upgrade.sh
|
@$(RUN_WITH_GIT_BASH) ./scripts/config-upgrade.sh
|
||||||
|
|
||||||
# Check required tools
|
# Check required tools
|
||||||
check:
|
check:
|
||||||
@@ -60,6 +73,8 @@ install:
|
|||||||
@cd backend && uv sync
|
@cd backend && uv sync
|
||||||
@echo "Installing frontend dependencies..."
|
@echo "Installing frontend dependencies..."
|
||||||
@cd frontend && pnpm install
|
@cd frontend && pnpm install
|
||||||
|
@echo "Installing pre-commit hooks..."
|
||||||
|
@$(BACKEND_UV_RUN) --with pre-commit pre-commit install
|
||||||
@echo "✓ All dependencies installed"
|
@echo "✓ All dependencies installed"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=========================================="
|
@echo "=========================================="
|
||||||
@@ -86,7 +101,7 @@ setup-sandbox:
|
|||||||
echo ""; \
|
echo ""; \
|
||||||
if command -v container >/dev/null 2>&1 && [ "$$(uname)" = "Darwin" ]; then \
|
if command -v container >/dev/null 2>&1 && [ "$$(uname)" = "Darwin" ]; then \
|
||||||
echo "Detected Apple Container on macOS, pulling image..."; \
|
echo "Detected Apple Container on macOS, pulling image..."; \
|
||||||
container pull "$$IMAGE" || echo "⚠ Apple Container pull failed, will try Docker"; \
|
container image pull "$$IMAGE" || echo "⚠ Apple Container pull failed, will try Docker"; \
|
||||||
fi; \
|
fi; \
|
||||||
if command -v docker >/dev/null 2>&1; then \
|
if command -v docker >/dev/null 2>&1; then \
|
||||||
echo "Pulling image using Docker..."; \
|
echo "Pulling image using Docker..."; \
|
||||||
@@ -106,78 +121,46 @@ setup-sandbox:
|
|||||||
# Start all services in development mode (with hot-reloading)
|
# Start all services in development mode (with hot-reloading)
|
||||||
dev:
|
dev:
|
||||||
@$(PYTHON) ./scripts/check.py
|
@$(PYTHON) ./scripts/check.py
|
||||||
ifeq ($(OS),Windows_NT)
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev
|
||||||
@call scripts\run-with-git-bash.cmd ./scripts/serve.sh --dev
|
|
||||||
else
|
|
||||||
@./scripts/serve.sh --dev
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Start all services in dev + Gateway mode (experimental: agent runtime embedded in Gateway)
|
# Start all services in dev + Gateway mode (experimental: agent runtime embedded in Gateway)
|
||||||
dev-pro:
|
dev-pro:
|
||||||
@$(PYTHON) ./scripts/check.py
|
@$(PYTHON) ./scripts/check.py
|
||||||
ifeq ($(OS),Windows_NT)
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev --gateway
|
||||||
@call scripts\run-with-git-bash.cmd ./scripts/serve.sh --dev --gateway
|
|
||||||
else
|
|
||||||
@./scripts/serve.sh --dev --gateway
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Start all services in production mode (with optimizations)
|
# Start all services in production mode (with optimizations)
|
||||||
start:
|
start:
|
||||||
@$(PYTHON) ./scripts/check.py
|
@$(PYTHON) ./scripts/check.py
|
||||||
ifeq ($(OS),Windows_NT)
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod
|
||||||
@call scripts\run-with-git-bash.cmd ./scripts/serve.sh --prod
|
|
||||||
else
|
|
||||||
@./scripts/serve.sh --prod
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Start all services in prod + Gateway mode (experimental)
|
# Start all services in prod + Gateway mode (experimental)
|
||||||
start-pro:
|
start-pro:
|
||||||
@$(PYTHON) ./scripts/check.py
|
@$(PYTHON) ./scripts/check.py
|
||||||
ifeq ($(OS),Windows_NT)
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod --gateway
|
||||||
@call scripts\run-with-git-bash.cmd ./scripts/serve.sh --prod --gateway
|
|
||||||
else
|
|
||||||
@./scripts/serve.sh --prod --gateway
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Start all services in daemon mode (background)
|
# Start all services in daemon mode (background)
|
||||||
dev-daemon:
|
dev-daemon:
|
||||||
@$(PYTHON) ./scripts/check.py
|
@$(PYTHON) ./scripts/check.py
|
||||||
ifeq ($(OS),Windows_NT)
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev --daemon
|
||||||
@call scripts\run-with-git-bash.cmd ./scripts/serve.sh --dev --daemon
|
|
||||||
else
|
|
||||||
@./scripts/serve.sh --dev --daemon
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Start daemon + Gateway mode (experimental)
|
# Start daemon + Gateway mode (experimental)
|
||||||
dev-daemon-pro:
|
dev-daemon-pro:
|
||||||
@$(PYTHON) ./scripts/check.py
|
@$(PYTHON) ./scripts/check.py
|
||||||
ifeq ($(OS),Windows_NT)
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev --gateway --daemon
|
||||||
@call scripts\run-with-git-bash.cmd ./scripts/serve.sh --dev --gateway --daemon
|
|
||||||
else
|
|
||||||
@./scripts/serve.sh --dev --gateway --daemon
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Start prod services in daemon mode (background)
|
# Start prod services in daemon mode (background)
|
||||||
start-daemon:
|
start-daemon:
|
||||||
@$(PYTHON) ./scripts/check.py
|
@$(PYTHON) ./scripts/check.py
|
||||||
ifeq ($(OS),Windows_NT)
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod --daemon
|
||||||
@call scripts\run-with-git-bash.cmd ./scripts/serve.sh --prod --daemon
|
|
||||||
else
|
|
||||||
@./scripts/serve.sh --prod --daemon
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Start prod daemon + Gateway mode (experimental)
|
# Start prod daemon + Gateway mode (experimental)
|
||||||
start-daemon-pro:
|
start-daemon-pro:
|
||||||
@$(PYTHON) ./scripts/check.py
|
@$(PYTHON) ./scripts/check.py
|
||||||
ifeq ($(OS),Windows_NT)
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod --gateway --daemon
|
||||||
@call scripts\run-with-git-bash.cmd ./scripts/serve.sh --prod --gateway --daemon
|
|
||||||
else
|
|
||||||
@./scripts/serve.sh --prod --gateway --daemon
|
|
||||||
endif
|
|
||||||
|
|
||||||
# Stop all services
|
# Stop all services
|
||||||
stop:
|
stop:
|
||||||
@./scripts/serve.sh --stop
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --stop
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
clean: stop
|
clean: stop
|
||||||
@@ -193,29 +176,29 @@ clean: stop
|
|||||||
|
|
||||||
# Initialize Docker containers and install dependencies
|
# Initialize Docker containers and install dependencies
|
||||||
docker-init:
|
docker-init:
|
||||||
@./scripts/docker.sh init
|
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh init
|
||||||
|
|
||||||
# Start Docker development environment
|
# Start Docker development environment
|
||||||
docker-start:
|
docker-start:
|
||||||
@./scripts/docker.sh start
|
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh start
|
||||||
|
|
||||||
# Start Docker in Gateway mode (experimental)
|
# Start Docker in Gateway mode (experimental)
|
||||||
docker-start-pro:
|
docker-start-pro:
|
||||||
@./scripts/docker.sh start --gateway
|
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh start --gateway
|
||||||
|
|
||||||
# Stop Docker development environment
|
# Stop Docker development environment
|
||||||
docker-stop:
|
docker-stop:
|
||||||
@./scripts/docker.sh stop
|
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh stop
|
||||||
|
|
||||||
# View Docker development logs
|
# View Docker development logs
|
||||||
docker-logs:
|
docker-logs:
|
||||||
@./scripts/docker.sh logs
|
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs
|
||||||
|
|
||||||
# View Docker development logs
|
# View Docker development logs
|
||||||
docker-logs-frontend:
|
docker-logs-frontend:
|
||||||
@./scripts/docker.sh logs --frontend
|
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs --frontend
|
||||||
docker-logs-gateway:
|
docker-logs-gateway:
|
||||||
@./scripts/docker.sh logs --gateway
|
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs --gateway
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Production Docker Commands
|
# Production Docker Commands
|
||||||
@@ -223,12 +206,12 @@ docker-logs-gateway:
|
|||||||
|
|
||||||
# Build and start production services
|
# Build and start production services
|
||||||
up:
|
up:
|
||||||
@./scripts/deploy.sh
|
@$(RUN_WITH_GIT_BASH) ./scripts/deploy.sh
|
||||||
|
|
||||||
# Build and start production services in Gateway mode
|
# Build and start production services in Gateway mode
|
||||||
up-pro:
|
up-pro:
|
||||||
@./scripts/deploy.sh --gateway
|
@$(RUN_WITH_GIT_BASH) ./scripts/deploy.sh --gateway
|
||||||
|
|
||||||
# Stop and remove production containers
|
# Stop and remove production containers
|
||||||
down:
|
down:
|
||||||
@./scripts/deploy.sh down
|
@$(RUN_WITH_GIT_BASH) ./scripts/deploy.sh down
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe
|
|||||||
- [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Running the Application](#running-the-application)
|
- [Running the Application](#running-the-application)
|
||||||
|
- [Deployment Sizing](#deployment-sizing)
|
||||||
- [Option 1: Docker (Recommended)](#option-1-docker-recommended)
|
- [Option 1: Docker (Recommended)](#option-1-docker-recommended)
|
||||||
- [Option 2: Local Development](#option-2-local-development)
|
- [Option 2: Local Development](#option-2-local-development)
|
||||||
- [Advanced](#advanced)
|
- [Advanced](#advanced)
|
||||||
@@ -103,35 +104,38 @@ That prompt is intended for coding agents. It tells the agent to clone the repo
|
|||||||
cd deer-flow
|
cd deer-flow
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Generate local configuration files**
|
2. **Run the setup wizard**
|
||||||
|
|
||||||
From the project root directory (`deer-flow/`), run:
|
From the project root directory (`deer-flow/`), run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make config
|
make setup
|
||||||
```
|
```
|
||||||
|
|
||||||
This command creates local configuration files based on the provided example templates.
|
This launches an interactive wizard that guides you through choosing an LLM provider, optional web search, and execution/safety preferences such as sandbox mode, bash access, and file-write tools. It generates a minimal `config.yaml` and writes your keys to `.env`. Takes about 2 minutes.
|
||||||
|
|
||||||
3. **Configure your preferred model(s)**
|
The wizard also lets you configure an optional web search provider, or skip it for now.
|
||||||
|
|
||||||
Edit `config.yaml` and define at least one model:
|
Run `make doctor` at any time to verify your setup and get actionable fix hints.
|
||||||
|
|
||||||
|
> **Advanced / manual configuration**: If you prefer to edit `config.yaml` directly, run `make config` instead to copy the full template. See `config.example.yaml` for the complete reference including CLI-backed providers (Codex CLI, Claude Code OAuth), OpenRouter, Responses API, and more.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Manual model configuration examples</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
models:
|
models:
|
||||||
- name: gpt-4 # Internal identifier
|
- name: gpt-4o
|
||||||
display_name: GPT-4 # Human-readable name
|
display_name: GPT-4o
|
||||||
use: langchain_openai:ChatOpenAI # LangChain class path
|
use: langchain_openai:ChatOpenAI
|
||||||
model: gpt-4 # Model identifier for API
|
model: gpt-4o
|
||||||
api_key: $OPENAI_API_KEY # API key (recommended: use env var)
|
api_key: $OPENAI_API_KEY
|
||||||
max_tokens: 4096 # Maximum tokens per request
|
|
||||||
temperature: 0.7 # Sampling temperature
|
|
||||||
|
|
||||||
- name: openrouter-gemini-2.5-flash
|
- name: openrouter-gemini-2.5-flash
|
||||||
display_name: Gemini 2.5 Flash (OpenRouter)
|
display_name: Gemini 2.5 Flash (OpenRouter)
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
model: google/gemini-2.5-flash-preview
|
model: google/gemini-2.5-flash-preview
|
||||||
api_key: $OPENAI_API_KEY # OpenRouter still uses the OpenAI-compatible field name here
|
api_key: $OPENROUTER_API_KEY
|
||||||
base_url: https://openrouter.ai/api/v1
|
base_url: https://openrouter.ai/api/v1
|
||||||
|
|
||||||
- name: gpt-5-responses
|
- name: gpt-5-responses
|
||||||
@@ -181,50 +185,39 @@ That prompt is intended for coding agents. It tells the agent to clone the repo
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Codex CLI reads `~/.codex/auth.json`
|
- Codex CLI reads `~/.codex/auth.json`
|
||||||
- The Codex Responses endpoint currently rejects `max_tokens` and `max_output_tokens`, so `CodexChatModel` does not expose a request-level token cap
|
- Claude Code accepts `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_CREDENTIALS_PATH`, or `~/.claude/.credentials.json`
|
||||||
- Claude Code accepts `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`, `CLAUDE_CODE_CREDENTIALS_PATH`, or plaintext `~/.claude/.credentials.json`
|
- ACP agent entries are separate from model providers — if you configure `acp_agents.codex`, point it at a Codex ACP adapter such as `npx -y @zed-industries/codex-acp`
|
||||||
- ACP agent entries are separate from model providers. If you configure `acp_agents.codex`, point it at a Codex ACP adapter such as `npx -y @zed-industries/codex-acp`; the standard `codex` CLI binary is not ACP-compatible by itself
|
- On macOS, export Claude Code auth explicitly if needed:
|
||||||
- On macOS, DeerFlow does not probe Keychain automatically. Export Claude Code auth explicitly if needed:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(python3 scripts/export_claude_code_oauth.py --print-export)"
|
eval "$(python3 scripts/export_claude_code_oauth.py --print-export)"
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Set API keys for your configured model(s)**
|
|
||||||
|
|
||||||
Choose one of the following methods:
|
|
||||||
|
|
||||||
- Option A: Edit the `.env` file in the project root (Recommended)
|
|
||||||
|
|
||||||
|
API keys can also be set manually in `.env` (recommended) or exported in your shell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
TAVILY_API_KEY=your-tavily-api-key
|
|
||||||
OPENAI_API_KEY=your-openai-api-key
|
OPENAI_API_KEY=your-openai-api-key
|
||||||
# OpenRouter also uses OPENAI_API_KEY when your config uses langchain_openai:ChatOpenAI + base_url.
|
TAVILY_API_KEY=your-tavily-api-key
|
||||||
# Add other provider keys as needed
|
|
||||||
INFOQUEST_API_KEY=your-infoquest-api-key
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Option B: Export environment variables in your shell
|
</details>
|
||||||
|
|
||||||
```bash
|
|
||||||
export OPENAI_API_KEY=your-openai-api-key
|
|
||||||
```
|
|
||||||
|
|
||||||
For CLI-backed providers:
|
|
||||||
- Codex CLI: `~/.codex/auth.json`
|
|
||||||
- Claude Code OAuth: explicit env/file handoff or `~/.claude/.credentials.json`
|
|
||||||
|
|
||||||
- Option C: Edit `config.yaml` directly (Not recommended for production)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
models:
|
|
||||||
- name: gpt-4
|
|
||||||
api_key: your-actual-api-key-here # Replace placeholder
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running the Application
|
### Running the Application
|
||||||
|
|
||||||
|
#### Deployment Sizing
|
||||||
|
|
||||||
|
Use the table below as a practical starting point when choosing how to run DeerFlow:
|
||||||
|
|
||||||
|
| Deployment target | Starting point | Recommended | Notes |
|
||||||
|
|---------|-----------|------------|-------|
|
||||||
|
| Local evaluation / `make dev` | 4 vCPU, 8 GB RAM, 20 GB free SSD | 8 vCPU, 16 GB RAM | Good for one developer or one light session with hosted model APIs. `2 vCPU / 4 GB` is usually not enough. |
|
||||||
|
| Docker development / `make docker-start` | 4 vCPU, 8 GB RAM, 25 GB free SSD | 8 vCPU, 16 GB RAM | Image builds, bind mounts, and sandbox containers need more headroom than pure local dev. |
|
||||||
|
| Long-running server / `make up` | 8 vCPU, 16 GB RAM, 40 GB free SSD | 16 vCPU, 32 GB RAM | Preferred for shared use, multi-agent runs, report generation, or heavier sandbox workloads. |
|
||||||
|
|
||||||
|
- These numbers cover DeerFlow itself. If you also host a local LLM, size that service separately.
|
||||||
|
- Linux plus Docker is the recommended deployment target for a persistent server. macOS and Windows are best treated as development or evaluation environments.
|
||||||
|
- If CPU or memory usage stays pinned, reduce concurrent runs first, then move to the next sizing tier.
|
||||||
|
|
||||||
#### Option 1: Docker (Recommended)
|
#### Option 1: Docker (Recommended)
|
||||||
|
|
||||||
**Development** (hot-reload, source mounts):
|
**Development** (hot-reload, source mounts):
|
||||||
@@ -261,7 +254,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide.
|
|||||||
|
|
||||||
If you prefer running services locally:
|
If you prefer running services locally:
|
||||||
|
|
||||||
Prerequisite: complete the "Configuration" steps above first (`make config` and model API keys). `make dev` requires a valid configuration file (defaults to `config.yaml` in the project root; can be overridden via `DEER_FLOW_CONFIG_PATH`).
|
Prerequisite: complete the "Configuration" steps above first (`make setup`). `make dev` requires a valid `config.yaml` in the project root (can be overridden via `DEER_FLOW_CONFIG_PATH`). Run `make doctor` to verify your setup before starting.
|
||||||
On Windows, run the local development flow from Git Bash. Native `cmd.exe` and PowerShell shells are not supported for the bash-based service scripts, and WSL is not guaranteed because some scripts rely on Git for Windows utilities such as `cygpath`.
|
On Windows, run the local development flow from Git Bash. Native `cmd.exe` and PowerShell shells are not supported for the bash-based service scripts, and WSL is not guaranteed because some scripts rely on Git for Windows utilities such as `cygpath`.
|
||||||
|
|
||||||
1. **Check prerequisites**:
|
1. **Check prerequisites**:
|
||||||
@@ -271,7 +264,7 @@ On Windows, run the local development flow from Git Bash. Native `cmd.exe` and P
|
|||||||
|
|
||||||
2. **Install dependencies**:
|
2. **Install dependencies**:
|
||||||
```bash
|
```bash
|
||||||
make install # Install backend + frontend dependencies
|
make install # Install backend + frontend dependencies + pre-commit hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **(Optional) Pre-pull sandbox image**:
|
3. **(Optional) Pre-pull sandbox image**:
|
||||||
@@ -375,6 +368,7 @@ DeerFlow supports receiving tasks from messaging apps. Channels auto-start when
|
|||||||
| Telegram | Bot API (long-polling) | Easy |
|
| Telegram | Bot API (long-polling) | Easy |
|
||||||
| Slack | Socket Mode | Moderate |
|
| Slack | Socket Mode | Moderate |
|
||||||
| Feishu / Lark | WebSocket | Moderate |
|
| Feishu / Lark | WebSocket | Moderate |
|
||||||
|
| WeChat | Tencent iLink (long-polling) | Moderate |
|
||||||
| WeCom | WebSocket | Moderate |
|
| WeCom | WebSocket | Moderate |
|
||||||
|
|
||||||
**Configuration in `config.yaml`:**
|
**Configuration in `config.yaml`:**
|
||||||
@@ -419,6 +413,19 @@ channels:
|
|||||||
bot_token: $TELEGRAM_BOT_TOKEN
|
bot_token: $TELEGRAM_BOT_TOKEN
|
||||||
allowed_users: [] # empty = allow all
|
allowed_users: [] # empty = allow all
|
||||||
|
|
||||||
|
wechat:
|
||||||
|
enabled: false
|
||||||
|
bot_token: $WECHAT_BOT_TOKEN
|
||||||
|
ilink_bot_id: $WECHAT_ILINK_BOT_ID
|
||||||
|
qrcode_login_enabled: true # optional: allow first-time QR bootstrap when bot_token is absent
|
||||||
|
allowed_users: [] # empty = allow all
|
||||||
|
polling_timeout: 35
|
||||||
|
state_dir: ./.deer-flow/wechat/state
|
||||||
|
max_inbound_image_bytes: 20971520
|
||||||
|
max_outbound_image_bytes: 20971520
|
||||||
|
max_inbound_file_bytes: 52428800
|
||||||
|
max_outbound_file_bytes: 52428800
|
||||||
|
|
||||||
# Optional: per-channel / per-user session settings
|
# Optional: per-channel / per-user session settings
|
||||||
session:
|
session:
|
||||||
assistant_id: mobile-agent # custom agent names are also supported here
|
assistant_id: mobile-agent # custom agent names are also supported here
|
||||||
@@ -452,6 +459,10 @@ SLACK_APP_TOKEN=xapp-...
|
|||||||
FEISHU_APP_ID=cli_xxxx
|
FEISHU_APP_ID=cli_xxxx
|
||||||
FEISHU_APP_SECRET=your_app_secret
|
FEISHU_APP_SECRET=your_app_secret
|
||||||
|
|
||||||
|
# WeChat iLink
|
||||||
|
WECHAT_BOT_TOKEN=your_ilink_bot_token
|
||||||
|
WECHAT_ILINK_BOT_ID=your_ilink_bot_id
|
||||||
|
|
||||||
# WeCom
|
# WeCom
|
||||||
WECOM_BOT_ID=your_bot_id
|
WECOM_BOT_ID=your_bot_id
|
||||||
WECOM_BOT_SECRET=your_bot_secret
|
WECOM_BOT_SECRET=your_bot_secret
|
||||||
@@ -477,6 +488,14 @@ WECOM_BOT_SECRET=your_bot_secret
|
|||||||
3. Under **Events**, subscribe to `im.message.receive_v1` and select **Long Connection** mode.
|
3. Under **Events**, subscribe to `im.message.receive_v1` and select **Long Connection** mode.
|
||||||
4. Copy the App ID and App Secret. Set `FEISHU_APP_ID` and `FEISHU_APP_SECRET` in `.env` and enable the channel in `config.yaml`.
|
4. Copy the App ID and App Secret. Set `FEISHU_APP_ID` and `FEISHU_APP_SECRET` in `.env` and enable the channel in `config.yaml`.
|
||||||
|
|
||||||
|
**WeChat Setup**
|
||||||
|
|
||||||
|
1. Enable the `wechat` channel in `config.yaml`.
|
||||||
|
2. Either set `WECHAT_BOT_TOKEN` in `.env`, or set `qrcode_login_enabled: true` for first-time QR bootstrap.
|
||||||
|
3. When `bot_token` is absent and QR bootstrap is enabled, watch backend logs for the QR content returned by iLink and complete the binding flow.
|
||||||
|
4. After the QR flow succeeds, DeerFlow persists the acquired token under `state_dir` for later restarts.
|
||||||
|
5. For Docker Compose deployments, keep `state_dir` on a persistent volume so the `get_updates_buf` cursor and saved auth state survive restarts.
|
||||||
|
|
||||||
**WeCom Setup**
|
**WeCom Setup**
|
||||||
|
|
||||||
1. Create a bot on the WeCom AI Bot platform and obtain the `bot_id` and `bot_secret`.
|
1. Create a bot on the WeCom AI Bot platform and obtain the `bot_id` and `bot_secret`.
|
||||||
@@ -639,6 +658,8 @@ This is the difference between a chatbot with tool access and an agent with an a
|
|||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
**Strict Tool-Call Recovery**: When a provider or middleware interrupts a tool-call loop, DeerFlow now strips provider-level raw tool-call metadata on forced-stop assistant messages and injects placeholder tool results for dangling calls before the next model invocation. This keeps OpenAI-compatible reasoning models that strictly validate `tool_call_id` sequences from failing with malformed history errors.
|
||||||
|
|
||||||
### Long-Term Memory
|
### Long-Term Memory
|
||||||
|
|
||||||
Most agents forget everything the moment a conversation ends. DeerFlow remembers.
|
Most agents forget everything the moment a conversation ends. DeerFlow remembers.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
|||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [配置](#配置)
|
- [配置](#配置)
|
||||||
- [运行应用](#运行应用)
|
- [运行应用](#运行应用)
|
||||||
|
- [部署建议与资源规划](#部署建议与资源规划)
|
||||||
- [方式一:Docker(推荐)](#方式一docker推荐)
|
- [方式一:Docker(推荐)](#方式一docker推荐)
|
||||||
- [方式二:本地开发](#方式二本地开发)
|
- [方式二:本地开发](#方式二本地开发)
|
||||||
- [进阶配置](#进阶配置)
|
- [进阶配置](#进阶配置)
|
||||||
@@ -150,6 +151,20 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
|||||||
|
|
||||||
### 运行应用
|
### 运行应用
|
||||||
|
|
||||||
|
#### 部署建议与资源规划
|
||||||
|
|
||||||
|
可以先按下面的资源档位来选择 DeerFlow 的运行方式:
|
||||||
|
|
||||||
|
| 部署场景 | 起步配置 | 推荐配置 | 说明 |
|
||||||
|
|---------|-----------|------------|-------|
|
||||||
|
| 本地体验 / `make dev` | 4 vCPU、8 GB 内存、20 GB SSD 可用空间 | 8 vCPU、16 GB 内存 | 适合单个开发者或单个轻量会话,且模型走外部 API。`2 核 / 4 GB` 通常跑不稳。 |
|
||||||
|
| Docker 开发 / `make docker-start` | 4 vCPU、8 GB 内存、25 GB SSD 可用空间 | 8 vCPU、16 GB 内存 | 镜像构建、源码挂载和 sandbox 容器都会比纯本地模式更吃资源。 |
|
||||||
|
| 长期运行服务 / `make up` | 8 vCPU、16 GB 内存、40 GB SSD 可用空间 | 16 vCPU、32 GB 内存 | 更适合共享环境、多 agent 任务、报告生成或更重的 sandbox 负载。 |
|
||||||
|
|
||||||
|
- 上面的配置只覆盖 DeerFlow 本身;如果你还要本机部署本地大模型,请单独为模型服务预留资源。
|
||||||
|
- 持续运行的服务更推荐使用 Linux + Docker。macOS 和 Windows 更适合作为开发机或体验环境。
|
||||||
|
- 如果 CPU 或内存长期打满,先降低并发会话或重任务数量,再考虑升级到更高一档配置。
|
||||||
|
|
||||||
#### 方式一:Docker(推荐)
|
#### 方式一:Docker(推荐)
|
||||||
|
|
||||||
**开发模式**(支持热更新,挂载源码):
|
**开发模式**(支持热更新,挂载源码):
|
||||||
|
|||||||
+23
-15
@@ -156,20 +156,26 @@ from deerflow.config import get_app_config
|
|||||||
|
|
||||||
### Middleware Chain
|
### Middleware Chain
|
||||||
|
|
||||||
Middlewares execute in strict order in `packages/harness/deerflow/agents/lead_agent/agent.py`:
|
Lead-agent middlewares are assembled in strict append order across `packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py` (`build_lead_runtime_middlewares`) and `packages/harness/deerflow/agents/lead_agent/agent.py` (`_build_middlewares`):
|
||||||
|
|
||||||
1. **ThreadDataMiddleware** - Creates per-thread directories (`backend/.deer-flow/threads/{thread_id}/user-data/{workspace,uploads,outputs}`); Web UI thread deletion now follows LangGraph thread removal with Gateway cleanup of the local `.deer-flow/threads/{thread_id}` directory
|
1. **ThreadDataMiddleware** - Creates per-thread directories (`backend/.deer-flow/threads/{thread_id}/user-data/{workspace,uploads,outputs}`); Web UI thread deletion now follows LangGraph thread removal with Gateway cleanup of the local `.deer-flow/threads/{thread_id}` directory
|
||||||
2. **UploadsMiddleware** - Tracks and injects newly uploaded files into conversation
|
2. **UploadsMiddleware** - Tracks and injects newly uploaded files into conversation
|
||||||
3. **SandboxMiddleware** - Acquires sandbox, stores `sandbox_id` in state
|
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)
|
4. **DanglingToolCallMiddleware** - Injects placeholder ToolMessages for AIMessage tool_calls that lack responses (e.g., due to user interruption), including raw provider tool-call payloads preserved only in `additional_kwargs["tool_calls"]`
|
||||||
5. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
|
5. **LLMErrorHandlingMiddleware** - Normalizes provider/model invocation failures into recoverable assistant-facing errors before later middleware/tool stages run
|
||||||
6. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
|
||||||
7. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
|
||||||
8. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
||||||
9. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||||
10. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||||
11. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if subagent_enabled)
|
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
||||||
12. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
12. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
||||||
|
13. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
||||||
|
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
||||||
|
15. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
|
||||||
|
16. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
||||||
|
17. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
|
||||||
|
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||||
|
|
||||||
### Configuration System
|
### Configuration System
|
||||||
|
|
||||||
@@ -395,14 +401,16 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
|||||||
**Architecture**: Imports the same `deerflow` modules that LangGraph Server and Gateway API use. Shares the same config files and data directories. No FastAPI dependency.
|
**Architecture**: Imports the same `deerflow` modules that LangGraph Server and Gateway API use. Shares the same config files and data directories. No FastAPI dependency.
|
||||||
|
|
||||||
**Agent Conversation** (replaces LangGraph Server):
|
**Agent Conversation** (replaces LangGraph Server):
|
||||||
- `chat(message, thread_id)` — synchronous, returns final text
|
- `chat(message, thread_id)` — synchronous, accumulates streaming deltas per message-id and returns the final AI text
|
||||||
- `stream(message, thread_id)` — yields `StreamEvent` aligned with LangGraph SSE protocol:
|
- `stream(message, thread_id)` — subscribes to LangGraph `stream_mode=["values", "messages", "custom"]` and yields `StreamEvent`:
|
||||||
- `"values"` — full state snapshot (title, messages, artifacts)
|
- `"values"` — full state snapshot (title, messages, artifacts); AI text already delivered via `messages` mode is **not** re-synthesized here to avoid duplicate deliveries
|
||||||
- `"messages-tuple"` — per-message update (AI text, tool calls, tool results)
|
- `"messages-tuple"` — per-chunk update: for AI text this is a **delta** (concat per `id` to rebuild the full message); tool calls and tool results are emitted once each
|
||||||
- `"end"` — stream finished
|
- `"custom"` — forwarded from `StreamWriter`
|
||||||
|
- `"end"` — stream finished (carries cumulative `usage` counted once per message id)
|
||||||
- Agent created lazily via `create_agent()` + `_build_middlewares()`, same as `make_lead_agent`
|
- Agent created lazily via `create_agent()` + `_build_middlewares()`, same as `make_lead_agent`
|
||||||
- Supports `checkpointer` parameter for state persistence across turns
|
- Supports `checkpointer` parameter for state persistence across turns
|
||||||
- `reset_agent()` forces agent recreation (e.g. after memory or skill changes)
|
- `reset_agent()` forces agent recreation (e.g. after memory or skill changes)
|
||||||
|
- See [docs/STREAMING.md](docs/STREAMING.md) for the full design: why Gateway and DeerFlowClient are parallel paths, LangGraph's `stream_mode` semantics, the per-id dedup invariants, and regression testing strategy
|
||||||
|
|
||||||
**Gateway Equivalent Methods** (replaces Gateway API):
|
**Gateway Equivalent Methods** (replaces Gateway API):
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -84,4 +84,4 @@ COPY --from=builder /app/backend ./backend
|
|||||||
EXPOSE 8001 2024
|
EXPOSE 8001 2024
|
||||||
|
|
||||||
# Default command (can be overridden in docker-compose)
|
# Default command (can be overridden in docker-compose)
|
||||||
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
|
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Discord channel integration using discord.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.channels.base import Channel
|
||||||
|
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DISCORD_MAX_MESSAGE_LEN = 2000
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordChannel(Channel):
|
||||||
|
"""Discord bot channel.
|
||||||
|
|
||||||
|
Configuration keys (in ``config.yaml`` under ``channels.discord``):
|
||||||
|
- ``bot_token``: Discord Bot token.
|
||||||
|
- ``allowed_guilds``: (optional) List of allowed Discord guild IDs. Empty = allow all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None:
|
||||||
|
super().__init__(name="discord", bus=bus, config=config)
|
||||||
|
self._bot_token = str(config.get("bot_token", "")).strip()
|
||||||
|
self._allowed_guilds: set[int] = set()
|
||||||
|
for guild_id in config.get("allowed_guilds", []):
|
||||||
|
try:
|
||||||
|
self._allowed_guilds.add(int(guild_id))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._client = None
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._discord_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._main_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._discord_module = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import discord
|
||||||
|
except ImportError:
|
||||||
|
logger.error("discord.py is not installed. Install it with: uv add discord.py")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._bot_token:
|
||||||
|
logger.error("Discord channel requires bot_token")
|
||||||
|
return
|
||||||
|
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.messages = True
|
||||||
|
intents.guilds = True
|
||||||
|
intents.message_content = True
|
||||||
|
|
||||||
|
client = discord.Client(
|
||||||
|
intents=intents,
|
||||||
|
allowed_mentions=discord.AllowedMentions.none(),
|
||||||
|
)
|
||||||
|
self._client = client
|
||||||
|
self._discord_module = discord
|
||||||
|
self._main_loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
@client.event
|
||||||
|
async def on_message(message) -> None:
|
||||||
|
await self._on_message(message)
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self.bus.subscribe_outbound(self._on_outbound)
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=self._run_client, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Discord channel started")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
self.bus.unsubscribe_outbound(self._on_outbound)
|
||||||
|
|
||||||
|
if self._client and self._discord_loop and self._discord_loop.is_running():
|
||||||
|
close_future = asyncio.run_coroutine_threadsafe(self._client.close(), self._discord_loop)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.wrap_future(close_future), timeout=10)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning("[Discord] client close timed out after 10s")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[Discord] error while closing client")
|
||||||
|
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=10)
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
self._client = None
|
||||||
|
self._discord_loop = None
|
||||||
|
self._discord_module = None
|
||||||
|
logger.info("Discord channel stopped")
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
target = await self._resolve_target(msg)
|
||||||
|
if target is None:
|
||||||
|
logger.error("[Discord] target not found for chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = msg.text or ""
|
||||||
|
for chunk in self._split_text(text):
|
||||||
|
send_future = asyncio.run_coroutine_threadsafe(target.send(chunk), self._discord_loop)
|
||||||
|
await asyncio.wrap_future(send_future)
|
||||||
|
|
||||||
|
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
|
||||||
|
target = await self._resolve_target(msg)
|
||||||
|
if target is None:
|
||||||
|
logger.error("[Discord] target not found for file upload chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._discord_module is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
fp = open(str(attachment.actual_path), "rb") # noqa: SIM115
|
||||||
|
file = self._discord_module.File(fp, filename=attachment.filename)
|
||||||
|
send_future = asyncio.run_coroutine_threadsafe(target.send(file=file), self._discord_loop)
|
||||||
|
await asyncio.wrap_future(send_future)
|
||||||
|
logger.info("[Discord] file uploaded: %s", attachment.filename)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[Discord] failed to upload file: %s", attachment.filename)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _on_message(self, message) -> None:
|
||||||
|
if not self._running or not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
if message.author.bot:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._client.user and message.author.id == self._client.user.id:
|
||||||
|
return
|
||||||
|
|
||||||
|
guild = message.guild
|
||||||
|
if self._allowed_guilds:
|
||||||
|
if guild is None or guild.id not in self._allowed_guilds:
|
||||||
|
return
|
||||||
|
|
||||||
|
text = (message.content or "").strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._discord_module is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(message.channel, self._discord_module.Thread):
|
||||||
|
chat_id = str(message.channel.parent_id or message.channel.id)
|
||||||
|
thread_id = str(message.channel.id)
|
||||||
|
else:
|
||||||
|
thread = await self._create_thread(message)
|
||||||
|
if thread is None:
|
||||||
|
return
|
||||||
|
chat_id = str(message.channel.id)
|
||||||
|
thread_id = str(thread.id)
|
||||||
|
|
||||||
|
msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
|
||||||
|
inbound = self._make_inbound(
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=str(message.author.id),
|
||||||
|
text=text,
|
||||||
|
msg_type=msg_type,
|
||||||
|
thread_ts=thread_id,
|
||||||
|
metadata={
|
||||||
|
"guild_id": str(guild.id) if guild else None,
|
||||||
|
"channel_id": str(message.channel.id),
|
||||||
|
"message_id": str(message.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
inbound.topic_id = thread_id
|
||||||
|
|
||||||
|
if self._main_loop and self._main_loop.is_running():
|
||||||
|
future = asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._main_loop)
|
||||||
|
future.add_done_callback(lambda f: logger.exception("[Discord] publish_inbound failed", exc_info=f.exception()) if f.exception() else None)
|
||||||
|
|
||||||
|
def _run_client(self) -> None:
|
||||||
|
self._discord_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self._discord_loop)
|
||||||
|
try:
|
||||||
|
self._discord_loop.run_until_complete(self._client.start(self._bot_token))
|
||||||
|
except Exception:
|
||||||
|
if self._running:
|
||||||
|
logger.exception("Discord client error")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if self._client and not self._client.is_closed():
|
||||||
|
self._discord_loop.run_until_complete(self._client.close())
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error during Discord shutdown")
|
||||||
|
|
||||||
|
async def _create_thread(self, message):
|
||||||
|
try:
|
||||||
|
thread_name = f"deerflow-{message.author.display_name}-{message.id}"[:100]
|
||||||
|
return await message.create_thread(name=thread_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[Discord] failed to create thread for message=%s (threads may be disabled or missing permissions)", message.id)
|
||||||
|
try:
|
||||||
|
await message.channel.send("Could not create a thread for your message. Please check that threads are enabled in this channel.")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _resolve_target(self, msg: OutboundMessage):
|
||||||
|
if not self._client or not self._discord_loop:
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_ids: list[str] = []
|
||||||
|
if msg.thread_ts:
|
||||||
|
target_ids.append(msg.thread_ts)
|
||||||
|
if msg.chat_id and msg.chat_id not in target_ids:
|
||||||
|
target_ids.append(msg.chat_id)
|
||||||
|
|
||||||
|
for raw_id in target_ids:
|
||||||
|
target = await self._get_channel_or_thread(raw_id)
|
||||||
|
if target is not None:
|
||||||
|
return target
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _get_channel_or_thread(self, raw_id: str):
|
||||||
|
if not self._client or not self._discord_loop:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_id = int(raw_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
get_future = asyncio.run_coroutine_threadsafe(self._fetch_channel(target_id), self._discord_loop)
|
||||||
|
try:
|
||||||
|
return await asyncio.wrap_future(get_future)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[Discord] failed to resolve target id=%s", raw_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _fetch_channel(self, target_id: int):
|
||||||
|
if not self._client:
|
||||||
|
return None
|
||||||
|
|
||||||
|
channel = self._client.get_channel(target_id)
|
||||||
|
if channel is not None:
|
||||||
|
return channel
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._client.fetch_channel(target_id)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_text(text: str) -> list[str]:
|
||||||
|
if not text:
|
||||||
|
return [""]
|
||||||
|
|
||||||
|
chunks: list[str] = []
|
||||||
|
remaining = text
|
||||||
|
while len(remaining) > _DISCORD_MAX_MESSAGE_LEN:
|
||||||
|
split_at = remaining.rfind("\n", 0, _DISCORD_MAX_MESSAGE_LEN)
|
||||||
|
if split_at <= 0:
|
||||||
|
split_at = _DISCORD_MAX_MESSAGE_LEN
|
||||||
|
chunks.append(remaining[:split_at])
|
||||||
|
remaining = remaining[split_at:].lstrip("\n")
|
||||||
|
|
||||||
|
if remaining:
|
||||||
|
chunks.append(remaining)
|
||||||
|
|
||||||
|
return chunks
|
||||||
@@ -8,6 +8,7 @@ import mimetypes
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -34,9 +35,11 @@ STREAM_UPDATE_MIN_INTERVAL_SECONDS = 0.35
|
|||||||
THREAD_BUSY_MESSAGE = "This conversation is already processing another request. Please wait for it to finish and try again."
|
THREAD_BUSY_MESSAGE = "This conversation is already processing another request. Please wait for it to finish and try again."
|
||||||
|
|
||||||
CHANNEL_CAPABILITIES = {
|
CHANNEL_CAPABILITIES = {
|
||||||
|
"discord": {"supports_streaming": False},
|
||||||
"feishu": {"supports_streaming": True},
|
"feishu": {"supports_streaming": True},
|
||||||
"slack": {"supports_streaming": False},
|
"slack": {"supports_streaming": False},
|
||||||
"telegram": {"supports_streaming": False},
|
"telegram": {"supports_streaming": False},
|
||||||
|
"wechat": {"supports_streaming": False},
|
||||||
"wecom": {"supports_streaming": True},
|
"wecom": {"supports_streaming": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +81,24 @@ async def _read_wecom_inbound_file(file_info: dict[str, Any], client: httpx.Asyn
|
|||||||
return decrypt_file(data, aeskey)
|
return decrypt_file(data, aeskey)
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_wechat_inbound_file(file_info: dict[str, Any], client: httpx.AsyncClient) -> bytes | None:
|
||||||
|
raw_path = file_info.get("path")
|
||||||
|
if isinstance(raw_path, str) and raw_path.strip():
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(Path(raw_path).read_bytes)
|
||||||
|
except OSError:
|
||||||
|
logger.exception("[Manager] failed to read WeChat inbound file from local path: %s", raw_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
full_url = file_info.get("full_url")
|
||||||
|
if isinstance(full_url, str) and full_url.strip():
|
||||||
|
return await _read_http_inbound_file({"url": full_url}, client)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
register_inbound_file_reader("wecom", _read_wecom_inbound_file)
|
register_inbound_file_reader("wecom", _read_wecom_inbound_file)
|
||||||
|
register_inbound_file_reader("wechat", _read_wechat_inbound_file)
|
||||||
|
|
||||||
|
|
||||||
class InvalidChannelSessionConfigError(ValueError):
|
class InvalidChannelSessionConfigError(ValueError):
|
||||||
|
|||||||
@@ -15,12 +15,24 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Channel name → import path for lazy loading
|
# Channel name → import path for lazy loading
|
||||||
_CHANNEL_REGISTRY: dict[str, str] = {
|
_CHANNEL_REGISTRY: dict[str, str] = {
|
||||||
|
"discord": "app.channels.discord:DiscordChannel",
|
||||||
"feishu": "app.channels.feishu:FeishuChannel",
|
"feishu": "app.channels.feishu:FeishuChannel",
|
||||||
"slack": "app.channels.slack:SlackChannel",
|
"slack": "app.channels.slack:SlackChannel",
|
||||||
"telegram": "app.channels.telegram:TelegramChannel",
|
"telegram": "app.channels.telegram:TelegramChannel",
|
||||||
|
"wechat": "app.channels.wechat:WechatChannel",
|
||||||
"wecom": "app.channels.wecom:WeComChannel",
|
"wecom": "app.channels.wecom:WeComChannel",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Keys that indicate a user has configured credentials for a channel.
|
||||||
|
_CHANNEL_CREDENTIAL_KEYS: dict[str, list[str]] = {
|
||||||
|
"discord": ["bot_token"],
|
||||||
|
"feishu": ["app_id", "app_secret"],
|
||||||
|
"slack": ["bot_token", "app_token"],
|
||||||
|
"telegram": ["bot_token"],
|
||||||
|
"wecom": ["bot_id", "bot_secret"],
|
||||||
|
"wechat": ["bot_token"],
|
||||||
|
}
|
||||||
|
|
||||||
_CHANNELS_LANGGRAPH_URL_ENV = "DEER_FLOW_CHANNELS_LANGGRAPH_URL"
|
_CHANNELS_LANGGRAPH_URL_ENV = "DEER_FLOW_CHANNELS_LANGGRAPH_URL"
|
||||||
_CHANNELS_GATEWAY_URL_ENV = "DEER_FLOW_CHANNELS_GATEWAY_URL"
|
_CHANNELS_GATEWAY_URL_ENV = "DEER_FLOW_CHANNELS_GATEWAY_URL"
|
||||||
|
|
||||||
@@ -86,7 +98,16 @@ class ChannelService:
|
|||||||
if not isinstance(channel_config, dict):
|
if not isinstance(channel_config, dict):
|
||||||
continue
|
continue
|
||||||
if not channel_config.get("enabled", False):
|
if not channel_config.get("enabled", False):
|
||||||
logger.info("Channel %s is disabled, skipping", name)
|
cred_keys = _CHANNEL_CREDENTIAL_KEYS.get(name, [])
|
||||||
|
has_creds = any(not isinstance(channel_config.get(k), bool) and channel_config.get(k) is not None and str(channel_config[k]).strip() for k in cred_keys)
|
||||||
|
if has_creds:
|
||||||
|
logger.warning(
|
||||||
|
"Channel '%s' has credentials configured but is disabled. Set enabled: true under channels.%s in config.yaml to activate it.",
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Channel %s is disabled, skipping", name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await self._start_channel(name, channel_config)
|
await self._start_channel(name, channel_config)
|
||||||
|
|||||||
@@ -16,13 +16,31 @@ logger = logging.getLogger(__name__)
|
|||||||
_slack_md_converter = SlackMarkdownConverter()
|
_slack_md_converter = SlackMarkdownConverter()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_allowed_users(allowed_users: Any) -> set[str]:
|
||||||
|
if allowed_users is None:
|
||||||
|
return set()
|
||||||
|
if isinstance(allowed_users, str):
|
||||||
|
values = [allowed_users]
|
||||||
|
elif isinstance(allowed_users, list | tuple | set):
|
||||||
|
values = allowed_users
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Slack allowed_users should be a list of Slack user IDs or a single Slack user ID string; treating %s as one string value",
|
||||||
|
type(allowed_users).__name__,
|
||||||
|
)
|
||||||
|
values = [allowed_users]
|
||||||
|
return {str(user_id) for user_id in values if str(user_id)}
|
||||||
|
|
||||||
|
|
||||||
class SlackChannel(Channel):
|
class SlackChannel(Channel):
|
||||||
"""Slack IM channel using Socket Mode (WebSocket, no public IP).
|
"""Slack IM channel using Socket Mode (WebSocket, no public IP).
|
||||||
|
|
||||||
Configuration keys (in ``config.yaml`` under ``channels.slack``):
|
Configuration keys (in ``config.yaml`` under ``channels.slack``):
|
||||||
- ``bot_token``: Slack Bot User OAuth Token (xoxb-...).
|
- ``bot_token``: Slack Bot User OAuth Token (xoxb-...).
|
||||||
- ``app_token``: Slack App-Level Token (xapp-...) for Socket Mode.
|
- ``app_token``: Slack App-Level Token (xapp-...) for Socket Mode.
|
||||||
- ``allowed_users``: (optional) List of allowed Slack user IDs. Empty = allow all.
|
- ``allowed_users``: (optional) List of allowed Slack user IDs, or a
|
||||||
|
single Slack user ID string as shorthand. Empty = allow all. Other
|
||||||
|
scalar values are treated as a single string with a warning.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None:
|
def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None:
|
||||||
@@ -30,7 +48,7 @@ class SlackChannel(Channel):
|
|||||||
self._socket_client = None
|
self._socket_client = None
|
||||||
self._web_client = None
|
self._web_client = None
|
||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
self._allowed_users: set[str] = {str(user_id) for user_id in config.get("allowed_users", [])}
|
self._allowed_users = _normalize_allowed_users(config.get("allowed_users", []))
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if self._running:
|
if self._running:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -32,6 +33,11 @@ logging.basicConfig(
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Upper bound (seconds) each lifespan shutdown hook is allowed to run.
|
||||||
|
# Bounds worker exit time so uvicorn's reload supervisor does not keep
|
||||||
|
# firing signals into a worker that is stuck waiting for shutdown cleanup.
|
||||||
|
_SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
@@ -63,11 +69,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Stop channel service on shutdown
|
# Stop channel service on shutdown (bounded to prevent worker hang)
|
||||||
try:
|
try:
|
||||||
from app.channels.service import stop_channel_service
|
from app.channels.service import stop_channel_service
|
||||||
|
|
||||||
await stop_channel_service()
|
await asyncio.wait_for(
|
||||||
|
stop_channel_service(),
|
||||||
|
timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"Channel service shutdown exceeded %.1fs; proceeding with worker exit.",
|
||||||
|
_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to stop channel service")
|
logger.exception("Failed to stop channel service")
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import yaml
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from deerflow.config.agents_api_config import get_agents_api_config
|
||||||
from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul
|
from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ class AgentResponse(BaseModel):
|
|||||||
description: str = Field(default="", description="Agent description")
|
description: str = Field(default="", description="Agent description")
|
||||||
model: str | None = Field(default=None, description="Optional model override")
|
model: str | None = Field(default=None, description="Optional model override")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
||||||
|
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all, []=none)")
|
||||||
soul: str | None = Field(default=None, description="SOUL.md content")
|
soul: str | None = Field(default=None, description="SOUL.md content")
|
||||||
|
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ class AgentCreateRequest(BaseModel):
|
|||||||
description: str = Field(default="", description="Agent description")
|
description: str = Field(default="", description="Agent description")
|
||||||
model: str | None = Field(default=None, description="Optional model override")
|
model: str | None = Field(default=None, description="Optional model override")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
||||||
|
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all enabled, []=none)")
|
||||||
soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails")
|
soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails")
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ class AgentUpdateRequest(BaseModel):
|
|||||||
description: str | None = Field(default=None, description="Updated description")
|
description: str | None = Field(default=None, description="Updated description")
|
||||||
model: str | None = Field(default=None, description="Updated model override")
|
model: str | None = Field(default=None, description="Updated model override")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
|
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
|
||||||
|
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (None=all, []=none)")
|
||||||
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||||
|
|
||||||
|
|
||||||
@@ -73,6 +77,15 @@ def _normalize_agent_name(name: str) -> str:
|
|||||||
return name.lower()
|
return name.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_agents_api_enabled() -> None:
|
||||||
|
"""Reject access unless the custom-agent management API is explicitly enabled."""
|
||||||
|
if not get_agents_api_config().enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=("Custom-agent management API is disabled. Set agents_api.enabled=true to expose agent and user-profile routes over HTTP."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse:
|
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse:
|
||||||
"""Convert AgentConfig to AgentResponse."""
|
"""Convert AgentConfig to AgentResponse."""
|
||||||
soul: str | None = None
|
soul: str | None = None
|
||||||
@@ -84,6 +97,7 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False
|
|||||||
description=agent_cfg.description,
|
description=agent_cfg.description,
|
||||||
model=agent_cfg.model,
|
model=agent_cfg.model,
|
||||||
tool_groups=agent_cfg.tool_groups,
|
tool_groups=agent_cfg.tool_groups,
|
||||||
|
skills=agent_cfg.skills,
|
||||||
soul=soul,
|
soul=soul,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,6 +114,8 @@ async def list_agents() -> AgentsListResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
List of all custom agents with their metadata and soul content.
|
List of all custom agents with their metadata and soul content.
|
||||||
"""
|
"""
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agents = list_custom_agents()
|
agents = list_custom_agents()
|
||||||
return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True) for a in agents])
|
return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True) for a in agents])
|
||||||
@@ -125,6 +141,7 @@ async def check_agent_name(name: str) -> dict:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 422 if the name is invalid.
|
HTTPException: 422 if the name is invalid.
|
||||||
"""
|
"""
|
||||||
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
normalized = _normalize_agent_name(name)
|
normalized = _normalize_agent_name(name)
|
||||||
available = not get_paths().agent_dir(normalized).exists()
|
available = not get_paths().agent_dir(normalized).exists()
|
||||||
@@ -149,6 +166,7 @@ async def get_agent(name: str) -> AgentResponse:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if agent not found.
|
HTTPException: 404 if agent not found.
|
||||||
"""
|
"""
|
||||||
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
|
|
||||||
@@ -181,6 +199,7 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 409 if agent already exists, 422 if name is invalid.
|
HTTPException: 409 if agent already exists, 422 if name is invalid.
|
||||||
"""
|
"""
|
||||||
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(request.name)
|
_validate_agent_name(request.name)
|
||||||
normalized_name = _normalize_agent_name(request.name)
|
normalized_name = _normalize_agent_name(request.name)
|
||||||
|
|
||||||
@@ -200,6 +219,8 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
|||||||
config_data["model"] = request.model
|
config_data["model"] = request.model
|
||||||
if request.tool_groups is not None:
|
if request.tool_groups is not None:
|
||||||
config_data["tool_groups"] = request.tool_groups
|
config_data["tool_groups"] = request.tool_groups
|
||||||
|
if request.skills is not None:
|
||||||
|
config_data["skills"] = request.skills
|
||||||
|
|
||||||
config_file = agent_dir / "config.yaml"
|
config_file = agent_dir / "config.yaml"
|
||||||
with open(config_file, "w", encoding="utf-8") as f:
|
with open(config_file, "w", encoding="utf-8") as f:
|
||||||
@@ -243,6 +264,7 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if agent not found.
|
HTTPException: 404 if agent not found.
|
||||||
"""
|
"""
|
||||||
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
|
|
||||||
@@ -255,21 +277,32 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Update config if any config fields changed
|
# Update config if any config fields changed
|
||||||
config_changed = any(v is not None for v in [request.description, request.model, request.tool_groups])
|
# Use model_fields_set to distinguish "field omitted" from "explicitly set to null".
|
||||||
|
# This is critical for skills where None means "inherit all" (not "don't change").
|
||||||
|
fields_set = request.model_fields_set
|
||||||
|
config_changed = bool(fields_set & {"description", "model", "tool_groups", "skills"})
|
||||||
|
|
||||||
if config_changed:
|
if config_changed:
|
||||||
updated: dict = {
|
updated: dict = {
|
||||||
"name": agent_cfg.name,
|
"name": agent_cfg.name,
|
||||||
"description": request.description if request.description is not None else agent_cfg.description,
|
"description": request.description if "description" in fields_set else agent_cfg.description,
|
||||||
}
|
}
|
||||||
new_model = request.model if request.model is not None else agent_cfg.model
|
new_model = request.model if "model" in fields_set else agent_cfg.model
|
||||||
if new_model is not None:
|
if new_model is not None:
|
||||||
updated["model"] = new_model
|
updated["model"] = new_model
|
||||||
|
|
||||||
new_tool_groups = request.tool_groups if request.tool_groups is not None else agent_cfg.tool_groups
|
new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups
|
||||||
if new_tool_groups is not None:
|
if new_tool_groups is not None:
|
||||||
updated["tool_groups"] = new_tool_groups
|
updated["tool_groups"] = new_tool_groups
|
||||||
|
|
||||||
|
# skills: None = inherit all, [] = no skills, ["a","b"] = whitelist
|
||||||
|
if "skills" in fields_set:
|
||||||
|
new_skills = request.skills
|
||||||
|
else:
|
||||||
|
new_skills = agent_cfg.skills
|
||||||
|
if new_skills is not None:
|
||||||
|
updated["skills"] = new_skills
|
||||||
|
|
||||||
config_file = agent_dir / "config.yaml"
|
config_file = agent_dir / "config.yaml"
|
||||||
with open(config_file, "w", encoding="utf-8") as f:
|
with open(config_file, "w", encoding="utf-8") as f:
|
||||||
yaml.dump(updated, f, default_flow_style=False, allow_unicode=True)
|
yaml.dump(updated, f, default_flow_style=False, allow_unicode=True)
|
||||||
@@ -315,6 +348,8 @@ async def get_user_profile() -> UserProfileResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
UserProfileResponse with content=None if USER.md does not exist yet.
|
UserProfileResponse with content=None if USER.md does not exist yet.
|
||||||
"""
|
"""
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_md_path = get_paths().user_md_file
|
user_md_path = get_paths().user_md_file
|
||||||
if not user_md_path.exists():
|
if not user_md_path.exists():
|
||||||
@@ -341,6 +376,8 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR
|
|||||||
Returns:
|
Returns:
|
||||||
UserProfileResponse with the saved content.
|
UserProfileResponse with the saved content.
|
||||||
"""
|
"""
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
paths = get_paths()
|
paths = get_paths()
|
||||||
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -367,6 +404,7 @@ async def delete_agent(name: str) -> None:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if agent not found.
|
HTTPException: 404 if agent not found.
|
||||||
"""
|
"""
|
||||||
|
_require_agents_api_enabled()
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,17 @@ class ModelResponse(BaseModel):
|
|||||||
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
|
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsageResponse(BaseModel):
|
||||||
|
"""Token usage display configuration."""
|
||||||
|
|
||||||
|
enabled: bool = Field(default=False, description="Whether token usage display is enabled")
|
||||||
|
|
||||||
|
|
||||||
class ModelsListResponse(BaseModel):
|
class ModelsListResponse(BaseModel):
|
||||||
"""Response model for listing all models."""
|
"""Response model for listing all models."""
|
||||||
|
|
||||||
models: list[ModelResponse]
|
models: list[ModelResponse]
|
||||||
|
token_usage: TokenUsageResponse
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -36,7 +43,7 @@ async def list_models() -> ModelsListResponse:
|
|||||||
excluding sensitive fields like API keys and internal configuration.
|
excluding sensitive fields like API keys and internal configuration.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of all configured models with their metadata.
|
A list of all configured models with their metadata and token usage display settings.
|
||||||
|
|
||||||
Example Response:
|
Example Response:
|
||||||
```json
|
```json
|
||||||
@@ -44,17 +51,24 @@ async def list_models() -> ModelsListResponse:
|
|||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
"name": "gpt-4",
|
"name": "gpt-4",
|
||||||
|
"model": "gpt-4",
|
||||||
"display_name": "GPT-4",
|
"display_name": "GPT-4",
|
||||||
"description": "OpenAI GPT-4 model",
|
"description": "OpenAI GPT-4 model",
|
||||||
"supports_thinking": false
|
"supports_thinking": false,
|
||||||
|
"supports_reasoning_effort": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "claude-3-opus",
|
"name": "claude-3-opus",
|
||||||
|
"model": "claude-3-opus",
|
||||||
"display_name": "Claude 3 Opus",
|
"display_name": "Claude 3 Opus",
|
||||||
"description": "Anthropic Claude 3 Opus model",
|
"description": "Anthropic Claude 3 Opus model",
|
||||||
"supports_thinking": true
|
"supports_thinking": true,
|
||||||
|
"supports_reasoning_effort": false
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"token_usage": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
@@ -70,7 +84,10 @@ async def list_models() -> ModelsListResponse:
|
|||||||
)
|
)
|
||||||
for model in config.models
|
for model in config.models
|
||||||
]
|
]
|
||||||
return ModelsListResponse(models=models)
|
return ModelsListResponse(
|
||||||
|
models=models,
|
||||||
|
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import errno
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
@@ -7,7 +8,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||||
from deerflow.agents.lead_agent.prompt import clear_skills_system_prompt_cache
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||||
from deerflow.skills import Skill, load_skills
|
from deerflow.skills import Skill, load_skills
|
||||||
from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive
|
from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive
|
||||||
@@ -119,6 +120,7 @@ async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse:
|
|||||||
try:
|
try:
|
||||||
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
||||||
result = install_skill_from_archive(skill_file_path)
|
result = install_skill_from_archive(skill_file_path)
|
||||||
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return SkillInstallResponse(**result)
|
return SkillInstallResponse(**result)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
@@ -181,7 +183,7 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
|||||||
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
clear_skills_system_prompt_cache()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return await get_custom_skill(skill_name)
|
return await get_custom_skill(skill_name)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -200,20 +202,25 @@ async def delete_custom_skill(skill_name: str) -> dict[str, bool]:
|
|||||||
ensure_custom_skill_is_editable(skill_name)
|
ensure_custom_skill_is_editable(skill_name)
|
||||||
skill_dir = get_custom_skill_dir(skill_name)
|
skill_dir = get_custom_skill_dir(skill_name)
|
||||||
prev_content = read_custom_skill_content(skill_name)
|
prev_content = read_custom_skill_content(skill_name)
|
||||||
append_history(
|
try:
|
||||||
skill_name,
|
append_history(
|
||||||
{
|
skill_name,
|
||||||
"action": "human_delete",
|
{
|
||||||
"author": "human",
|
"action": "human_delete",
|
||||||
"thread_id": None,
|
"author": "human",
|
||||||
"file_path": "SKILL.md",
|
"thread_id": None,
|
||||||
"prev_content": prev_content,
|
"file_path": "SKILL.md",
|
||||||
"new_content": None,
|
"prev_content": prev_content,
|
||||||
"scanner": {"decision": "allow", "reason": "Deletion requested."},
|
"new_content": None,
|
||||||
},
|
"scanner": {"decision": "allow", "reason": "Deletion requested."},
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
if not isinstance(e, PermissionError) and e.errno not in {errno.EACCES, errno.EPERM, errno.EROFS}:
|
||||||
|
raise
|
||||||
|
logger.warning("Skipping delete history write for custom skill %s due to readonly/permission failure; continuing with skill directory removal: %s", skill_name, e)
|
||||||
shutil.rmtree(skill_dir)
|
shutil.rmtree(skill_dir)
|
||||||
clear_skills_system_prompt_cache()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
@@ -268,7 +275,7 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest)
|
|||||||
raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
|
raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
|
||||||
atomic_write(skill_file, target_content)
|
atomic_write(skill_file, target_content)
|
||||||
append_history(skill_name, history_entry)
|
append_history(skill_name, history_entry)
|
||||||
clear_skills_system_prompt_cache()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return await get_custom_skill(skill_name)
|
return await get_custom_skill(skill_name)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -337,6 +344,7 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillRes
|
|||||||
|
|
||||||
logger.info(f"Skills configuration updated and saved to: {config_path}")
|
logger.info(f"Skills configuration updated and saved to: {config_path}")
|
||||||
reload_extensions_config()
|
reload_extensions_config()
|
||||||
|
await refresh_skills_system_prompt_cache_async()
|
||||||
|
|
||||||
skills = load_skills(enabled_only=False)
|
skills = load_skills(enabled_only=False)
|
||||||
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ async def generate_suggestions(thread_id: str, request: SuggestionsRequest) -> S
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
model = create_chat_model(name=request.model_name, thinking_enabled=False)
|
model = create_chat_model(name=request.model_name, thinking_enabled=False)
|
||||||
response = await model.ainvoke([SystemMessage(content=system_instruction), HumanMessage(content=user_content)])
|
response = await model.ainvoke([SystemMessage(content=system_instruction), HumanMessage(content=user_content)], config={"run_name": "suggest_agent"})
|
||||||
raw = _extract_response_text(response.content)
|
raw = _extract_response_text(response.content)
|
||||||
suggestions = _parse_json_string_list(raw) or []
|
suggestions = _parse_json_string_list(raw) or []
|
||||||
cleaned = [s.replace("\n", " ").strip() for s in suggestions if s.strip()]
|
cleaned = [s.replace("\n", " ").strip() for s in suggestions if s.strip()]
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import stat
|
|||||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from deerflow.config.app_config import get_app_config
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
from deerflow.sandbox.sandbox_provider import SandboxProvider, get_sandbox_provider
|
||||||
from deerflow.uploads.manager import (
|
from deerflow.uploads.manager import (
|
||||||
PathTraversalError,
|
PathTraversalError,
|
||||||
delete_file_safe,
|
delete_file_safe,
|
||||||
@@ -53,6 +54,34 @@ def _make_file_sandbox_writable(file_path: os.PathLike[str] | str) -> None:
|
|||||||
os.chmod(file_path, writable_mode, **chmod_kwargs)
|
os.chmod(file_path, writable_mode, **chmod_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _uses_thread_data_mounts(sandbox_provider: SandboxProvider) -> bool:
|
||||||
|
return bool(getattr(sandbox_provider, "uses_thread_data_mounts", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_uploads_config_value(key: str, default: object) -> object:
|
||||||
|
"""Read a value from the uploads config, supporting dict and attribute access."""
|
||||||
|
cfg = get_app_config()
|
||||||
|
uploads_cfg = getattr(cfg, "uploads", None)
|
||||||
|
if isinstance(uploads_cfg, dict):
|
||||||
|
return uploads_cfg.get(key, default)
|
||||||
|
return getattr(uploads_cfg, key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_convert_documents_enabled() -> bool:
|
||||||
|
"""Return whether automatic host-side document conversion is enabled.
|
||||||
|
|
||||||
|
The secure default is disabled unless an operator explicitly opts in via
|
||||||
|
uploads.auto_convert_documents in config.yaml.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = _get_uploads_config_value("auto_convert_documents", False)
|
||||||
|
if isinstance(raw, str):
|
||||||
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
return bool(raw)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=UploadResponse)
|
@router.post("", response_model=UploadResponse)
|
||||||
async def upload_files(
|
async def upload_files(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
@@ -70,8 +99,12 @@ async def upload_files(
|
|||||||
uploaded_files = []
|
uploaded_files = []
|
||||||
|
|
||||||
sandbox_provider = get_sandbox_provider()
|
sandbox_provider = get_sandbox_provider()
|
||||||
sandbox_id = sandbox_provider.acquire(thread_id)
|
sync_to_sandbox = not _uses_thread_data_mounts(sandbox_provider)
|
||||||
sandbox = sandbox_provider.get(sandbox_id)
|
sandbox = None
|
||||||
|
if sync_to_sandbox:
|
||||||
|
sandbox_id = sandbox_provider.acquire(thread_id)
|
||||||
|
sandbox = sandbox_provider.get(sandbox_id)
|
||||||
|
auto_convert_documents = _auto_convert_documents_enabled()
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
@@ -90,7 +123,7 @@ async def upload_files(
|
|||||||
|
|
||||||
virtual_path = upload_virtual_path(safe_filename)
|
virtual_path = upload_virtual_path(safe_filename)
|
||||||
|
|
||||||
if sandbox_id != "local":
|
if sync_to_sandbox and sandbox is not None:
|
||||||
_make_file_sandbox_writable(file_path)
|
_make_file_sandbox_writable(file_path)
|
||||||
sandbox.update_file(virtual_path, content)
|
sandbox.update_file(virtual_path, content)
|
||||||
|
|
||||||
@@ -105,12 +138,12 @@ async def upload_files(
|
|||||||
logger.info(f"Saved file: {safe_filename} ({len(content)} bytes) to {file_info['path']}")
|
logger.info(f"Saved file: {safe_filename} ({len(content)} bytes) to {file_info['path']}")
|
||||||
|
|
||||||
file_ext = file_path.suffix.lower()
|
file_ext = file_path.suffix.lower()
|
||||||
if file_ext in CONVERTIBLE_EXTENSIONS:
|
if auto_convert_documents and file_ext in CONVERTIBLE_EXTENSIONS:
|
||||||
md_path = await convert_file_to_markdown(file_path)
|
md_path = await convert_file_to_markdown(file_path)
|
||||||
if md_path:
|
if md_path:
|
||||||
md_virtual_path = upload_virtual_path(md_path.name)
|
md_virtual_path = upload_virtual_path(md_path.name)
|
||||||
|
|
||||||
if sandbox_id != "local":
|
if sync_to_sandbox and sandbox is not None:
|
||||||
_make_file_sandbox_writable(md_path)
|
_make_file_sandbox_writable(md_path)
|
||||||
sandbox.update_file(md_virtual_path, md_path.read_bytes())
|
sandbox.update_file(md_virtual_path, md_path.read_bytes())
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
@@ -101,9 +102,10 @@ def resolve_agent_factory(assistant_id: str | None):
|
|||||||
"""Resolve the agent factory callable from config.
|
"""Resolve the agent factory callable from config.
|
||||||
|
|
||||||
Custom agents are implemented as ``lead_agent`` + an ``agent_name``
|
Custom agents are implemented as ``lead_agent`` + an ``agent_name``
|
||||||
injected into ``configurable`` — see :func:`build_run_config`. All
|
injected into ``configurable`` or ``context`` — see
|
||||||
``assistant_id`` values therefore map to the same factory; the routing
|
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
|
||||||
happens inside ``make_lead_agent`` when it reads ``cfg["agent_name"]``.
|
same factory; the routing happens inside ``make_lead_agent`` when it reads
|
||||||
|
``cfg["agent_name"]``.
|
||||||
"""
|
"""
|
||||||
from deerflow.agents.lead_agent.agent import make_lead_agent
|
from deerflow.agents.lead_agent.agent import make_lead_agent
|
||||||
|
|
||||||
@@ -120,10 +122,12 @@ def build_run_config(
|
|||||||
"""Build a RunnableConfig dict for the agent.
|
"""Build a RunnableConfig dict for the agent.
|
||||||
|
|
||||||
When *assistant_id* refers to a custom agent (anything other than
|
When *assistant_id* refers to a custom agent (anything other than
|
||||||
``"lead_agent"`` / ``None``), the name is forwarded as
|
``"lead_agent"`` / ``None``), the name is forwarded as ``agent_name`` in
|
||||||
``configurable["agent_name"]``. ``make_lead_agent`` reads this key to
|
whichever runtime options container is active: ``context`` for
|
||||||
load the matching ``agents/<name>/SOUL.md`` and per-agent config —
|
LangGraph >= 0.6.0 requests, otherwise ``configurable``.
|
||||||
without it the agent silently runs as the default lead agent.
|
``make_lead_agent`` reads this key to load the matching
|
||||||
|
``agents/<name>/SOUL.md`` and per-agent config — without it the agent
|
||||||
|
silently runs as the default lead agent.
|
||||||
|
|
||||||
This mirrors the channel manager's ``_resolve_run_params`` logic so that
|
This mirrors the channel manager's ``_resolve_run_params`` logic so that
|
||||||
the LangGraph Platform-compatible HTTP API and the IM channel path behave
|
the LangGraph Platform-compatible HTTP API and the IM channel path behave
|
||||||
@@ -142,7 +146,14 @@ def build_run_config(
|
|||||||
thread_id,
|
thread_id,
|
||||||
list(request_config.get("configurable", {}).keys()),
|
list(request_config.get("configurable", {}).keys()),
|
||||||
)
|
)
|
||||||
config["context"] = request_config["context"]
|
context_value = request_config["context"]
|
||||||
|
if context_value is None:
|
||||||
|
context = {}
|
||||||
|
elif isinstance(context_value, Mapping):
|
||||||
|
context = dict(context_value)
|
||||||
|
else:
|
||||||
|
raise ValueError("request config 'context' must be a mapping or null.")
|
||||||
|
config["context"] = context
|
||||||
else:
|
else:
|
||||||
configurable = {"thread_id": thread_id}
|
configurable = {"thread_id": thread_id}
|
||||||
configurable.update(request_config.get("configurable", {}))
|
configurable.update(request_config.get("configurable", {}))
|
||||||
@@ -154,13 +165,19 @@ def build_run_config(
|
|||||||
config["configurable"] = {"thread_id": thread_id}
|
config["configurable"] = {"thread_id": thread_id}
|
||||||
|
|
||||||
# Inject custom agent name when the caller specified a non-default assistant.
|
# Inject custom agent name when the caller specified a non-default assistant.
|
||||||
# Honour an explicit configurable["agent_name"] in the request if already set.
|
# Honour an explicit agent_name in the active runtime options container.
|
||||||
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID and "configurable" in config:
|
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
|
||||||
if "agent_name" not in config["configurable"]:
|
normalized = assistant_id.strip().lower().replace("_", "-")
|
||||||
normalized = assistant_id.strip().lower().replace("_", "-")
|
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
|
||||||
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
|
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.")
|
||||||
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.")
|
if "configurable" in config:
|
||||||
config["configurable"]["agent_name"] = normalized
|
target = config["configurable"]
|
||||||
|
elif "context" in config:
|
||||||
|
target = config["context"]
|
||||||
|
else:
|
||||||
|
target = config.setdefault("configurable", {})
|
||||||
|
if target is not None and "agent_name" not in target:
|
||||||
|
target["agent_name"] = normalized
|
||||||
if metadata:
|
if metadata:
|
||||||
config.setdefault("metadata", {}).update(metadata)
|
config.setdefault("metadata", {}).update(metadata)
|
||||||
return config
|
return config
|
||||||
@@ -298,6 +315,8 @@ async def start_run(
|
|||||||
"is_plan_mode",
|
"is_plan_mode",
|
||||||
"subagent_enabled",
|
"subagent_enabled",
|
||||||
"max_concurrent_subagents",
|
"max_concurrent_subagents",
|
||||||
|
"agent_name",
|
||||||
|
"is_bootstrap",
|
||||||
}
|
}
|
||||||
configurable = config.setdefault("configurable", {})
|
configurable = config.setdefault("configurable", {})
|
||||||
for key in _CONTEXT_CONFIGURABLE_KEYS:
|
for key in _CONTEXT_CONFIGURABLE_KEYS:
|
||||||
|
|||||||
+78
-13
@@ -19,24 +19,78 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
|
|
||||||
from deerflow.agents import make_lead_agent
|
try:
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
from prompt_toolkit.history import InMemoryHistory
|
||||||
|
|
||||||
|
_HAS_PROMPT_TOOLKIT = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_PROMPT_TOOLKIT = False
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
logging.basicConfig(
|
_LOG_FMT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
level=logging.INFO,
|
_LOG_DATEFMT = "%Y-%m-%d %H:%M:%S"
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
|
||||||
)
|
def _logging_level_from_config(name: str) -> int:
|
||||||
|
"""Map ``config.yaml`` ``log_level`` string to a ``logging`` level constant."""
|
||||||
|
mapping = logging.getLevelNamesMapping()
|
||||||
|
return mapping.get((name or "info").strip().upper(), logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging(log_level: str) -> None:
|
||||||
|
"""Send application logs to ``debug.log`` at *log_level*; do not print them on the console.
|
||||||
|
|
||||||
|
Idempotent: any pre-existing handlers on the root logger (e.g. installed by
|
||||||
|
``logging.basicConfig`` in transitively imported modules) are removed so the
|
||||||
|
debug session output only lands in ``debug.log``.
|
||||||
|
"""
|
||||||
|
level = _logging_level_from_config(log_level)
|
||||||
|
root = logging.root
|
||||||
|
for h in list(root.handlers):
|
||||||
|
root.removeHandler(h)
|
||||||
|
h.close()
|
||||||
|
root.setLevel(level)
|
||||||
|
|
||||||
|
file_handler = logging.FileHandler("debug.log", mode="a", encoding="utf-8")
|
||||||
|
file_handler.setLevel(level)
|
||||||
|
file_handler.setFormatter(logging.Formatter(_LOG_FMT, datefmt=_LOG_DATEFMT))
|
||||||
|
root.addHandler(file_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_logging_level(log_level: str) -> None:
|
||||||
|
"""Update the root logger and existing handlers to *log_level*."""
|
||||||
|
level = _logging_level_from_config(log_level)
|
||||||
|
root = logging.root
|
||||||
|
root.setLevel(level)
|
||||||
|
for handler in root.handlers:
|
||||||
|
handler.setLevel(level)
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
# Install file logging first so warnings emitted while loading config do not
|
||||||
|
# leak onto the interactive terminal via Python's lastResort handler.
|
||||||
|
_setup_logging("info")
|
||||||
|
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
|
||||||
|
app_config = get_app_config()
|
||||||
|
_update_logging_level(app_config.log_level)
|
||||||
|
|
||||||
|
# Delay the rest of the deerflow imports until *after* logging is installed
|
||||||
|
# so that any import-time side effects (e.g. deerflow.agents starts a
|
||||||
|
# background skill-loader thread on import) emit logs to debug.log instead
|
||||||
|
# of leaking onto the interactive terminal via Python's lastResort handler.
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
|
from deerflow.agents import make_lead_agent
|
||||||
|
from deerflow.mcp import initialize_mcp_tools
|
||||||
|
|
||||||
# Initialize MCP tools at startup
|
# Initialize MCP tools at startup
|
||||||
try:
|
try:
|
||||||
from deerflow.mcp import initialize_mcp_tools
|
|
||||||
|
|
||||||
await initialize_mcp_tools()
|
await initialize_mcp_tools()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Failed to initialize MCP tools: {e}")
|
print(f"Warning: Failed to initialize MCP tools: {e}")
|
||||||
@@ -52,16 +106,27 @@ async def main():
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime = Runtime(context={"thread_id": config["configurable"]["thread_id"]})
|
||||||
|
config["configurable"]["__pregel_runtime"] = runtime
|
||||||
|
|
||||||
agent = make_lead_agent(config)
|
agent = make_lead_agent(config)
|
||||||
|
|
||||||
|
session = PromptSession(history=InMemoryHistory()) if _HAS_PROMPT_TOOLKIT else None
|
||||||
|
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print("Lead Agent Debug Mode")
|
print("Lead Agent Debug Mode")
|
||||||
print("Type 'quit' or 'exit' to stop")
|
print("Type 'quit' or 'exit' to stop")
|
||||||
|
print(f"Logs: debug.log (log_level={app_config.log_level})")
|
||||||
|
if not _HAS_PROMPT_TOOLKIT:
|
||||||
|
print("Tip: `uv sync --group dev` to enable arrow-key & history support")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
user_input = input("\nYou: ").strip()
|
if session:
|
||||||
|
user_input = (await session.prompt_async("\nYou: ")).strip()
|
||||||
|
else:
|
||||||
|
user_input = input("\nYou: ").strip()
|
||||||
if not user_input:
|
if not user_input:
|
||||||
continue
|
continue
|
||||||
if user_input.lower() in ("quit", "exit"):
|
if user_input.lower() in ("quit", "exit"):
|
||||||
@@ -70,15 +135,15 @@ async def main():
|
|||||||
|
|
||||||
# Invoke the agent
|
# Invoke the agent
|
||||||
state = {"messages": [HumanMessage(content=user_input)]}
|
state = {"messages": [HumanMessage(content=user_input)]}
|
||||||
result = await agent.ainvoke(state, config=config, context={"thread_id": "debug-thread-001"})
|
result = await agent.ainvoke(state, config=config)
|
||||||
|
|
||||||
# Print the response
|
# Print the response
|
||||||
if result.get("messages"):
|
if result.get("messages"):
|
||||||
last_message = result["messages"][-1]
|
last_message = result["messages"][-1]
|
||||||
print(f"\nAgent: {last_message.content}")
|
print(f"\nAgent: {last_message.content}")
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except (KeyboardInterrupt, EOFError):
|
||||||
print("\nInterrupted. Goodbye!")
|
print("\nGoodbye!")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\nError: {e}")
|
print(f"\nError: {e}")
|
||||||
|
|||||||
+25
-1
@@ -86,6 +86,7 @@ Content-Type: application/json
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
"recursion_limit": 100,
|
||||||
"configurable": {
|
"configurable": {
|
||||||
"model_name": "gpt-4",
|
"model_name": "gpt-4",
|
||||||
"thinking_enabled": false,
|
"thinking_enabled": false,
|
||||||
@@ -100,6 +101,21 @@ Content-Type: application/json
|
|||||||
- Use: `values`, `messages-tuple`, `custom`, `updates`, `events`, `debug`, `tasks`, `checkpoints`
|
- Use: `values`, `messages-tuple`, `custom`, `updates`, `events`, `debug`, `tasks`, `checkpoints`
|
||||||
- Do not use: `tools` (deprecated/invalid in current `langgraph-api` and will trigger schema validation errors)
|
- Do not use: `tools` (deprecated/invalid in current `langgraph-api` and will trigger schema validation errors)
|
||||||
|
|
||||||
|
**Recursion Limit:**
|
||||||
|
|
||||||
|
`config.recursion_limit` caps the number of graph steps LangGraph will execute
|
||||||
|
in a single run. The `/api/langgraph/*` endpoints go straight to the LangGraph
|
||||||
|
server and therefore inherit LangGraph's native default of **25**, which is
|
||||||
|
too low for plan-mode or subagent-heavy runs — the agent typically errors out
|
||||||
|
with `GraphRecursionError` after the first round of subagent results comes
|
||||||
|
back, before the lead agent can synthesize the final answer.
|
||||||
|
|
||||||
|
DeerFlow's own Gateway and IM-channel paths mitigate this by defaulting to
|
||||||
|
`100` in `build_run_config` (see `backend/app/gateway/services.py`), but
|
||||||
|
clients calling the LangGraph API directly must set `recursion_limit`
|
||||||
|
explicitly in the request body. `100` matches the Gateway default and is a
|
||||||
|
safe starting point; increase it if you run deeply nested subagent graphs.
|
||||||
|
|
||||||
**Configurable Options:**
|
**Configurable Options:**
|
||||||
- `model_name` (string): Override the default model
|
- `model_name` (string): Override the default model
|
||||||
- `thinking_enabled` (boolean): Enable extended thinking for supported models
|
- `thinking_enabled` (boolean): Enable extended thinking for supported models
|
||||||
@@ -626,6 +642,14 @@ curl -X POST http://localhost:2026/api/langgraph/threads/abc123/runs \
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"input": {"messages": [{"role": "user", "content": "Hello"}]},
|
"input": {"messages": [{"role": "user", "content": "Hello"}]},
|
||||||
"config": {"configurable": {"model_name": "gpt-4"}}
|
"config": {
|
||||||
|
"recursion_limit": 100,
|
||||||
|
"configurable": {"model_name": "gpt-4"}
|
||||||
|
}
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> The `/api/langgraph/*` endpoints bypass DeerFlow's Gateway and inherit
|
||||||
|
> LangGraph's native `recursion_limit` default of 25, which is too low for
|
||||||
|
> plan-mode or subagent runs. Set `config.recursion_limit` explicitly — see
|
||||||
|
> the [Create Run](#create-run) section for details.
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class ThreadState(AgentState):
|
|||||||
│ Built-in Tools │ │ Configured Tools │ │ MCP Tools │
|
│ Built-in Tools │ │ Configured Tools │ │ MCP Tools │
|
||||||
│ (packages/harness/deerflow/tools/) │ │ (config.yaml) │ │ (extensions.json) │
|
│ (packages/harness/deerflow/tools/) │ │ (config.yaml) │ │ (extensions.json) │
|
||||||
├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤
|
├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤
|
||||||
│ - present_file │ │ - web_search │ │ - github │
|
│ - present_files │ │ - web_search │ │ - github │
|
||||||
│ - ask_clarification │ │ - web_fetch │ │ - filesystem │
|
│ - ask_clarification │ │ - web_fetch │ │ - filesystem │
|
||||||
│ - view_image │ │ - bash │ │ - postgres │
|
│ - view_image │ │ - bash │ │ - postgres │
|
||||||
│ │ │ - read_file │ │ - brave-search │
|
│ │ │ - read_file │ │ - brave-search │
|
||||||
|
|||||||
@@ -192,8 +192,8 @@ tools:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Built-in Tools**:
|
**Built-in Tools**:
|
||||||
- `web_search` - Search the web (Tavily)
|
- `web_search` - Search the web (DuckDuckGo, Tavily, Exa, InfoQuest, Firecrawl)
|
||||||
- `web_fetch` - Fetch web pages (Jina AI)
|
- `web_fetch` - Fetch web pages (Jina AI, Exa, InfoQuest, Firecrawl)
|
||||||
- `ls` - List directory contents
|
- `ls` - List directory contents
|
||||||
- `read_file` - Read file contents
|
- `read_file` - Read file contents
|
||||||
- `write_file` - Write file contents
|
- `write_file` - Write file contents
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
DeerFlow 后端提供了完整的文件上传功能,支持多文件上传,并自动将 Office 文档和 PDF 转换为 Markdown 格式。
|
DeerFlow 后端提供了完整的文件上传功能,支持多文件上传,并可选地将 Office 文档和 PDF 转换为 Markdown 格式。
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- ✅ 支持多文件同时上传
|
- ✅ 支持多文件同时上传
|
||||||
- ✅ 自动转换文档为 Markdown(PDF、PPT、Excel、Word)
|
- ✅ 可选地转换文档为 Markdown(PDF、PPT、Excel、Word)
|
||||||
- ✅ 文件存储在线程隔离的目录中
|
- ✅ 文件存储在线程隔离的目录中
|
||||||
- ✅ Agent 自动感知已上传的文件
|
- ✅ Agent 自动感知已上传的文件
|
||||||
- ✅ 支持文件列表查询和删除
|
- ✅ 支持文件列表查询和删除
|
||||||
@@ -86,7 +86,7 @@ DELETE /api/threads/{thread_id}/uploads/{filename}
|
|||||||
|
|
||||||
## 支持的文档格式
|
## 支持的文档格式
|
||||||
|
|
||||||
以下格式会自动转换为 Markdown:
|
以下格式在显式启用 `uploads.auto_convert_documents: true` 时会自动转换为 Markdown:
|
||||||
- PDF (`.pdf`)
|
- PDF (`.pdf`)
|
||||||
- PowerPoint (`.ppt`, `.pptx`)
|
- PowerPoint (`.ppt`, `.pptx`)
|
||||||
- Excel (`.xls`, `.xlsx`)
|
- Excel (`.xls`, `.xlsx`)
|
||||||
@@ -94,6 +94,8 @@ DELETE /api/threads/{thread_id}/uploads/{filename}
|
|||||||
|
|
||||||
转换后的 Markdown 文件会保存在同一目录下,文件名为原文件名 + `.md` 扩展名。
|
转换后的 Markdown 文件会保存在同一目录下,文件名为原文件名 + `.md` 扩展名。
|
||||||
|
|
||||||
|
默认情况下,自动转换是关闭的,以避免在网关主机上对不受信任的 Office/PDF 上传执行解析。只有在受信任部署中明确接受此风险时,才应将 `uploads.auto_convert_documents` 设置为 `true`。
|
||||||
|
|
||||||
## Agent 集成
|
## Agent 集成
|
||||||
|
|
||||||
### 自动文件列举
|
### 自动文件列举
|
||||||
@@ -207,6 +209,7 @@ backend/.deer-flow/threads/
|
|||||||
- 最大文件大小:100MB(可在 nginx.conf 中配置 `client_max_body_size`)
|
- 最大文件大小:100MB(可在 nginx.conf 中配置 `client_max_body_size`)
|
||||||
- 文件名安全性:系统会自动验证文件路径,防止目录遍历攻击
|
- 文件名安全性:系统会自动验证文件路径,防止目录遍历攻击
|
||||||
- 线程隔离:每个线程的上传文件相互隔离,无法跨线程访问
|
- 线程隔离:每个线程的上传文件相互隔离,无法跨线程访问
|
||||||
|
- 自动文档转换默认关闭;如需启用,需在 `config.yaml` 中显式设置 `uploads.auto_convert_documents: true`
|
||||||
|
|
||||||
## 技术实现
|
## 技术实现
|
||||||
|
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ These are the tool names your provider will see in `request.tool_name`:
|
|||||||
| `web_search` | Web search query |
|
| `web_search` | Web search query |
|
||||||
| `web_fetch` | Fetch URL content |
|
| `web_fetch` | Fetch URL content |
|
||||||
| `image_search` | Image search |
|
| `image_search` | Image search |
|
||||||
| `present_file` | Present file to user |
|
| `present_files` | Present file to user |
|
||||||
| `view_image` | Display image |
|
| `view_image` | Display image |
|
||||||
| `ask_clarification` | Ask user a question |
|
| `ask_clarification` | Ask user a question |
|
||||||
| `task` | Delegate to subagent |
|
| `task` | Delegate to subagent |
|
||||||
|
|||||||
@@ -45,6 +45,41 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Custom Tool Interceptors
|
||||||
|
|
||||||
|
You can register custom interceptors that run before every MCP tool call. This is useful for injecting per-request headers (e.g., user auth tokens from the LangGraph execution context), logging, or metrics.
|
||||||
|
|
||||||
|
Declare interceptors in `extensions_config.json` using the `mcpInterceptors` field:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpInterceptors": [
|
||||||
|
"my_package.mcp.auth:build_auth_interceptor"
|
||||||
|
],
|
||||||
|
"mcpServers": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each entry is a Python import path in `module:variable` format (resolved via `resolve_variable`). The variable must be a **no-arg builder function** that returns an async interceptor compatible with `MultiServerMCPClient`’s `tool_interceptors` interface, or `None` to skip.
|
||||||
|
|
||||||
|
Example interceptor that injects auth headers from LangGraph metadata:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_auth_interceptor():
|
||||||
|
async def interceptor(request, handler):
|
||||||
|
from langgraph.config import get_config
|
||||||
|
metadata = get_config().get("metadata", {})
|
||||||
|
headers = dict(request.headers or {})
|
||||||
|
if token := metadata.get("auth_token"):
|
||||||
|
headers["X-Auth-Token"] = token
|
||||||
|
return await handler(request.override(headers=headers))
|
||||||
|
return interceptor
|
||||||
|
```
|
||||||
|
|
||||||
|
- A single string value is accepted and normalized to a one-element list.
|
||||||
|
- Invalid paths or builder failures are logged as warnings without blocking other interceptors.
|
||||||
|
- The builder return value must be `callable`; non-callable values are skipped with a warning.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
MCP servers expose tools that are automatically discovered and integrated into DeerFlow’s agent system at runtime. Once enabled, these tools become available to agents without additional code changes.
|
MCP servers expose tools that are automatically discovered and integrated into DeerFlow’s agent system at runtime. Once enabled, these tools become available to agents without additional code changes.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ This directory contains detailed documentation for the DeerFlow backend.
|
|||||||
|
|
||||||
| Document | Description |
|
| Document | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
|
| [STREAMING.md](STREAMING.md) | Token-level streaming design: Gateway vs DeerFlowClient paths, `stream_mode` semantics, per-id dedup |
|
||||||
| [FILE_UPLOAD.md](FILE_UPLOAD.md) | File upload functionality |
|
| [FILE_UPLOAD.md](FILE_UPLOAD.md) | File upload functionality |
|
||||||
| [PATH_EXAMPLES.md](PATH_EXAMPLES.md) | Path types and usage examples |
|
| [PATH_EXAMPLES.md](PATH_EXAMPLES.md) | Path types and usage examples |
|
||||||
| [summarization.md](summarization.md) | Context summarization feature |
|
| [summarization.md](summarization.md) | Context summarization feature |
|
||||||
@@ -47,6 +48,7 @@ docs/
|
|||||||
├── PATH_EXAMPLES.md # Path usage examples
|
├── PATH_EXAMPLES.md # Path usage examples
|
||||||
├── summarization.md # Summarization feature
|
├── summarization.md # Summarization feature
|
||||||
├── plan_mode_usage.md # Plan mode feature
|
├── plan_mode_usage.md # Plan mode feature
|
||||||
|
├── STREAMING.md # Token-level streaming design
|
||||||
├── AUTO_TITLE_GENERATION.md # Title generation
|
├── AUTO_TITLE_GENERATION.md # Title generation
|
||||||
├── TITLE_GENERATION_IMPLEMENTATION.md # Title implementation details
|
├── TITLE_GENERATION_IMPLEMENTATION.md # Title implementation details
|
||||||
└── TODO.md # Roadmap and issues
|
└── TODO.md # Roadmap and issues
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# DeerFlow 流式输出设计
|
||||||
|
|
||||||
|
本文档解释 DeerFlow 是如何把 LangGraph agent 的事件流端到端送到两类消费者(HTTP 客户端、嵌入式 Python 调用方)的:两条路径为什么**必须**并存、它们各自的契约是什么、以及设计里那些 non-obvious 的不变式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
- DeerFlow 有**两条并行**的流式路径:**Gateway 路径**(async / HTTP SSE / JSON 序列化)服务浏览器和 IM 渠道;**DeerFlowClient 路径**(sync / in-process / 原生 LangChain 对象)服务 Jupyter、脚本、测试。它们**无法合并**——消费者模型不同。
|
||||||
|
- 两条路径都从 `create_agent()` 工厂出发,核心都是订阅 LangGraph 的 `stream_mode=["values", "messages", "custom"]`。`values` 是节点级 state 快照,`messages` 是 LLM token 级 delta,`custom` 是显式 `StreamWriter` 事件。**这三种模式不是详细程度的梯度,是三个独立的事件源**,要 token 流就必须显式订阅 `messages`。
|
||||||
|
- 嵌入式 client 为每个 `stream()` 调用维护三个 `set[str]`:`seen_ids` / `streamed_ids` / `counted_usage_ids`。三者看起来相似但管理**三个独立的不变式**,不能合并。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么有两条流式路径
|
||||||
|
|
||||||
|
两条路径服务的消费者模型根本不同:
|
||||||
|
|
||||||
|
| 维度 | Gateway 路径 | DeerFlowClient 路径 |
|
||||||
|
|---|---|---|
|
||||||
|
| 入口 | FastAPI `/runs/stream` endpoint | `DeerFlowClient.stream(message)` |
|
||||||
|
| 触发层 | `runtime/runs/worker.py::run_agent` | `packages/harness/deerflow/client.py::DeerFlowClient.stream` |
|
||||||
|
| 执行模型 | `async def` + `agent.astream()` | sync generator + `agent.stream()` |
|
||||||
|
| 事件传输 | `StreamBridge`(asyncio Queue)+ `sse_consumer` | 直接 `yield` |
|
||||||
|
| 序列化 | `serialize(chunk)` → 纯 JSON dict,匹配 LangGraph Platform wire 格式 | `StreamEvent.data`,携带原生 LangChain 对象 |
|
||||||
|
| 消费者 | 前端 `useStream` React hook、飞书/Slack/Telegram channel、LangGraph SDK 客户端 | Jupyter notebook、集成测试、内部 Python 脚本 |
|
||||||
|
| 生命周期管理 | `RunManager`:run_id 跟踪、disconnect 语义、multitask 策略、heartbeat | 无;函数返回即结束 |
|
||||||
|
| 断连恢复 | `Last-Event-ID` SSE 重连 | 无需要 |
|
||||||
|
|
||||||
|
**两条路径的存在是 DRY 的刻意妥协**:Gateway 的全部基础设施(async + Queue + JSON + RunManager)**都是为了跨网络边界把事件送给 HTTP 消费者**。当生产者(agent)和消费者(Python 调用栈)在同一个进程时,这整套东西都是纯开销。
|
||||||
|
|
||||||
|
### 为什么不能让 DeerFlowClient 复用 Gateway
|
||||||
|
|
||||||
|
曾经考虑过三种复用方案,都被否决:
|
||||||
|
|
||||||
|
1. **让 `client.stream()` 变成 `async def client.astream()`**
|
||||||
|
breaking change。用户用不上的 `async for` / `asyncio.run()` 要硬塞进 Jupyter notebook 和同步脚本。DeerFlowClient 的一大卖点("把 agent 当普通函数调用")直接消失。
|
||||||
|
|
||||||
|
2. **在 `client.stream()` 内部起一个独立事件循环线程,用 `StreamBridge` 在 sync/async 之间做桥接**
|
||||||
|
引入线程池、队列、信号量。为了"消除重复",把**复杂度**代替代码行数引进来。是典型的"wrong abstraction"——开销高于复用收益。
|
||||||
|
|
||||||
|
3. **让 `run_agent` 自己兼容 sync mode**
|
||||||
|
给 Gateway 加一条用不到的死分支,污染 worker.py 的焦点。
|
||||||
|
|
||||||
|
所以两条路径的事件处理逻辑会**相似但不共享**。这是刻意设计,不是疏忽。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LangGraph `stream_mode` 三层语义
|
||||||
|
|
||||||
|
LangGraph 的 `agent.stream(stream_mode=[...])` 是**多路复用**接口:一次订阅多个 mode,每个 mode 是一个独立的事件源。三种核心 mode:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
classDef values fill:#B8C5D1,stroke:#5A6B7A,color:#2C3E50
|
||||||
|
classDef messages fill:#C9B8A8,stroke:#7A6B5A,color:#2C3E50
|
||||||
|
classDef custom fill:#B5C4B1,stroke:#5A7A5A,color:#2C3E50
|
||||||
|
|
||||||
|
subgraph LG["LangGraph agent graph"]
|
||||||
|
direction TB
|
||||||
|
Node1["node: LLM call"]
|
||||||
|
Node2["node: tool call"]
|
||||||
|
Node3["node: reducer"]
|
||||||
|
end
|
||||||
|
|
||||||
|
LG -->|"每个节点完成后"| V["values: 完整 state 快照"]
|
||||||
|
Node1 -->|"LLM 每产生一个 token"| M["messages: (AIMessageChunk, meta)"]
|
||||||
|
Node1 -->|"StreamWriter.write()"| C["custom: 任意 dict"]
|
||||||
|
|
||||||
|
class V values
|
||||||
|
class M messages
|
||||||
|
class C custom
|
||||||
|
```
|
||||||
|
|
||||||
|
| Mode | 发射时机 | Payload | 粒度 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `values` | 每个 graph 节点完成后 | 完整 state dict(title、messages、artifacts)| 节点级 |
|
||||||
|
| `messages` | LLM 每次 yield 一个 chunk;tool 节点完成时 | `(AIMessageChunk \| ToolMessage, metadata_dict)` | token 级 |
|
||||||
|
| `custom` | 用户代码显式调用 `StreamWriter.write()` | 任意 dict | 应用定义 |
|
||||||
|
|
||||||
|
### 两套命名的由来
|
||||||
|
|
||||||
|
同一件事在**三个协议层**有三个名字:
|
||||||
|
|
||||||
|
```
|
||||||
|
Application HTTP / SSE LangGraph Graph
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ frontend │ │ LangGraph │ │ agent.astream│
|
||||||
|
│ useStream │──"messages- │ Platform SDK │──"messages"──│ graph.astream│
|
||||||
|
│ Feishu IM │ tuple"──────│ HTTP wire │ │ │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Graph 层**(`agent.stream` / `agent.astream`):LangGraph Python 直接 API,mode 叫 **`"messages"`**。
|
||||||
|
- **Platform SDK 层**(`langgraph-sdk` HTTP client):跨进程 HTTP 契约,mode 叫 **`"messages-tuple"`**。
|
||||||
|
- **Gateway worker** 显式做翻译:`if m == "messages-tuple": lg_modes.append("messages")`(`runtime/runs/worker.py:117-121`)。
|
||||||
|
|
||||||
|
**后果**:`DeerFlowClient.stream()` 直接调 `agent.stream()`(Graph 层),所以必须传 `"messages"`。`app/channels/manager.py` 通过 `langgraph-sdk` 走 HTTP SDK,所以传 `"messages-tuple"`。**这两个字符串不能互相替代**,也不能抽成"一个共享常量"——它们是不同协议层的 type alias,共享只会让某一层说不是它母语的话。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gateway 路径:async + HTTP SSE
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client as HTTP Client
|
||||||
|
participant API as FastAPI<br/>thread_runs.py
|
||||||
|
participant Svc as services.py<br/>start_run
|
||||||
|
participant Worker as worker.py<br/>run_agent (async)
|
||||||
|
participant Bridge as StreamBridge<br/>(asyncio.Queue)
|
||||||
|
participant Agent as LangGraph<br/>agent.astream
|
||||||
|
participant SSE as sse_consumer
|
||||||
|
|
||||||
|
Client->>API: POST /runs/stream
|
||||||
|
API->>Svc: start_run(body)
|
||||||
|
Svc->>Bridge: create bridge
|
||||||
|
Svc->>Worker: asyncio.create_task(run_agent(...))
|
||||||
|
Svc-->>API: StreamingResponse(sse_consumer)
|
||||||
|
API-->>Client: event-stream opens
|
||||||
|
|
||||||
|
par worker (producer)
|
||||||
|
Worker->>Agent: astream(stream_mode=lg_modes)
|
||||||
|
loop 每个 chunk
|
||||||
|
Agent-->>Worker: (mode, chunk)
|
||||||
|
Worker->>Bridge: publish(run_id, event, serialize(chunk))
|
||||||
|
end
|
||||||
|
Worker->>Bridge: publish_end(run_id)
|
||||||
|
and sse_consumer (consumer)
|
||||||
|
SSE->>Bridge: subscribe(run_id)
|
||||||
|
loop 每个 event
|
||||||
|
Bridge-->>SSE: StreamEvent
|
||||||
|
SSE-->>Client: "event: <name>\ndata: <json>\n\n"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
关键组件:
|
||||||
|
|
||||||
|
- `runtime/runs/worker.py::run_agent` — 在 `asyncio.Task` 里跑 `agent.astream()`,把每个 chunk 通过 `serialize(chunk, mode=mode)` 转成 JSON,再 `bridge.publish()`。
|
||||||
|
- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。
|
||||||
|
- `app/gateway/services.py::sse_consumer` — 从 bridge 订阅,格式化为 SSE wire 帧。
|
||||||
|
- `runtime/serialization.py::serialize` — mode-aware 序列化;`messages` mode 下 `serialize_messages_tuple` 把 `(chunk, metadata)` 转成 `[chunk.model_dump(), metadata]`。
|
||||||
|
|
||||||
|
**`StreamBridge` 的存在价值**:当生产者(`run_agent` 任务)和消费者(HTTP 连接)在不同的 asyncio task 里运行时,需要一个可以跨 task 传递事件的中介。Queue 同时还承担断连重连的 buffer 和多订阅者的 fan-out。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DeerFlowClient 路径:sync + in-process
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as Python caller
|
||||||
|
participant Client as DeerFlowClient.stream
|
||||||
|
participant Agent as LangGraph<br/>agent.stream (sync)
|
||||||
|
|
||||||
|
User->>Client: for event in client.stream("hi"):
|
||||||
|
Client->>Agent: stream(stream_mode=["values","messages","custom"])
|
||||||
|
loop 每个 chunk
|
||||||
|
Agent-->>Client: (mode, chunk)
|
||||||
|
Client->>Client: 分发 mode<br/>构建 StreamEvent
|
||||||
|
Client-->>User: yield StreamEvent
|
||||||
|
end
|
||||||
|
Client-->>User: yield StreamEvent(type="end")
|
||||||
|
```
|
||||||
|
|
||||||
|
对比之下,sync 路径的每个环节都是显著更少的移动部件:
|
||||||
|
|
||||||
|
- 没有 `RunManager` —— 一次 `stream()` 调用对应一次生命周期,无需 run_id。
|
||||||
|
- 没有 `StreamBridge` —— 直接 `yield`,生产和消费在同一个 Python 调用栈,不需要跨 task 中介。
|
||||||
|
- 没有 JSON 序列化 —— `StreamEvent.data` 直接装原生 LangChain 对象(`AIMessage.content`、`usage_metadata` 的 `UsageMetadata` TypedDict)。Jupyter 用户拿到的是真正的类型,不是匿名 dict。
|
||||||
|
- 没有 asyncio —— 调用者可以直接 `for event in ...`,不必写 `async for`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 消费语义:delta vs cumulative
|
||||||
|
|
||||||
|
LangGraph `messages` mode 给出的是 **delta**:每个 `AIMessageChunk.content` 只包含这一次新 yield 的 token,**不是**从头的累计文本。
|
||||||
|
|
||||||
|
这个语义和 LangChain 的 `fs2 Stream` 风格一致:**上游发增量,下游负责累加**。Gateway 路径里前端 `useStream` React hook 自己维护累加器;DeerFlowClient 路径里 `chat()` 方法替调用者做累加。
|
||||||
|
|
||||||
|
### `DeerFlowClient.chat()` 的 O(n) 累加器
|
||||||
|
|
||||||
|
```python
|
||||||
|
chunks: dict[str, list[str]] = {}
|
||||||
|
last_id: str = ""
|
||||||
|
for event in self.stream(message, thread_id=thread_id, **kwargs):
|
||||||
|
if event.type == "messages-tuple" and event.data.get("type") == "ai":
|
||||||
|
msg_id = event.data.get("id") or ""
|
||||||
|
delta = event.data.get("content", "")
|
||||||
|
if delta:
|
||||||
|
chunks.setdefault(msg_id, []).append(delta)
|
||||||
|
last_id = msg_id
|
||||||
|
return "".join(chunks.get(last_id, ()))
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么不是 `buffers[id] = buffers.get(id,"") + delta`**:CPython 的字符串 in-place concat 优化仅在 refcount=1 且 LHS 是 local name 时生效;这里字符串存在 dict 里被 reassign,优化失效,每次都是 O(n) 拷贝 → 总体 O(n²)。实测 50 KB / 5000 chunk 的回复要 100-300ms 纯拷贝开销。用 `list` + `"".join()` 是 O(n)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三个 id set 为什么不能合并
|
||||||
|
|
||||||
|
`DeerFlowClient.stream()` 在一次调用生命周期内维护三个 `set[str]`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
seen_ids: set[str] = set() # values 路径内部 dedup
|
||||||
|
streamed_ids: set[str] = set() # messages → values 跨模式 dedup
|
||||||
|
counted_usage_ids: set[str] = set() # usage_metadata 幂等计数
|
||||||
|
```
|
||||||
|
|
||||||
|
乍看像是"三份几乎一样的东西",实际每个管**不同的不变式**。
|
||||||
|
|
||||||
|
| Set | 负责的不变式 | 被谁填充 | 被谁查询 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `seen_ids` | 连续两个 `values` 快照里同一条 message 只生成一个 `messages-tuple` 事件 | values 分支每处理一条消息就加入 | values 分支处理下一条消息前检查 |
|
||||||
|
| `streamed_ids` | 如果一条消息已经通过 `messages` 模式 token 级流过,values 快照到达时**不要**再合成一次完整 `messages-tuple` | messages 分支每发一个 AI/tool 事件就加入 | values 分支看到消息时检查 |
|
||||||
|
| `counted_usage_ids` | 同一个 `usage_metadata` 在 messages 末尾 chunk 和 values 快照的 final AIMessage 里各带一份,**累计总量只算一次** | `_account_usage()` 每次接受 usage 就加入 | `_account_usage()` 每次调用时检查 |
|
||||||
|
|
||||||
|
### 为什么不能只用一个 set
|
||||||
|
|
||||||
|
关键观察:**同一个 message id 在这三个 set 里的加入时机不同**。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant M as messages mode
|
||||||
|
participant V as values mode
|
||||||
|
participant SS as streamed_ids
|
||||||
|
participant SU as counted_usage_ids
|
||||||
|
participant SE as seen_ids
|
||||||
|
|
||||||
|
Note over M: 第一个 AI text chunk 到达
|
||||||
|
M->>SS: add(msg_id)
|
||||||
|
Note over M: 最后一个 chunk 带 usage
|
||||||
|
M->>SU: add(msg_id)
|
||||||
|
Note over V: snapshot 到达,包含同一条 AI message
|
||||||
|
V->>SE: add(msg_id)
|
||||||
|
V->>SS: 查询 → 已存在,跳过文本合成
|
||||||
|
V->>SU: 查询 → 已存在,不重复计数
|
||||||
|
```
|
||||||
|
|
||||||
|
- `seen_ids` **永远在 values 快照到达时**加入,所以它是 "values 已处理" 的标记。一条只出现在 messages 流里的消息(罕见但可能),`seen_ids` 里永远没有它。
|
||||||
|
- `streamed_ids` **在 messages 流的第一个有效事件时**加入。一条只通过 values 快照到达的非 AI 消息(HumanMessage、被 truncate 的 tool 消息),`streamed_ids` 里永远没有它。
|
||||||
|
- `counted_usage_ids` **只在看到非空 `usage_metadata` 时**加入。一条完全没有 usage 的消息(tool message、错误消息)永远不会进去。
|
||||||
|
|
||||||
|
**集合包含关系**:`counted_usage_ids ⊆ (streamed_ids ∪ seen_ids)` 大致成立,但**不是严格子集**,因为一条消息可以在 messages 模式流完 text 但**在最后那个带 usage 的 chunk 之前**就被 values snapshot 赶上——此时它已经在 `streamed_ids` 里,但还不在 `counted_usage_ids` 里。把它们合并成一个 dict-of-flags 会让这个微妙的时序依赖**从类型系统里消失**,变成注释里的一句话。三个独立的 set 把不变式显式化了:每个 set 名对应一个可以口头回答的问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 端到端:一次真实对话的事件时序
|
||||||
|
|
||||||
|
假设调用 `client.stream("Count from 1 to 15")`,LLM 给出 "one\ntwo\n...\nfifteen"(88 字符),tokenizer 把它拆成 ~35 个 BPE chunk。下面是事件到达序列的精简版:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant C as DeerFlowClient
|
||||||
|
participant A as LangGraph<br/>agent.stream
|
||||||
|
|
||||||
|
U->>C: stream("Count ... 15")
|
||||||
|
C->>A: stream(mode=["values","messages","custom"])
|
||||||
|
|
||||||
|
A-->>C: ("values", {messages: [HumanMessage]})
|
||||||
|
C-->>U: StreamEvent(type="values", ...)
|
||||||
|
|
||||||
|
Note over A,C: LLM 开始 yield token
|
||||||
|
loop 35 次,约 476ms
|
||||||
|
A-->>C: ("messages", (AIMessageChunk(content="ele"), meta))
|
||||||
|
C->>C: streamed_ids.add(ai-1)
|
||||||
|
C-->>U: StreamEvent(type="messages-tuple",<br/>data={type:ai, content:"ele", id:ai-1})
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over A: LLM finish_reason=stop,最后一个 chunk 带 usage
|
||||||
|
A-->>C: ("messages", (AIMessageChunk(content="", usage_metadata={...}), meta))
|
||||||
|
C->>C: counted_usage_ids.add(ai-1)<br/>(无文本,不 yield)
|
||||||
|
|
||||||
|
A-->>C: ("values", {messages: [..., AIMessage(complete)]})
|
||||||
|
C->>C: ai-1 in streamed_ids → 跳过合成
|
||||||
|
C->>C: 捕获 usage (已在 counted_usage_ids,no-op)
|
||||||
|
C-->>U: StreamEvent(type="values", ...)
|
||||||
|
|
||||||
|
C-->>U: StreamEvent(type="end", data={usage:{...}})
|
||||||
|
```
|
||||||
|
|
||||||
|
关键观察:
|
||||||
|
|
||||||
|
1. 用户看到 **35 个 messages-tuple 事件**,跨越约 476ms,每个事件带一个 token delta 和同一个 `id=ai-1`。
|
||||||
|
2. 最后一个 `values` 快照里的 `AIMessage` **不会**再触发一个完整的 `messages-tuple` 事件——因为 `ai-1 in streamed_ids` 跳过了合成。
|
||||||
|
3. `end` 事件里的 `usage` 正好等于那一份 cumulative usage,**不是它的两倍**——`counted_usage_ids` 在 messages 末尾 chunk 上已经吸收了,values 分支的重复访问是 no-op。
|
||||||
|
4. 消费者拿到的 `content` 是**增量**:"ele" 只包含 3 个字符,不是 "one\ntwo\n...ele"。想要完整文本要按 `id` 累加,`chat()` 已经帮你做了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么这个设计容易出 bug,以及测试策略
|
||||||
|
|
||||||
|
本文档的直接起因是 bytedance/deer-flow#1969:`DeerFlowClient.stream()` 原本只订阅 `["values", "custom"]`,**漏了 `"messages"`**。结果 `client.stream("hello")` 等价于一次性返回,视觉上和 `chat()` 没区别。
|
||||||
|
|
||||||
|
这类 bug 有三个结构性原因:
|
||||||
|
|
||||||
|
1. **多协议层命名**:`messages` / `messages-tuple` / HTTP SSE `messages` 是同一概念的三个名字。在其中一层出错不会在另外两层报错。
|
||||||
|
2. **多消费者模型**:Gateway 和 DeerFlowClient 是两套独立实现,**没有单一的"订阅哪些 mode"的 single source of truth**。前者订阅对了不代表后者也订阅对了。
|
||||||
|
3. **mock 测试绕开了真实路径**:老测试用 `agent.stream.return_value = iter([dict_chunk, ...])` 喂 values 形状的 dict 模拟 state 快照。这样构造的输入**永远不会进入 `messages` mode 分支**,所以即使 `stream_mode` 里少一个元素,CI 依然全绿。
|
||||||
|
|
||||||
|
### 防御手段
|
||||||
|
|
||||||
|
真正的防线是**显式断言 "messages" mode 被订阅 + 用真实 chunk shape mock**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_client.py::test_messages_mode_emits_token_deltas
|
||||||
|
agent.stream.return_value = iter([
|
||||||
|
("messages", (AIMessageChunk(content="Hel", id="ai-1"), {})),
|
||||||
|
("messages", (AIMessageChunk(content="lo ", id="ai-1"), {})),
|
||||||
|
("messages", (AIMessageChunk(content="world!", id="ai-1"), {})),
|
||||||
|
("values", {"messages": [HumanMessage(...), AIMessage(content="Hello world!", id="ai-1")]}),
|
||||||
|
])
|
||||||
|
# ...
|
||||||
|
assert [e.data["content"] for e in ai_text_events] == ["Hel", "lo ", "world!"]
|
||||||
|
assert len(ai_text_events) == 3 # values snapshot must NOT re-synthesize
|
||||||
|
assert "messages" in agent.stream.call_args.kwargs["stream_mode"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么这比"抽一个共享常量"更有效**:共享常量只能保证"用它的人写对字符串",但新增消费者的人可能根本不知道常量在哪。行为断言强制任何改动都要穿过**实际执行路径**,改回 `["values", "custom"]` 会立刻让 `assert "messages" in ...` 失败。
|
||||||
|
|
||||||
|
### 活体信号:BPE 子词边界
|
||||||
|
|
||||||
|
回归的最终验证是让真实 LLM 数 1-15,然后看是否能在输出里看到 tokenizer 的子词切分:
|
||||||
|
|
||||||
|
```
|
||||||
|
[5.460s] 'ele' / 'ven' eleven 被拆成两个 token
|
||||||
|
[5.508s] 'tw' / 'elve' twelve 拆两个
|
||||||
|
[5.568s] 'th' / 'irteen' thirteen 拆两个
|
||||||
|
[5.623s] 'four'/ 'teen' fourteen 拆两个
|
||||||
|
[5.677s] 'f' / 'if' / 'teen' fifteen 拆三个
|
||||||
|
```
|
||||||
|
|
||||||
|
子词切分是 tokenizer 的外部事实,**无法伪造**。能看到它就说明数据流**逐 chunk** 地穿过了整条管道,没有被任何中间层缓冲成整段。这种"活体信号"在流式系统里是比单元测试更高置信度的证据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关源码定位
|
||||||
|
|
||||||
|
| 关心什么 | 看这里 |
|
||||||
|
|---|---|
|
||||||
|
| DeerFlowClient 嵌入式流 | `packages/harness/deerflow/client.py::DeerFlowClient.stream` |
|
||||||
|
| `chat()` 的 delta 累加器 | `packages/harness/deerflow/client.py::DeerFlowClient.chat` |
|
||||||
|
| Gateway async 流 | `packages/harness/deerflow/runtime/runs/worker.py::run_agent` |
|
||||||
|
| HTTP SSE 帧输出 | `app/gateway/services.py::sse_consumer` / `format_sse` |
|
||||||
|
| 序列化到 wire 格式 | `packages/harness/deerflow/runtime/serialization.py` |
|
||||||
|
| LangGraph mode 命名翻译 | `packages/harness/deerflow/runtime/runs/worker.py:117-121` |
|
||||||
|
| 飞书渠道的增量卡片更新 | `app/channels/manager.py::_handle_streaming_chat` |
|
||||||
|
| Channels 自带的 delta/cumulative 防御性累加 | `app/channels/manager.py::_merge_stream_text` |
|
||||||
|
| Frontend useStream 支持的 mode 集合 | `frontend/src/core/api/stream-mode.ts` |
|
||||||
|
| 核心回归测试 | `backend/tests/test_client.py::TestStream::test_messages_mode_emits_token_deltas` |
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
- [x] Add Plan Mode with TodoList middleware
|
- [x] Add Plan Mode with TodoList middleware
|
||||||
- [x] Add vision model support with ViewImageMiddleware
|
- [x] Add vision model support with ViewImageMiddleware
|
||||||
- [x] Skills system with SKILL.md format
|
- [x] Skills system with SKILL.md format
|
||||||
|
- [x] Replace `time.sleep(5)` with `asyncio.sleep()` in `packages/harness/deerflow/tools/builtins/task_tool.py` (subagent polling)
|
||||||
|
|
||||||
## Planned Features
|
## Planned Features
|
||||||
|
|
||||||
@@ -21,10 +22,9 @@
|
|||||||
- [ ] Support for more document formats in upload
|
- [ ] Support for more document formats in upload
|
||||||
- [ ] Skill marketplace / remote skill installation
|
- [ ] Skill marketplace / remote skill installation
|
||||||
- [ ] Optimize async concurrency in agent hot path (IM channels multi-task scenario)
|
- [ ] Optimize async concurrency in agent hot path (IM channels multi-task scenario)
|
||||||
- Replace `time.sleep(5)` with `asyncio.sleep()` in `packages/harness/deerflow/tools/builtins/task_tool.py` (subagent polling)
|
- [ ] Replace `subprocess.run()` with `asyncio.create_subprocess_shell()` in `packages/harness/deerflow/sandbox/local/local_sandbox.py`
|
||||||
- Replace `subprocess.run()` with `asyncio.create_subprocess_shell()` in `packages/harness/deerflow/sandbox/local/local_sandbox.py`
|
|
||||||
- Replace sync `requests` with `httpx.AsyncClient` in community tools (tavily, jina_ai, firecrawl, infoquest, image_search)
|
- Replace sync `requests` with `httpx.AsyncClient` in community tools (tavily, jina_ai, firecrawl, infoquest, image_search)
|
||||||
- Replace sync `model.invoke()` with async `model.ainvoke()` in title_middleware and memory updater
|
- [x] Replace sync `model.invoke()` with async `model.ainvoke()` in title_middleware and memory updater
|
||||||
- Consider `asyncio.to_thread()` wrapper for remaining blocking file I/O
|
- Consider `asyncio.to_thread()` wrapper for remaining blocking file I/O
|
||||||
- For production: use `langgraph up` (multi-worker) instead of `langgraph dev` (single-worker)
|
- For production: use `langgraph up` (multi-worker) instead of `langgraph dev` (single-worker)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ summarization:
|
|||||||
|
|
||||||
# Custom summary prompt (optional)
|
# Custom summary prompt (optional)
|
||||||
summary_prompt: null
|
summary_prompt: null
|
||||||
|
|
||||||
|
# Tool names treated as skill file reads for skill rescue
|
||||||
|
skill_file_read_tool_names:
|
||||||
|
- read_file
|
||||||
|
- read
|
||||||
|
- view
|
||||||
|
- cat
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration Options
|
### Configuration Options
|
||||||
@@ -125,6 +132,26 @@ keep:
|
|||||||
- **Default**: `null` (uses LangChain's default prompt)
|
- **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.
|
- **Description**: Custom prompt template for generating summaries. The prompt should guide the model to extract the most important context.
|
||||||
|
|
||||||
|
#### `preserve_recent_skill_count`
|
||||||
|
- **Type**: Integer (≥ 0)
|
||||||
|
- **Default**: `5`
|
||||||
|
- **Description**: Number of most-recently-loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`, e.g. `/mnt/skills/...`) that are rescued from summarization. Prevents the agent from losing skill instructions after compression. Set to `0` to disable skill rescue entirely.
|
||||||
|
|
||||||
|
#### `preserve_recent_skill_tokens`
|
||||||
|
- **Type**: Integer (≥ 0)
|
||||||
|
- **Default**: `25000`
|
||||||
|
- **Description**: Total token budget reserved for rescued skill reads. Once this budget is exhausted, older skill bundles are allowed to be summarized.
|
||||||
|
|
||||||
|
#### `preserve_recent_skill_tokens_per_skill`
|
||||||
|
- **Type**: Integer (≥ 0)
|
||||||
|
- **Default**: `5000`
|
||||||
|
- **Description**: Per-skill token cap. Any individual skill read whose tool result exceeds this size is not rescued (it falls through to the summarizer like ordinary content).
|
||||||
|
|
||||||
|
#### `skill_file_read_tool_names`
|
||||||
|
- **Type**: List of strings
|
||||||
|
- **Default**: `["read_file", "read", "view", "cat"]`
|
||||||
|
- **Description**: Tool names treated as skill file reads during summarization rescue. A tool call is only eligible for skill rescue when its name appears in this list and its target path is under `skills.container_path`.
|
||||||
|
|
||||||
**Default Prompt Behavior:**
|
**Default Prompt Behavior:**
|
||||||
The default LangChain prompt instructs the model to:
|
The default LangChain prompt instructs the model to:
|
||||||
- Extract highest quality/most relevant context
|
- Extract highest quality/most relevant context
|
||||||
@@ -147,6 +174,7 @@ The default LangChain prompt instructs the model to:
|
|||||||
- A single summary message is added
|
- A single summary message is added
|
||||||
- Recent messages are preserved
|
- Recent messages are preserved
|
||||||
6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together
|
6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together
|
||||||
|
7. **Skill Rescue**: Before the summary is generated, the most recently loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`) are lifted out of the summarization set and prepended to the preserved tail. Selection walks newest-first under three budgets: `preserve_recent_skill_count`, `preserve_recent_skill_tokens`, and `preserve_recent_skill_tokens_per_skill`. The triggering AIMessage and all of its paired ToolMessages move together so tool_call ↔ tool_result pairing stays intact.
|
||||||
|
|
||||||
### Token Counting
|
### Token Counting
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ from .checkpointer import get_checkpointer, make_checkpointer, reset_checkpointe
|
|||||||
from .factory import create_deerflow_agent
|
from .factory import create_deerflow_agent
|
||||||
from .features import Next, Prev, RuntimeFeatures
|
from .features import Next, Prev, RuntimeFeatures
|
||||||
from .lead_agent import make_lead_agent
|
from .lead_agent import make_lead_agent
|
||||||
|
from .lead_agent.prompt import prime_enabled_skills_cache
|
||||||
from .thread_state import SandboxState, ThreadState
|
from .thread_state import SandboxState, ThreadState
|
||||||
|
|
||||||
|
# LangGraph imports deerflow.agents when registering the graph. Prime the
|
||||||
|
# enabled-skills cache here so the request path can usually read a warm cache
|
||||||
|
# without forcing synchronous filesystem work during prompt module import.
|
||||||
|
prime_enabled_skills_cache()
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"create_deerflow_agent",
|
"create_deerflow_agent",
|
||||||
"RuntimeFeatures",
|
"RuntimeFeatures",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ For sync usage see :mod:`deerflow.agents.checkpointer.provider`.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
@@ -54,7 +55,7 @@ async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]:
|
|||||||
raise ImportError(SQLITE_INSTALL) from exc
|
raise ImportError(SQLITE_INSTALL) from exc
|
||||||
|
|
||||||
conn_str = resolve_sqlite_conn_str(config.connection_string or "store.db")
|
conn_str = resolve_sqlite_conn_str(config.connection_string or "store.db")
|
||||||
ensure_sqlite_parent_dir(conn_str)
|
await asyncio.to_thread(ensure_sqlite_parent_dir, conn_str)
|
||||||
async with AsyncSqliteSaver.from_conn_string(conn_str) as saver:
|
async with AsyncSqliteSaver.from_conn_string(conn_str) as saver:
|
||||||
await saver.setup()
|
await saver.setup()
|
||||||
yield saver
|
yield saver
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from langgraph.types import Checkpointer
|
|||||||
|
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import get_app_config
|
||||||
from deerflow.config.checkpointer_config import CheckpointerConfig
|
from deerflow.config.checkpointer_config import CheckpointerConfig
|
||||||
from deerflow.runtime.store._sqlite_utils import resolve_sqlite_conn_str
|
from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -67,6 +67,7 @@ def _sync_checkpointer_cm(config: CheckpointerConfig) -> Iterator[Checkpointer]:
|
|||||||
raise ImportError(SQLITE_INSTALL) from exc
|
raise ImportError(SQLITE_INSTALL) from exc
|
||||||
|
|
||||||
conn_str = resolve_sqlite_conn_str(config.connection_string or "store.db")
|
conn_str = resolve_sqlite_conn_str(config.connection_string or "store.db")
|
||||||
|
ensure_sqlite_parent_dir(conn_str)
|
||||||
with SqliteSaver.from_conn_string(conn_str) as saver:
|
with SqliteSaver.from_conn_string(conn_str) as saver:
|
||||||
saver.setup()
|
saver.setup()
|
||||||
logger.info("Checkpointer: using SqliteSaver (%s)", conn_str)
|
logger.info("Checkpointer: using SqliteSaver (%s)", conn_str)
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from langchain.agents import create_agent
|
from langchain.agents import create_agent
|
||||||
from langchain.agents.middleware import AgentMiddleware, SummarizationMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
from langchain_core.runnables import RunnableConfig
|
from langchain_core.runnables import RunnableConfig
|
||||||
|
|
||||||
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
||||||
|
from deerflow.agents.memory.summarization_hook import memory_flush_hook
|
||||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||||
|
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||||
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
|
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
|
||||||
from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddleware
|
from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddleware
|
||||||
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
|
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
|
||||||
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
from deerflow.config.agents_config import load_agent_config
|
from deerflow.config.agents_config import load_agent_config, validate_agent_name
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import get_app_config
|
||||||
|
from deerflow.config.memory_config import get_memory_config
|
||||||
from deerflow.config.summarization_config import get_summarization_config
|
from deerflow.config.summarization_config import get_summarization_config
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_runtime_config(config: RunnableConfig) -> dict:
|
||||||
|
"""Merge legacy configurable options with LangGraph runtime context."""
|
||||||
|
cfg = dict(config.get("configurable", {}) or {})
|
||||||
|
context = config.get("context", {}) or {}
|
||||||
|
if isinstance(context, dict):
|
||||||
|
cfg.update(context)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def _resolve_model_name(requested_model_name: str | None = None) -> str:
|
def _resolve_model_name(requested_model_name: str | None = None) -> str:
|
||||||
"""Resolve a runtime model name safely, falling back to default if invalid. Returns None if no models are configured."""
|
"""Resolve a runtime model name safely, falling back to default if invalid. Returns None if no models are configured."""
|
||||||
app_config = get_app_config()
|
app_config = get_app_config()
|
||||||
@@ -38,7 +50,7 @@ def _resolve_model_name(requested_model_name: str | None = None) -> str:
|
|||||||
return default_model_name
|
return default_model_name
|
||||||
|
|
||||||
|
|
||||||
def _create_summarization_middleware() -> SummarizationMiddleware | None:
|
def _create_summarization_middleware() -> DeerFlowSummarizationMiddleware | None:
|
||||||
"""Create and configure the summarization middleware from config."""
|
"""Create and configure the summarization middleware from config."""
|
||||||
config = get_summarization_config()
|
config = get_summarization_config()
|
||||||
|
|
||||||
@@ -77,7 +89,28 @@ def _create_summarization_middleware() -> SummarizationMiddleware | None:
|
|||||||
if config.summary_prompt is not None:
|
if config.summary_prompt is not None:
|
||||||
kwargs["summary_prompt"] = config.summary_prompt
|
kwargs["summary_prompt"] = config.summary_prompt
|
||||||
|
|
||||||
return SummarizationMiddleware(**kwargs)
|
hooks: list[BeforeSummarizationHook] = []
|
||||||
|
if get_memory_config().enabled:
|
||||||
|
hooks.append(memory_flush_hook)
|
||||||
|
|
||||||
|
# The logic below relies on two assumptions holding true: this factory is
|
||||||
|
# the sole entry point for DeerFlowSummarizationMiddleware, and the runtime
|
||||||
|
# config is not expected to change after startup.
|
||||||
|
try:
|
||||||
|
skills_container_path = get_app_config().skills.container_path or "/mnt/skills"
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to resolve skills container path; falling back to default")
|
||||||
|
skills_container_path = "/mnt/skills"
|
||||||
|
|
||||||
|
return DeerFlowSummarizationMiddleware(
|
||||||
|
**kwargs,
|
||||||
|
skills_container_path=skills_container_path,
|
||||||
|
skill_file_read_tool_names=config.skill_file_read_tool_names,
|
||||||
|
before_summarization=hooks,
|
||||||
|
preserve_recent_skill_count=config.preserve_recent_skill_count,
|
||||||
|
preserve_recent_skill_tokens=config.preserve_recent_skill_tokens,
|
||||||
|
preserve_recent_skill_tokens_per_skill=config.preserve_recent_skill_tokens_per_skill,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _create_todo_list_middleware(is_plan_mode: bool) -> TodoMiddleware | None:
|
def _create_todo_list_middleware(is_plan_mode: bool) -> TodoMiddleware | None:
|
||||||
@@ -224,7 +257,8 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam
|
|||||||
middlewares.append(summarization_middleware)
|
middlewares.append(summarization_middleware)
|
||||||
|
|
||||||
# Add TodoList middleware if plan mode is enabled
|
# Add TodoList middleware if plan mode is enabled
|
||||||
is_plan_mode = config.get("configurable", {}).get("is_plan_mode", False)
|
cfg = _get_runtime_config(config)
|
||||||
|
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
||||||
if todo_list_middleware is not None:
|
if todo_list_middleware is not None:
|
||||||
middlewares.append(todo_list_middleware)
|
middlewares.append(todo_list_middleware)
|
||||||
@@ -253,9 +287,9 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam
|
|||||||
middlewares.append(DeferredToolFilterMiddleware())
|
middlewares.append(DeferredToolFilterMiddleware())
|
||||||
|
|
||||||
# Add SubagentLimitMiddleware to truncate excess parallel task calls
|
# Add SubagentLimitMiddleware to truncate excess parallel task calls
|
||||||
subagent_enabled = config.get("configurable", {}).get("subagent_enabled", False)
|
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||||
if subagent_enabled:
|
if subagent_enabled:
|
||||||
max_concurrent_subagents = config.get("configurable", {}).get("max_concurrent_subagents", 3)
|
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||||
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents))
|
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents))
|
||||||
|
|
||||||
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
||||||
@@ -275,7 +309,7 @@ def make_lead_agent(config: RunnableConfig):
|
|||||||
from deerflow.tools import get_available_tools
|
from deerflow.tools import get_available_tools
|
||||||
from deerflow.tools.builtins import setup_agent
|
from deerflow.tools.builtins import setup_agent
|
||||||
|
|
||||||
cfg = config.get("configurable", {})
|
cfg = _get_runtime_config(config)
|
||||||
|
|
||||||
thinking_enabled = cfg.get("thinking_enabled", True)
|
thinking_enabled = cfg.get("thinking_enabled", True)
|
||||||
reasoning_effort = cfg.get("reasoning_effort", None)
|
reasoning_effort = cfg.get("reasoning_effort", None)
|
||||||
@@ -284,17 +318,17 @@ def make_lead_agent(config: RunnableConfig):
|
|||||||
subagent_enabled = cfg.get("subagent_enabled", False)
|
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||||
agent_name = cfg.get("agent_name")
|
agent_name = validate_agent_name(cfg.get("agent_name"))
|
||||||
|
|
||||||
agent_config = load_agent_config(agent_name) if not is_bootstrap else None
|
agent_config = load_agent_config(agent_name) if not is_bootstrap else None
|
||||||
# Custom agent model or fallback to global/default model resolution
|
# Custom agent model from agent config (if any), or None to let _resolve_model_name pick the default
|
||||||
agent_model_name = agent_config.model if agent_config and agent_config.model else _resolve_model_name()
|
agent_model_name = agent_config.model if agent_config and agent_config.model else None
|
||||||
|
|
||||||
# Final model name resolution with request override, then agent config, then global default
|
# Final model name resolution: request → agent config → global default, with fallback for unknown names
|
||||||
model_name = requested_model_name or agent_model_name
|
model_name = _resolve_model_name(requested_model_name or agent_model_name)
|
||||||
|
|
||||||
app_config = get_app_config()
|
app_config = get_app_config()
|
||||||
model_config = app_config.get_model_config(model_name) if model_name else None
|
model_config = app_config.get_model_config(model_name)
|
||||||
|
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
raise ValueError("No chat model could be resolved. Please configure at least one model in config.yaml or provide a valid 'model_name'/'model' in the request.")
|
raise ValueError("No chat model could be resolved. Please configure at least one model in config.yaml or provide a valid 'model_name'/'model' in the request.")
|
||||||
@@ -325,6 +359,8 @@ def make_lead_agent(config: RunnableConfig):
|
|||||||
"reasoning_effort": reasoning_effort,
|
"reasoning_effort": reasoning_effort,
|
||||||
"is_plan_mode": is_plan_mode,
|
"is_plan_mode": is_plan_mode,
|
||||||
"subagent_enabled": subagent_enabled,
|
"subagent_enabled": subagent_enabled,
|
||||||
|
"tool_groups": agent_config.tool_groups if agent_config else None,
|
||||||
|
"available_skills": ["bootstrap"] if is_bootstrap else (agent_config.skills if agent_config and agent_config.skills is not None else None),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,114 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from deerflow.config.agents_config import load_agent_soul
|
from deerflow.config.agents_config import load_agent_soul
|
||||||
from deerflow.skills import load_skills
|
from deerflow.skills import load_skills
|
||||||
|
from deerflow.skills.types import Skill
|
||||||
from deerflow.subagents import get_available_subagent_names
|
from deerflow.subagents import get_available_subagent_names
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ENABLED_SKILLS_REFRESH_WAIT_TIMEOUT_SECONDS = 5.0
|
||||||
|
_enabled_skills_lock = threading.Lock()
|
||||||
|
_enabled_skills_cache: list[Skill] | None = None
|
||||||
|
_enabled_skills_refresh_active = False
|
||||||
|
_enabled_skills_refresh_version = 0
|
||||||
|
_enabled_skills_refresh_event = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_enabled_skills_sync() -> list[Skill]:
|
||||||
|
return list(load_skills(enabled_only=True))
|
||||||
|
|
||||||
|
|
||||||
|
def _start_enabled_skills_refresh_thread() -> None:
|
||||||
|
threading.Thread(
|
||||||
|
target=_refresh_enabled_skills_cache_worker,
|
||||||
|
name="deerflow-enabled-skills-loader",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_enabled_skills_cache_worker() -> None:
|
||||||
|
global _enabled_skills_cache, _enabled_skills_refresh_active
|
||||||
|
|
||||||
|
while True:
|
||||||
|
with _enabled_skills_lock:
|
||||||
|
target_version = _enabled_skills_refresh_version
|
||||||
|
|
||||||
|
try:
|
||||||
|
skills = _load_enabled_skills_sync()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load enabled skills for prompt injection")
|
||||||
|
skills = []
|
||||||
|
|
||||||
|
with _enabled_skills_lock:
|
||||||
|
if _enabled_skills_refresh_version == target_version:
|
||||||
|
_enabled_skills_cache = skills
|
||||||
|
_enabled_skills_refresh_active = False
|
||||||
|
_enabled_skills_refresh_event.set()
|
||||||
|
return
|
||||||
|
|
||||||
|
# A newer invalidation happened while loading. Keep the worker alive
|
||||||
|
# and loop again so the cache always converges on the latest version.
|
||||||
|
_enabled_skills_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_enabled_skills_cache() -> threading.Event:
|
||||||
|
global _enabled_skills_refresh_active
|
||||||
|
|
||||||
|
with _enabled_skills_lock:
|
||||||
|
if _enabled_skills_cache is not None:
|
||||||
|
_enabled_skills_refresh_event.set()
|
||||||
|
return _enabled_skills_refresh_event
|
||||||
|
if _enabled_skills_refresh_active:
|
||||||
|
return _enabled_skills_refresh_event
|
||||||
|
_enabled_skills_refresh_active = True
|
||||||
|
_enabled_skills_refresh_event.clear()
|
||||||
|
|
||||||
|
_start_enabled_skills_refresh_thread()
|
||||||
|
return _enabled_skills_refresh_event
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_enabled_skills_cache() -> threading.Event:
|
||||||
|
global _enabled_skills_cache, _enabled_skills_refresh_active, _enabled_skills_refresh_version
|
||||||
|
|
||||||
|
_get_cached_skills_prompt_section.cache_clear()
|
||||||
|
with _enabled_skills_lock:
|
||||||
|
_enabled_skills_cache = None
|
||||||
|
_enabled_skills_refresh_version += 1
|
||||||
|
_enabled_skills_refresh_event.clear()
|
||||||
|
if _enabled_skills_refresh_active:
|
||||||
|
return _enabled_skills_refresh_event
|
||||||
|
_enabled_skills_refresh_active = True
|
||||||
|
|
||||||
|
_start_enabled_skills_refresh_thread()
|
||||||
|
return _enabled_skills_refresh_event
|
||||||
|
|
||||||
|
|
||||||
|
def prime_enabled_skills_cache() -> None:
|
||||||
|
_ensure_enabled_skills_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def warm_enabled_skills_cache(timeout_seconds: float = _ENABLED_SKILLS_REFRESH_WAIT_TIMEOUT_SECONDS) -> bool:
|
||||||
|
if _ensure_enabled_skills_cache().wait(timeout=timeout_seconds):
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.warning("Timed out waiting %.1fs for enabled skills cache warm-up", timeout_seconds)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_enabled_skills():
|
def _get_enabled_skills():
|
||||||
try:
|
with _enabled_skills_lock:
|
||||||
return list(load_skills(enabled_only=True))
|
cached = _enabled_skills_cache
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to load enabled skills for prompt injection")
|
if cached is not None:
|
||||||
return []
|
return list(cached)
|
||||||
|
|
||||||
|
_ensure_enabled_skills_cache()
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _skill_mutability_label(category: str) -> str:
|
def _skill_mutability_label(category: str) -> str:
|
||||||
@@ -22,7 +116,36 @@ def _skill_mutability_label(category: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def clear_skills_system_prompt_cache() -> None:
|
def clear_skills_system_prompt_cache() -> None:
|
||||||
|
_invalidate_enabled_skills_cache()
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_skills_system_prompt_cache_async() -> None:
|
||||||
|
await asyncio.to_thread(_invalidate_enabled_skills_cache().wait)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_skills_system_prompt_cache_state() -> None:
|
||||||
|
global _enabled_skills_cache, _enabled_skills_refresh_active, _enabled_skills_refresh_version
|
||||||
|
|
||||||
_get_cached_skills_prompt_section.cache_clear()
|
_get_cached_skills_prompt_section.cache_clear()
|
||||||
|
with _enabled_skills_lock:
|
||||||
|
_enabled_skills_cache = None
|
||||||
|
_enabled_skills_refresh_active = False
|
||||||
|
_enabled_skills_refresh_version = 0
|
||||||
|
_enabled_skills_refresh_event.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_enabled_skills_cache() -> None:
|
||||||
|
"""Backward-compatible test helper for direct synchronous reload."""
|
||||||
|
try:
|
||||||
|
skills = _load_enabled_skills_sync()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load enabled skills for prompt injection")
|
||||||
|
skills = []
|
||||||
|
|
||||||
|
with _enabled_skills_lock:
|
||||||
|
_enabled_skills_cache = skills
|
||||||
|
_enabled_skills_refresh_active = False
|
||||||
|
_enabled_skills_refresh_event.set()
|
||||||
|
|
||||||
|
|
||||||
def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str:
|
def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str:
|
||||||
@@ -41,6 +164,36 @@ Skip simple one-off tasks.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_available_subagents_description(available_names: list[str], bash_available: bool) -> str:
|
||||||
|
"""Dynamically build subagent type descriptions from registry.
|
||||||
|
|
||||||
|
Mirrors Codex's pattern where agent_type_description is dynamically generated
|
||||||
|
from all registered roles, so the LLM knows about every available type.
|
||||||
|
"""
|
||||||
|
# Built-in descriptions (kept for backward compatibility with existing prompt quality)
|
||||||
|
builtin_descriptions = {
|
||||||
|
"general-purpose": "For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.",
|
||||||
|
"bash": (
|
||||||
|
"For command execution (git, build, test, deploy operations)" if bash_available else "Not available in the current sandbox configuration. Use direct file/web tools or switch to AioSandboxProvider for isolated shell access."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lazy import moved outside loop to avoid repeated import overhead
|
||||||
|
from deerflow.subagents.registry import get_subagent_config
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for name in available_names:
|
||||||
|
if name in builtin_descriptions:
|
||||||
|
lines.append(f"- **{name}**: {builtin_descriptions[name]}")
|
||||||
|
else:
|
||||||
|
config = get_subagent_config(name)
|
||||||
|
if config is not None:
|
||||||
|
desc = config.description.split("\n")[0].strip() # First line only for brevity
|
||||||
|
lines.append(f"- **{name}**: {desc}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _build_subagent_section(max_concurrent: int) -> str:
|
def _build_subagent_section(max_concurrent: int) -> str:
|
||||||
"""Build the subagent system prompt section with dynamic concurrency limit.
|
"""Build the subagent system prompt section with dynamic concurrency limit.
|
||||||
|
|
||||||
@@ -51,13 +204,12 @@ def _build_subagent_section(max_concurrent: int) -> str:
|
|||||||
Formatted subagent section string.
|
Formatted subagent section string.
|
||||||
"""
|
"""
|
||||||
n = max_concurrent
|
n = max_concurrent
|
||||||
bash_available = "bash" in get_available_subagent_names()
|
available_names = get_available_subagent_names()
|
||||||
available_subagents = (
|
bash_available = "bash" in available_names
|
||||||
"- **general-purpose**: For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.\n- **bash**: For command execution (git, build, test, deploy operations)"
|
|
||||||
if bash_available
|
# Dynamically build subagent type descriptions from registry (aligned with Codex's
|
||||||
else "- **general-purpose**: For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.\n"
|
# agent_type_description pattern where all registered roles are listed in the tool spec).
|
||||||
"- **bash**: Not available in the current sandbox configuration. Use direct file/web tools or switch to AioSandboxProvider for isolated shell access."
|
available_subagents = _build_available_subagents_description(available_names, bash_available)
|
||||||
)
|
|
||||||
direct_tool_examples = "bash, ls, read_file, web_search, etc." if bash_available else "ls, read_file, web_search, etc."
|
direct_tool_examples = "bash, ls, read_file, web_search, etc." if bash_available else "ls, read_file, web_search, etc."
|
||||||
direct_execution_example = (
|
direct_execution_example = (
|
||||||
'# User asks: "Run the tests"\n# Thinking: Cannot decompose into parallel sub-tasks\n# → Execute directly\n\nbash("npm test") # Direct execution, not task()'
|
'# User asks: "Run the tests"\n# Thinking: Cannot decompose into parallel sub-tasks\n# → Execute directly\n\nbash("npm test") # Direct execution, not task()'
|
||||||
@@ -294,7 +446,10 @@ You: "Deploying to staging..." [proceed]
|
|||||||
- Use `read_file` tool to read uploaded files using their paths from the list
|
- 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
|
- For PDF, PPT, Excel, and Word files, converted Markdown versions (*.md) are available alongside originals
|
||||||
- All temporary work happens in `/mnt/user-data/workspace`
|
- 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
|
- Treat `/mnt/user-data/workspace` as your default current working directory for coding and file-editing tasks
|
||||||
|
- When writing scripts or commands that create/read files from the workspace, prefer relative paths such as `hello.txt`, `../uploads/data.csv`, and `../outputs/report.md`
|
||||||
|
- Avoid hardcoding `/mnt/user-data/...` inside generated scripts when a relative path from the workspace is enough
|
||||||
|
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_files` tool
|
||||||
{acp_section}
|
{acp_section}
|
||||||
</working_directory>
|
</working_directory>
|
||||||
|
|
||||||
@@ -522,7 +677,7 @@ def _build_acp_section() -> str:
|
|||||||
"- ACP agents (e.g. codex, claude_code) run in their own independent workspace — NOT in `/mnt/user-data/`\n"
|
"- ACP agents (e.g. codex, claude_code) run in their own independent workspace — NOT in `/mnt/user-data/`\n"
|
||||||
"- When writing prompts for ACP agents, describe the task only — do NOT reference `/mnt/user-data` paths\n"
|
"- When writing prompts for ACP agents, describe the task only — do NOT reference `/mnt/user-data` paths\n"
|
||||||
"- ACP agent results are accessible at `/mnt/acp-workspace/` (read-only) — use `ls`, `read_file`, or `bash cp` to retrieve output files\n"
|
"- ACP agent results are accessible at `/mnt/acp-workspace/` (read-only) — use `ls`, `read_file`, or `bash cp` to retrieve output files\n"
|
||||||
"- To deliver ACP output to the user: copy from `/mnt/acp-workspace/<file>` to `/mnt/user-data/outputs/<file>`, then use `present_file`"
|
"- To deliver ACP output to the user: copy from `/mnt/acp-workspace/<file>` to `/mnt/user-data/outputs/<file>`, then use `present_files`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Shared helpers for turning conversations into memory update inputs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from copy import copy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_UPLOAD_BLOCK_RE = re.compile(r"<uploaded_files>[\s\S]*?</uploaded_files>\n*", re.IGNORECASE)
|
||||||
|
_CORRECTION_PATTERNS = (
|
||||||
|
re.compile(r"\bthat(?:'s| is) (?:wrong|incorrect)\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\byou misunderstood\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\btry again\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bredo\b", re.IGNORECASE),
|
||||||
|
re.compile(r"不对"),
|
||||||
|
re.compile(r"你理解错了"),
|
||||||
|
re.compile(r"你理解有误"),
|
||||||
|
re.compile(r"重试"),
|
||||||
|
re.compile(r"重新来"),
|
||||||
|
re.compile(r"换一种"),
|
||||||
|
re.compile(r"改用"),
|
||||||
|
)
|
||||||
|
_REINFORCEMENT_PATTERNS = (
|
||||||
|
re.compile(r"\byes[,.]?\s+(?:exactly|perfect|that(?:'s| is) (?:right|correct|it))\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bperfect(?:[.!?]|$)", re.IGNORECASE),
|
||||||
|
re.compile(r"\bexactly\s+(?:right|correct)\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bthat(?:'s| is)\s+(?:exactly\s+)?(?:right|correct|what i (?:wanted|needed|meant))\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bkeep\s+(?:doing\s+)?that\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bjust\s+(?:like\s+)?(?:that|this)\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bthis is (?:great|helpful)\b(?:[.!?]|$)", re.IGNORECASE),
|
||||||
|
re.compile(r"\bthis is what i wanted\b(?:[.!?]|$)", re.IGNORECASE),
|
||||||
|
re.compile(r"对[,,]?\s*就是这样(?:[。!?!?.]|$)"),
|
||||||
|
re.compile(r"完全正确(?:[。!?!?.]|$)"),
|
||||||
|
re.compile(r"(?:对[,,]?\s*)?就是这个意思(?:[。!?!?.]|$)"),
|
||||||
|
re.compile(r"正是我想要的(?:[。!?!?.]|$)"),
|
||||||
|
re.compile(r"继续保持(?:[。!?!?.]|$)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_message_text(message: Any) -> str:
|
||||||
|
"""Extract plain text from message content for filtering and signal detection."""
|
||||||
|
content = getattr(message, "content", "")
|
||||||
|
if isinstance(content, list):
|
||||||
|
text_parts: list[str] = []
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, str):
|
||||||
|
text_parts.append(part)
|
||||||
|
elif isinstance(part, dict):
|
||||||
|
text_val = part.get("text")
|
||||||
|
if isinstance(text_val, str):
|
||||||
|
text_parts.append(text_val)
|
||||||
|
return " ".join(text_parts)
|
||||||
|
return str(content)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_messages_for_memory(messages: list[Any]) -> list[Any]:
|
||||||
|
"""Keep only user inputs and final assistant responses for memory updates."""
|
||||||
|
filtered = []
|
||||||
|
skip_next_ai = False
|
||||||
|
for msg in messages:
|
||||||
|
msg_type = getattr(msg, "type", None)
|
||||||
|
|
||||||
|
if msg_type == "human":
|
||||||
|
content_str = extract_message_text(msg)
|
||||||
|
if "<uploaded_files>" in content_str:
|
||||||
|
stripped = _UPLOAD_BLOCK_RE.sub("", content_str).strip()
|
||||||
|
if not stripped:
|
||||||
|
skip_next_ai = True
|
||||||
|
continue
|
||||||
|
clean_msg = copy(msg)
|
||||||
|
clean_msg.content = stripped
|
||||||
|
filtered.append(clean_msg)
|
||||||
|
skip_next_ai = False
|
||||||
|
else:
|
||||||
|
filtered.append(msg)
|
||||||
|
skip_next_ai = False
|
||||||
|
elif msg_type == "ai":
|
||||||
|
tool_calls = getattr(msg, "tool_calls", None)
|
||||||
|
if not tool_calls:
|
||||||
|
if skip_next_ai:
|
||||||
|
skip_next_ai = False
|
||||||
|
continue
|
||||||
|
filtered.append(msg)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def detect_correction(messages: list[Any]) -> bool:
|
||||||
|
"""Detect explicit user corrections in recent conversation turns."""
|
||||||
|
recent_user_msgs = [msg for msg in messages[-6:] if getattr(msg, "type", None) == "human"]
|
||||||
|
|
||||||
|
for msg in recent_user_msgs:
|
||||||
|
content = extract_message_text(msg).strip()
|
||||||
|
if content and any(pattern.search(content) for pattern in _CORRECTION_PATTERNS):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def detect_reinforcement(messages: list[Any]) -> bool:
|
||||||
|
"""Detect explicit positive reinforcement signals in recent conversation turns."""
|
||||||
|
recent_user_msgs = [msg for msg in messages[-6:] if getattr(msg, "type", None) == "human"]
|
||||||
|
|
||||||
|
for msg in recent_user_msgs:
|
||||||
|
content = extract_message_text(msg).strip()
|
||||||
|
if content and any(pattern.search(content) for pattern in _REINFORCEMENT_PATTERNS):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -4,7 +4,7 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deerflow.config.memory_config import get_memory_config
|
from deerflow.config.memory_config import get_memory_config
|
||||||
@@ -18,7 +18,7 @@ class ConversationContext:
|
|||||||
|
|
||||||
thread_id: str
|
thread_id: str
|
||||||
messages: list[Any]
|
messages: list[Any]
|
||||||
timestamp: datetime = field(default_factory=datetime.utcnow)
|
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||||
agent_name: str | None = None
|
agent_name: str | None = None
|
||||||
correction_detected: bool = False
|
correction_detected: bool = False
|
||||||
reinforcement_detected: bool = False
|
reinforcement_detected: bool = False
|
||||||
@@ -61,48 +61,88 @@ class MemoryUpdateQueue:
|
|||||||
return
|
return
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
existing_context = next(
|
self._enqueue_locked(
|
||||||
(context for context in self._queue if context.thread_id == thread_id),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
merged_correction_detected = correction_detected or (existing_context.correction_detected if existing_context is not None else False)
|
|
||||||
merged_reinforcement_detected = reinforcement_detected or (existing_context.reinforcement_detected if existing_context is not None else False)
|
|
||||||
context = ConversationContext(
|
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
agent_name=agent_name,
|
agent_name=agent_name,
|
||||||
correction_detected=merged_correction_detected,
|
correction_detected=correction_detected,
|
||||||
reinforcement_detected=merged_reinforcement_detected,
|
reinforcement_detected=reinforcement_detected,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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()
|
self._reset_timer()
|
||||||
|
|
||||||
logger.info("Memory update queued for thread %s, queue size: %d", thread_id, len(self._queue))
|
logger.info("Memory update queued for thread %s, queue size: %d", thread_id, len(self._queue))
|
||||||
|
|
||||||
|
def add_nowait(
|
||||||
|
self,
|
||||||
|
thread_id: str,
|
||||||
|
messages: list[Any],
|
||||||
|
agent_name: str | None = None,
|
||||||
|
correction_detected: bool = False,
|
||||||
|
reinforcement_detected: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Add a conversation and start processing immediately in the background."""
|
||||||
|
config = get_memory_config()
|
||||||
|
if not config.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._enqueue_locked(
|
||||||
|
thread_id=thread_id,
|
||||||
|
messages=messages,
|
||||||
|
agent_name=agent_name,
|
||||||
|
correction_detected=correction_detected,
|
||||||
|
reinforcement_detected=reinforcement_detected,
|
||||||
|
)
|
||||||
|
self._schedule_timer(0)
|
||||||
|
|
||||||
|
logger.info("Memory update queued for immediate processing on thread %s, queue size: %d", thread_id, len(self._queue))
|
||||||
|
|
||||||
|
def _enqueue_locked(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
thread_id: str,
|
||||||
|
messages: list[Any],
|
||||||
|
agent_name: str | None,
|
||||||
|
correction_detected: bool,
|
||||||
|
reinforcement_detected: bool,
|
||||||
|
) -> None:
|
||||||
|
existing_context = next(
|
||||||
|
(context for context in self._queue if context.thread_id == thread_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
merged_correction_detected = correction_detected or (existing_context.correction_detected if existing_context is not None else False)
|
||||||
|
merged_reinforcement_detected = reinforcement_detected or (existing_context.reinforcement_detected if existing_context is not None else False)
|
||||||
|
context = ConversationContext(
|
||||||
|
thread_id=thread_id,
|
||||||
|
messages=messages,
|
||||||
|
agent_name=agent_name,
|
||||||
|
correction_detected=merged_correction_detected,
|
||||||
|
reinforcement_detected=merged_reinforcement_detected,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._queue = [c for c in self._queue if c.thread_id != thread_id]
|
||||||
|
self._queue.append(context)
|
||||||
|
|
||||||
def _reset_timer(self) -> None:
|
def _reset_timer(self) -> None:
|
||||||
"""Reset the debounce timer."""
|
"""Reset the debounce timer."""
|
||||||
config = get_memory_config()
|
config = get_memory_config()
|
||||||
|
self._schedule_timer(config.debounce_seconds)
|
||||||
|
|
||||||
|
logger.debug("Memory update timer set for %ss", config.debounce_seconds)
|
||||||
|
|
||||||
|
def _schedule_timer(self, delay_seconds: float) -> None:
|
||||||
|
"""Schedule queue processing after the provided delay."""
|
||||||
# Cancel existing timer if any
|
# Cancel existing timer if any
|
||||||
if self._timer is not None:
|
if self._timer is not None:
|
||||||
self._timer.cancel()
|
self._timer.cancel()
|
||||||
|
|
||||||
# Start new timer
|
|
||||||
self._timer = threading.Timer(
|
self._timer = threading.Timer(
|
||||||
config.debounce_seconds,
|
delay_seconds,
|
||||||
self._process_queue,
|
self._process_queue,
|
||||||
)
|
)
|
||||||
self._timer.daemon = True
|
self._timer.daemon = True
|
||||||
self._timer.start()
|
self._timer.start()
|
||||||
|
|
||||||
logger.debug("Memory update timer set for %ss", config.debounce_seconds)
|
|
||||||
|
|
||||||
def _process_queue(self) -> None:
|
def _process_queue(self) -> None:
|
||||||
"""Process all queued conversation contexts."""
|
"""Process all queued conversation contexts."""
|
||||||
# Import here to avoid circular dependency
|
# Import here to avoid circular dependency
|
||||||
@@ -110,8 +150,8 @@ class MemoryUpdateQueue:
|
|||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._processing:
|
if self._processing:
|
||||||
# Already processing, reschedule
|
# Preserve immediate flush semantics even if another worker is active.
|
||||||
self._reset_timer()
|
self._schedule_timer(0)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self._queue:
|
if not self._queue:
|
||||||
@@ -164,6 +204,13 @@ class MemoryUpdateQueue:
|
|||||||
|
|
||||||
self._process_queue()
|
self._process_queue()
|
||||||
|
|
||||||
|
def flush_nowait(self) -> None:
|
||||||
|
"""Start queue processing immediately in a background thread."""
|
||||||
|
with self._lock:
|
||||||
|
# Daemon thread: queued messages may be lost if the process exits
|
||||||
|
# before _process_queue completes. Acceptable for best-effort memory updates.
|
||||||
|
self._schedule_timer(0)
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear the queue without processing.
|
"""Clear the queue without processing.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import abc
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -15,11 +16,16 @@ from deerflow.config.paths import get_paths
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_iso_z() -> str:
|
||||||
|
"""Current UTC time as ISO-8601 with ``Z`` suffix (matches prior naive-UTC output)."""
|
||||||
|
return datetime.now(UTC).isoformat().removesuffix("+00:00") + "Z"
|
||||||
|
|
||||||
|
|
||||||
def create_empty_memory() -> dict[str, Any]:
|
def create_empty_memory() -> dict[str, Any]:
|
||||||
"""Create an empty memory structure."""
|
"""Create an empty memory structure."""
|
||||||
return {
|
return {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"lastUpdated": datetime.utcnow().isoformat() + "Z",
|
"lastUpdated": utc_now_iso_z(),
|
||||||
"user": {
|
"user": {
|
||||||
"workContext": {"summary": "", "updatedAt": ""},
|
"workContext": {"summary": "", "updatedAt": ""},
|
||||||
"personalContext": {"summary": "", "updatedAt": ""},
|
"personalContext": {"summary": "", "updatedAt": ""},
|
||||||
@@ -61,6 +67,8 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
# Per-agent memory cache: keyed by agent_name (None = global)
|
# Per-agent memory cache: keyed by agent_name (None = global)
|
||||||
# Value: (memory_data, file_mtime)
|
# Value: (memory_data, file_mtime)
|
||||||
self._memory_cache: dict[str | None, tuple[dict[str, Any], float | None]] = {}
|
self._memory_cache: dict[str | None, tuple[dict[str, Any], float | None]] = {}
|
||||||
|
# Guards all reads and writes to _memory_cache across concurrent callers.
|
||||||
|
self._cache_lock = threading.Lock()
|
||||||
|
|
||||||
def _validate_agent_name(self, agent_name: str) -> None:
|
def _validate_agent_name(self, agent_name: str) -> None:
|
||||||
"""Validate that the agent name is safe to use in filesystem paths.
|
"""Validate that the agent name is safe to use in filesystem paths.
|
||||||
@@ -109,14 +117,17 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
except OSError:
|
except OSError:
|
||||||
current_mtime = None
|
current_mtime = None
|
||||||
|
|
||||||
cached = self._memory_cache.get(agent_name)
|
with self._cache_lock:
|
||||||
|
cached = self._memory_cache.get(agent_name)
|
||||||
|
if cached is not None and cached[1] == current_mtime:
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
if cached is None or cached[1] != current_mtime:
|
memory_data = self._load_memory_from_file(agent_name)
|
||||||
memory_data = self._load_memory_from_file(agent_name)
|
|
||||||
|
with self._cache_lock:
|
||||||
self._memory_cache[agent_name] = (memory_data, current_mtime)
|
self._memory_cache[agent_name] = (memory_data, current_mtime)
|
||||||
return memory_data
|
|
||||||
|
|
||||||
return cached[0]
|
return memory_data
|
||||||
|
|
||||||
def reload(self, agent_name: str | None = None) -> dict[str, Any]:
|
def reload(self, agent_name: str | None = None) -> dict[str, Any]:
|
||||||
"""Reload memory data from file, forcing cache invalidation."""
|
"""Reload memory data from file, forcing cache invalidation."""
|
||||||
@@ -128,7 +139,8 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
except OSError:
|
except OSError:
|
||||||
mtime = None
|
mtime = None
|
||||||
|
|
||||||
self._memory_cache[agent_name] = (memory_data, mtime)
|
with self._cache_lock:
|
||||||
|
self._memory_cache[agent_name] = (memory_data, mtime)
|
||||||
return memory_data
|
return memory_data
|
||||||
|
|
||||||
def save(self, memory_data: dict[str, Any], agent_name: str | None = None) -> bool:
|
def save(self, memory_data: dict[str, Any], agent_name: str | None = None) -> bool:
|
||||||
@@ -137,9 +149,12 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
memory_data["lastUpdated"] = datetime.utcnow().isoformat() + "Z"
|
# Shallow-copy before adding lastUpdated so the caller's dict is not
|
||||||
|
# mutated as a side-effect, and the cache reference is not silently
|
||||||
|
# updated before the file write succeeds.
|
||||||
|
memory_data = {**memory_data, "lastUpdated": utc_now_iso_z()}
|
||||||
|
|
||||||
temp_path = file_path.with_suffix(".tmp")
|
temp_path = file_path.with_suffix(f".{uuid.uuid4().hex}.tmp")
|
||||||
with open(temp_path, "w", encoding="utf-8") as f:
|
with open(temp_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(memory_data, f, indent=2, ensure_ascii=False)
|
json.dump(memory_data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
@@ -150,7 +165,8 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
except OSError:
|
except OSError:
|
||||||
mtime = None
|
mtime = None
|
||||||
|
|
||||||
self._memory_cache[agent_name] = (memory_data, mtime)
|
with self._cache_lock:
|
||||||
|
self._memory_cache[agent_name] = (memory_data, mtime)
|
||||||
logger.info("Memory saved to %s", file_path)
|
logger.info("Memory saved to %s", file_path)
|
||||||
return True
|
return True
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Hooks fired before summarization removes messages from state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from deerflow.agents.memory.message_processing import detect_correction, detect_reinforcement, filter_messages_for_memory
|
||||||
|
from deerflow.agents.memory.queue import get_memory_queue
|
||||||
|
from deerflow.agents.middlewares.summarization_middleware import SummarizationEvent
|
||||||
|
from deerflow.config.memory_config import get_memory_config
|
||||||
|
|
||||||
|
|
||||||
|
def memory_flush_hook(event: SummarizationEvent) -> None:
|
||||||
|
"""Flush messages about to be summarized into the memory queue."""
|
||||||
|
if not get_memory_config().enabled or not event.thread_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
filtered_messages = filter_messages_for_memory(list(event.messages_to_summarize))
|
||||||
|
user_messages = [message for message in filtered_messages if getattr(message, "type", None) == "human"]
|
||||||
|
assistant_messages = [message for message in filtered_messages if getattr(message, "type", None) == "ai"]
|
||||||
|
if not user_messages or not assistant_messages:
|
||||||
|
return
|
||||||
|
|
||||||
|
correction_detected = detect_correction(filtered_messages)
|
||||||
|
reinforcement_detected = not correction_detected and detect_reinforcement(filtered_messages)
|
||||||
|
queue = get_memory_queue()
|
||||||
|
queue.add_nowait(
|
||||||
|
thread_id=event.thread_id,
|
||||||
|
messages=filtered_messages,
|
||||||
|
agent_name=event.agent_name,
|
||||||
|
correction_detected=correction_detected,
|
||||||
|
reinforcement_detected=reinforcement_detected,
|
||||||
|
)
|
||||||
@@ -1,23 +1,37 @@
|
|||||||
"""Memory updater for reading, writing, and updating memory data."""
|
"""Memory updater for reading, writing, and updating memory data."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import atexit
|
||||||
|
import concurrent.futures
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from collections.abc import Awaitable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deerflow.agents.memory.prompt import (
|
from deerflow.agents.memory.prompt import (
|
||||||
MEMORY_UPDATE_PROMPT,
|
MEMORY_UPDATE_PROMPT,
|
||||||
format_conversation_for_update,
|
format_conversation_for_update,
|
||||||
)
|
)
|
||||||
from deerflow.agents.memory.storage import create_empty_memory, get_memory_storage
|
from deerflow.agents.memory.storage import (
|
||||||
|
create_empty_memory,
|
||||||
|
get_memory_storage,
|
||||||
|
utc_now_iso_z,
|
||||||
|
)
|
||||||
from deerflow.config.memory_config import get_memory_config
|
from deerflow.config.memory_config import get_memory_config
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SYNC_MEMORY_UPDATER_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
||||||
|
max_workers=4,
|
||||||
|
thread_name_prefix="memory-updater-sync",
|
||||||
|
)
|
||||||
|
atexit.register(lambda: _SYNC_MEMORY_UPDATER_EXECUTOR.shutdown(wait=False))
|
||||||
|
|
||||||
|
|
||||||
def _create_empty_memory() -> dict[str, Any]:
|
def _create_empty_memory() -> dict[str, Any]:
|
||||||
"""Backward-compatible wrapper around the storage-layer empty-memory factory."""
|
"""Backward-compatible wrapper around the storage-layer empty-memory factory."""
|
||||||
@@ -86,7 +100,7 @@ def create_memory_fact(
|
|||||||
|
|
||||||
normalized_category = category.strip() or "context"
|
normalized_category = category.strip() or "context"
|
||||||
validated_confidence = _validate_confidence(confidence)
|
validated_confidence = _validate_confidence(confidence)
|
||||||
now = datetime.utcnow().isoformat() + "Z"
|
now = utc_now_iso_z()
|
||||||
memory_data = get_memory_data(agent_name)
|
memory_data = get_memory_data(agent_name)
|
||||||
updated_memory = dict(memory_data)
|
updated_memory = dict(memory_data)
|
||||||
facts = list(memory_data.get("facts", []))
|
facts = list(memory_data.get("facts", []))
|
||||||
@@ -203,6 +217,39 @@ def _extract_text(content: Any) -> str:
|
|||||||
return str(content)
|
return str(content)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_async_update_sync(coro: Awaitable[bool]) -> bool:
|
||||||
|
"""Run an async memory update from sync code, including nested-loop contexts."""
|
||||||
|
handed_off = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
loop = None
|
||||||
|
|
||||||
|
if loop is not None and loop.is_running():
|
||||||
|
future = _SYNC_MEMORY_UPDATER_EXECUTOR.submit(asyncio.run, coro)
|
||||||
|
handed_off = True
|
||||||
|
return future.result()
|
||||||
|
|
||||||
|
handed_off = True
|
||||||
|
return asyncio.run(coro)
|
||||||
|
except Exception:
|
||||||
|
if not handed_off:
|
||||||
|
close = getattr(coro, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
try:
|
||||||
|
close()
|
||||||
|
except Exception:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to close un-awaited memory update coroutine",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.exception("Failed to run async memory update from sync context")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Matches sentences that describe a file-upload *event* rather than general
|
# Matches sentences that describe a file-upload *event* rather than general
|
||||||
# file-related work. Deliberately narrow to avoid removing legitimate facts
|
# file-related work. Deliberately narrow to avoid removing legitimate facts
|
||||||
# such as "User works with CSV files" or "prefers PDF export".
|
# such as "User works with CSV files" or "prefers PDF export".
|
||||||
@@ -266,6 +313,117 @@ class MemoryUpdater:
|
|||||||
model_name = self._model_name or config.model_name
|
model_name = self._model_name or config.model_name
|
||||||
return create_chat_model(name=model_name, thinking_enabled=False)
|
return create_chat_model(name=model_name, thinking_enabled=False)
|
||||||
|
|
||||||
|
def _build_correction_hint(
|
||||||
|
self,
|
||||||
|
correction_detected: bool,
|
||||||
|
reinforcement_detected: bool,
|
||||||
|
) -> str:
|
||||||
|
"""Build optional prompt hints for correction and reinforcement signals."""
|
||||||
|
correction_hint = ""
|
||||||
|
if correction_detected:
|
||||||
|
correction_hint = (
|
||||||
|
"IMPORTANT: Explicit correction signals were detected in this conversation. "
|
||||||
|
"Pay special attention to what the agent got wrong, what the user corrected, "
|
||||||
|
"and record the correct approach as a fact with category "
|
||||||
|
'"correction" and confidence >= 0.95 when appropriate.'
|
||||||
|
)
|
||||||
|
if reinforcement_detected:
|
||||||
|
reinforcement_hint = (
|
||||||
|
"IMPORTANT: Positive reinforcement signals were detected in this conversation. "
|
||||||
|
"The user explicitly confirmed the agent's approach was correct or helpful. "
|
||||||
|
"Record the confirmed approach, style, or preference as a fact with category "
|
||||||
|
'"preference" or "behavior" and confidence >= 0.9 when appropriate.'
|
||||||
|
)
|
||||||
|
correction_hint = (correction_hint + "\n" + reinforcement_hint).strip() if correction_hint else reinforcement_hint
|
||||||
|
|
||||||
|
return correction_hint
|
||||||
|
|
||||||
|
def _prepare_update_prompt(
|
||||||
|
self,
|
||||||
|
messages: list[Any],
|
||||||
|
agent_name: str | None,
|
||||||
|
correction_detected: bool,
|
||||||
|
reinforcement_detected: bool,
|
||||||
|
) -> tuple[dict[str, Any], str] | None:
|
||||||
|
"""Load memory and build the update prompt for a conversation."""
|
||||||
|
config = get_memory_config()
|
||||||
|
if not config.enabled or not messages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_memory = get_memory_data(agent_name)
|
||||||
|
conversation_text = format_conversation_for_update(messages)
|
||||||
|
if not conversation_text.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
correction_hint = self._build_correction_hint(
|
||||||
|
correction_detected=correction_detected,
|
||||||
|
reinforcement_detected=reinforcement_detected,
|
||||||
|
)
|
||||||
|
prompt = MEMORY_UPDATE_PROMPT.format(
|
||||||
|
current_memory=json.dumps(current_memory, indent=2),
|
||||||
|
conversation=conversation_text,
|
||||||
|
correction_hint=correction_hint,
|
||||||
|
)
|
||||||
|
return current_memory, prompt
|
||||||
|
|
||||||
|
def _finalize_update(
|
||||||
|
self,
|
||||||
|
current_memory: dict[str, Any],
|
||||||
|
response_content: Any,
|
||||||
|
thread_id: str | None,
|
||||||
|
agent_name: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Parse the model response, apply updates, and persist memory."""
|
||||||
|
response_text = _extract_text(response_content).strip()
|
||||||
|
|
||||||
|
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)
|
||||||
|
# Deep-copy before in-place mutation so a subsequent save() failure
|
||||||
|
# cannot corrupt the still-cached original object reference.
|
||||||
|
updated_memory = self._apply_updates(copy.deepcopy(current_memory), update_data, thread_id)
|
||||||
|
updated_memory = _strip_upload_mentions_from_memory(updated_memory)
|
||||||
|
return get_memory_storage().save(updated_memory, agent_name)
|
||||||
|
|
||||||
|
async def aupdate_memory(
|
||||||
|
self,
|
||||||
|
messages: list[Any],
|
||||||
|
thread_id: str | None = None,
|
||||||
|
agent_name: str | None = None,
|
||||||
|
correction_detected: bool = False,
|
||||||
|
reinforcement_detected: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Update memory asynchronously based on conversation messages."""
|
||||||
|
try:
|
||||||
|
prepared = await asyncio.to_thread(
|
||||||
|
self._prepare_update_prompt,
|
||||||
|
messages=messages,
|
||||||
|
agent_name=agent_name,
|
||||||
|
correction_detected=correction_detected,
|
||||||
|
reinforcement_detected=reinforcement_detected,
|
||||||
|
)
|
||||||
|
if prepared is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_memory, prompt = prepared
|
||||||
|
model = self._get_model()
|
||||||
|
response = await model.ainvoke(prompt, config={"run_name": "memory_agent"})
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self._finalize_update,
|
||||||
|
current_memory=current_memory,
|
||||||
|
response_content=response.content,
|
||||||
|
thread_id=thread_id,
|
||||||
|
agent_name=agent_name,
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning("Failed to parse LLM response for memory update: %s", e)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Memory update failed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
def update_memory(
|
def update_memory(
|
||||||
self,
|
self,
|
||||||
messages: list[Any],
|
messages: list[Any],
|
||||||
@@ -274,7 +432,7 @@ class MemoryUpdater:
|
|||||||
correction_detected: bool = False,
|
correction_detected: bool = False,
|
||||||
reinforcement_detected: bool = False,
|
reinforcement_detected: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Update memory based on conversation messages.
|
"""Synchronously update memory via the async updater path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: List of conversation messages.
|
messages: List of conversation messages.
|
||||||
@@ -286,78 +444,15 @@ class MemoryUpdater:
|
|||||||
Returns:
|
Returns:
|
||||||
True if update was successful, False otherwise.
|
True if update was successful, False otherwise.
|
||||||
"""
|
"""
|
||||||
config = get_memory_config()
|
return _run_async_update_sync(
|
||||||
if not config.enabled:
|
self.aupdate_memory(
|
||||||
return False
|
messages=messages,
|
||||||
|
thread_id=thread_id,
|
||||||
if not messages:
|
agent_name=agent_name,
|
||||||
return False
|
correction_detected=correction_detected,
|
||||||
|
reinforcement_detected=reinforcement_detected,
|
||||||
try:
|
|
||||||
# Get current memory
|
|
||||||
current_memory = get_memory_data(agent_name)
|
|
||||||
|
|
||||||
# Format conversation for prompt
|
|
||||||
conversation_text = format_conversation_for_update(messages)
|
|
||||||
|
|
||||||
if not conversation_text.strip():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Build prompt
|
|
||||||
correction_hint = ""
|
|
||||||
if correction_detected:
|
|
||||||
correction_hint = (
|
|
||||||
"IMPORTANT: Explicit correction signals were detected in this conversation. "
|
|
||||||
"Pay special attention to what the agent got wrong, what the user corrected, "
|
|
||||||
"and record the correct approach as a fact with category "
|
|
||||||
'"correction" and confidence >= 0.95 when appropriate.'
|
|
||||||
)
|
|
||||||
if reinforcement_detected:
|
|
||||||
reinforcement_hint = (
|
|
||||||
"IMPORTANT: Positive reinforcement signals were detected in this conversation. "
|
|
||||||
"The user explicitly confirmed the agent's approach was correct or helpful. "
|
|
||||||
"Record the confirmed approach, style, or preference as a fact with category "
|
|
||||||
'"preference" or "behavior" and confidence >= 0.9 when appropriate.'
|
|
||||||
)
|
|
||||||
correction_hint = (correction_hint + "\n" + reinforcement_hint).strip() if correction_hint else reinforcement_hint
|
|
||||||
|
|
||||||
prompt = MEMORY_UPDATE_PROMPT.format(
|
|
||||||
current_memory=json.dumps(current_memory, indent=2),
|
|
||||||
conversation=conversation_text,
|
|
||||||
correction_hint=correction_hint,
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
# Call LLM
|
|
||||||
model = self._get_model()
|
|
||||||
response = model.invoke(prompt)
|
|
||||||
response_text = _extract_text(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)
|
|
||||||
|
|
||||||
# Strip file-upload mentions from all summaries before saving.
|
|
||||||
# Uploaded files are session-scoped and won't exist in future sessions,
|
|
||||||
# so recording upload events in long-term memory causes the agent to
|
|
||||||
# try (and fail) to locate those files in subsequent conversations.
|
|
||||||
updated_memory = _strip_upload_mentions_from_memory(updated_memory)
|
|
||||||
|
|
||||||
# Save
|
|
||||||
return get_memory_storage().save(updated_memory, agent_name)
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.warning("Failed to parse LLM response for memory update: %s", e)
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Memory update failed: %s", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _apply_updates(
|
def _apply_updates(
|
||||||
self,
|
self,
|
||||||
@@ -376,7 +471,7 @@ class MemoryUpdater:
|
|||||||
Updated memory data.
|
Updated memory data.
|
||||||
"""
|
"""
|
||||||
config = get_memory_config()
|
config = get_memory_config()
|
||||||
now = datetime.utcnow().isoformat() + "Z"
|
now = utc_now_iso_z()
|
||||||
|
|
||||||
# Update user sections
|
# Update user sections
|
||||||
user_updates = update_data.get("user", {})
|
user_updates = update_data.get("user", {})
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Middleware for intercepting clarification requests and presenting them to the user."""
|
"""Middleware for intercepting clarification requests and presenting them to the user."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from hashlib import sha256
|
||||||
from typing import override
|
from typing import override
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
@@ -35,6 +37,13 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||||||
|
|
||||||
state_schema = ClarificationMiddlewareState
|
state_schema = ClarificationMiddlewareState
|
||||||
|
|
||||||
|
def _stable_message_id(self, tool_call_id: str, formatted_message: str) -> str:
|
||||||
|
"""Build a deterministic message ID so retried clarification calls replace, not append."""
|
||||||
|
if tool_call_id:
|
||||||
|
return f"clarification:{tool_call_id}"
|
||||||
|
digest = sha256(formatted_message.encode("utf-8")).hexdigest()[:16]
|
||||||
|
return f"clarification:{digest}"
|
||||||
|
|
||||||
def _is_chinese(self, text: str) -> bool:
|
def _is_chinese(self, text: str) -> bool:
|
||||||
"""Check if text contains Chinese characters.
|
"""Check if text contains Chinese characters.
|
||||||
|
|
||||||
@@ -60,6 +69,20 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||||||
context = args.get("context")
|
context = args.get("context")
|
||||||
options = args.get("options", [])
|
options = args.get("options", [])
|
||||||
|
|
||||||
|
# Some models (e.g. Qwen3-Max) serialize array parameters as JSON strings
|
||||||
|
# instead of native arrays. Deserialize and normalize so `options`
|
||||||
|
# is always a list for the rendering logic below.
|
||||||
|
if isinstance(options, str):
|
||||||
|
try:
|
||||||
|
options = json.loads(options)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
options = [options]
|
||||||
|
|
||||||
|
if options is None:
|
||||||
|
options = []
|
||||||
|
elif not isinstance(options, list):
|
||||||
|
options = [options]
|
||||||
|
|
||||||
# Type-specific icons
|
# Type-specific icons
|
||||||
type_icons = {
|
type_icons = {
|
||||||
"missing_info": "❓",
|
"missing_info": "❓",
|
||||||
@@ -116,6 +139,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||||||
# Create a ToolMessage with the formatted question
|
# Create a ToolMessage with the formatted question
|
||||||
# This will be added to the message history
|
# This will be added to the message history
|
||||||
tool_message = ToolMessage(
|
tool_message = ToolMessage(
|
||||||
|
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||||
content=formatted_message,
|
content=formatted_message,
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
name="ask_clarification",
|
name="ask_clarification",
|
||||||
|
|||||||
+41
-2
@@ -13,6 +13,7 @@ at the correct positions (immediately after each dangling AIMessage), not append
|
|||||||
to the end of the message list as before_model + add_messages reducer would do.
|
to the end of the message list as before_model + add_messages reducer would do.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import override
|
from typing import override
|
||||||
@@ -33,6 +34,44 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
|||||||
offending AIMessage so the LLM receives a well-formed conversation.
|
offending AIMessage so the LLM receives a well-formed conversation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _message_tool_calls(msg) -> list[dict]:
|
||||||
|
"""Return normalized tool calls from structured fields or raw provider payloads."""
|
||||||
|
tool_calls = getattr(msg, "tool_calls", None) or []
|
||||||
|
if tool_calls:
|
||||||
|
return list(tool_calls)
|
||||||
|
|
||||||
|
raw_tool_calls = (getattr(msg, "additional_kwargs", None) or {}).get("tool_calls") or []
|
||||||
|
normalized: list[dict] = []
|
||||||
|
for raw_tc in raw_tool_calls:
|
||||||
|
if not isinstance(raw_tc, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
function = raw_tc.get("function")
|
||||||
|
name = raw_tc.get("name")
|
||||||
|
if not name and isinstance(function, dict):
|
||||||
|
name = function.get("name")
|
||||||
|
|
||||||
|
args = raw_tc.get("args", {})
|
||||||
|
if not args and isinstance(function, dict):
|
||||||
|
raw_args = function.get("arguments")
|
||||||
|
if isinstance(raw_args, str):
|
||||||
|
try:
|
||||||
|
parsed_args = json.loads(raw_args)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
parsed_args = {}
|
||||||
|
args = parsed_args if isinstance(parsed_args, dict) else {}
|
||||||
|
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"id": raw_tc.get("id"),
|
||||||
|
"name": name or "unknown",
|
||||||
|
"args": args if isinstance(args, dict) else {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
def _build_patched_messages(self, messages: list) -> list | None:
|
def _build_patched_messages(self, messages: list) -> list | None:
|
||||||
"""Return a new message list with patches inserted at the correct positions.
|
"""Return a new message list with patches inserted at the correct positions.
|
||||||
|
|
||||||
@@ -51,7 +90,7 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
|||||||
for msg in messages:
|
for msg in messages:
|
||||||
if getattr(msg, "type", None) != "ai":
|
if getattr(msg, "type", None) != "ai":
|
||||||
continue
|
continue
|
||||||
for tc in getattr(msg, "tool_calls", None) or []:
|
for tc in self._message_tool_calls(msg):
|
||||||
tc_id = tc.get("id")
|
tc_id = tc.get("id")
|
||||||
if tc_id and tc_id not in existing_tool_msg_ids:
|
if tc_id and tc_id not in existing_tool_msg_ids:
|
||||||
needs_patch = True
|
needs_patch = True
|
||||||
@@ -70,7 +109,7 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
|||||||
patched.append(msg)
|
patched.append(msg)
|
||||||
if getattr(msg, "type", None) != "ai":
|
if getattr(msg, "type", None) != "ai":
|
||||||
continue
|
continue
|
||||||
for tc in getattr(msg, "tool_calls", None) or []:
|
for tc in self._message_tool_calls(msg):
|
||||||
tc_id = tc.get("id")
|
tc_id = tc.get("id")
|
||||||
if tc_id and tc_id not in existing_tool_msg_ids and tc_id not in patched_ids:
|
if tc_id and tc_id not in existing_tool_msg_ids and tc_id not in patched_ids:
|
||||||
patched.append(
|
patched.append(
|
||||||
|
|||||||
+48
-1
@@ -16,6 +16,9 @@ from typing import override
|
|||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,7 +38,7 @@ class DeferredToolFilterMiddleware(AgentMiddleware[AgentState]):
|
|||||||
if not registry:
|
if not registry:
|
||||||
return request
|
return request
|
||||||
|
|
||||||
deferred_names = {e.name for e in registry.entries}
|
deferred_names = registry.deferred_names
|
||||||
active_tools = [t for t in request.tools if getattr(t, "name", None) not in deferred_names]
|
active_tools = [t for t in request.tools if getattr(t, "name", None) not in deferred_names]
|
||||||
|
|
||||||
if len(active_tools) < len(request.tools):
|
if len(active_tools) < len(request.tools):
|
||||||
@@ -43,6 +46,28 @@ class DeferredToolFilterMiddleware(AgentMiddleware[AgentState]):
|
|||||||
|
|
||||||
return request.override(tools=active_tools)
|
return request.override(tools=active_tools)
|
||||||
|
|
||||||
|
def _blocked_tool_message(self, request: ToolCallRequest) -> ToolMessage | None:
|
||||||
|
from deerflow.tools.builtins.tool_search import get_deferred_registry
|
||||||
|
|
||||||
|
registry = get_deferred_registry()
|
||||||
|
if not registry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tool_name = str(request.tool_call.get("name") or "")
|
||||||
|
if not tool_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not registry.contains(tool_name):
|
||||||
|
return None
|
||||||
|
|
||||||
|
tool_call_id = str(request.tool_call.get("id") or "missing_tool_call_id")
|
||||||
|
return ToolMessage(
|
||||||
|
content=(f"Error: Tool '{tool_name}' is deferred and has not been promoted yet. Call tool_search first to expose and promote this tool's schema, then retry."),
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
name=tool_name,
|
||||||
|
status="error",
|
||||||
|
)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def wrap_model_call(
|
def wrap_model_call(
|
||||||
self,
|
self,
|
||||||
@@ -51,6 +76,17 @@ class DeferredToolFilterMiddleware(AgentMiddleware[AgentState]):
|
|||||||
) -> ModelCallResult:
|
) -> ModelCallResult:
|
||||||
return handler(self._filter_tools(request))
|
return handler(self._filter_tools(request))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def wrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||||
|
) -> ToolMessage | Command:
|
||||||
|
blocked = self._blocked_tool_message(request)
|
||||||
|
if blocked is not None:
|
||||||
|
return blocked
|
||||||
|
return handler(request)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def awrap_model_call(
|
async def awrap_model_call(
|
||||||
self,
|
self,
|
||||||
@@ -58,3 +94,14 @@ class DeferredToolFilterMiddleware(AgentMiddleware[AgentState]):
|
|||||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
) -> ModelCallResult:
|
) -> ModelCallResult:
|
||||||
return await handler(self._filter_tools(request))
|
return await handler(self._filter_tools(request))
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def awrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||||
|
) -> ToolMessage | Command:
|
||||||
|
blocked = self._blocked_tool_message(request)
|
||||||
|
if blocked is not None:
|
||||||
|
return blocked
|
||||||
|
return await handler(request)
|
||||||
|
|||||||
+104
-2
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from email.utils import parsedate_to_datetime
|
from email.utils import parsedate_to_datetime
|
||||||
@@ -19,6 +20,8 @@ from langchain.agents.middleware.types import (
|
|||||||
from langchain_core.messages import AIMessage
|
from langchain_core.messages import AIMessage
|
||||||
from langgraph.errors import GraphBubbleUp
|
from langgraph.errors import GraphBubbleUp
|
||||||
|
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_RETRIABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
|
_RETRIABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
|
||||||
@@ -67,6 +70,80 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
retry_base_delay_ms: int = 1000
|
retry_base_delay_ms: int = 1000
|
||||||
retry_cap_delay_ms: int = 8000
|
retry_cap_delay_ms: int = 8000
|
||||||
|
|
||||||
|
circuit_failure_threshold: int = 5
|
||||||
|
circuit_recovery_timeout_sec: int = 60
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
# Load Circuit Breaker configs from app config if available, fall back to defaults
|
||||||
|
try:
|
||||||
|
app_config = get_app_config()
|
||||||
|
self.circuit_failure_threshold = app_config.circuit_breaker.failure_threshold
|
||||||
|
self.circuit_recovery_timeout_sec = app_config.circuit_breaker.recovery_timeout_sec
|
||||||
|
except (FileNotFoundError, RuntimeError):
|
||||||
|
# Gracefully fall back to class defaults in test environments
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Circuit Breaker state
|
||||||
|
self._circuit_lock = threading.Lock()
|
||||||
|
self._circuit_failure_count = 0
|
||||||
|
self._circuit_open_until = 0.0
|
||||||
|
self._circuit_state = "closed"
|
||||||
|
self._circuit_probe_in_flight = False
|
||||||
|
|
||||||
|
def _check_circuit(self) -> bool:
|
||||||
|
"""Returns True if circuit is OPEN (fast fail), False otherwise."""
|
||||||
|
with self._circuit_lock:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if self._circuit_state == "open":
|
||||||
|
if now < self._circuit_open_until:
|
||||||
|
return True
|
||||||
|
self._circuit_state = "half_open"
|
||||||
|
self._circuit_probe_in_flight = False
|
||||||
|
|
||||||
|
if self._circuit_state == "half_open":
|
||||||
|
if self._circuit_probe_in_flight:
|
||||||
|
return True
|
||||||
|
self._circuit_probe_in_flight = True
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _record_success(self) -> None:
|
||||||
|
with self._circuit_lock:
|
||||||
|
if self._circuit_state != "closed" or self._circuit_failure_count > 0:
|
||||||
|
logger.info("Circuit breaker reset (Closed). LLM service recovered.")
|
||||||
|
self._circuit_failure_count = 0
|
||||||
|
self._circuit_open_until = 0.0
|
||||||
|
self._circuit_state = "closed"
|
||||||
|
self._circuit_probe_in_flight = False
|
||||||
|
|
||||||
|
def _record_failure(self) -> None:
|
||||||
|
with self._circuit_lock:
|
||||||
|
if self._circuit_state == "half_open":
|
||||||
|
self._circuit_open_until = time.time() + self.circuit_recovery_timeout_sec
|
||||||
|
self._circuit_state = "open"
|
||||||
|
self._circuit_probe_in_flight = False
|
||||||
|
logger.error(
|
||||||
|
"Circuit breaker probe failed (Open). Will probe again after %ds.",
|
||||||
|
self.circuit_recovery_timeout_sec,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._circuit_failure_count += 1
|
||||||
|
if self._circuit_failure_count >= self.circuit_failure_threshold:
|
||||||
|
self._circuit_open_until = time.time() + self.circuit_recovery_timeout_sec
|
||||||
|
if self._circuit_state != "open":
|
||||||
|
self._circuit_state = "open"
|
||||||
|
self._circuit_probe_in_flight = False
|
||||||
|
logger.error(
|
||||||
|
"Circuit breaker tripped (Open). Threshold reached (%d). Will probe after %ds.",
|
||||||
|
self.circuit_failure_threshold,
|
||||||
|
self.circuit_recovery_timeout_sec,
|
||||||
|
)
|
||||||
|
|
||||||
def _classify_error(self, exc: BaseException) -> tuple[bool, str]:
|
def _classify_error(self, exc: BaseException) -> tuple[bool, str]:
|
||||||
detail = _extract_error_detail(exc)
|
detail = _extract_error_detail(exc)
|
||||||
lowered = detail.lower()
|
lowered = detail.lower()
|
||||||
@@ -83,6 +160,8 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
"APITimeoutError",
|
"APITimeoutError",
|
||||||
"APIConnectionError",
|
"APIConnectionError",
|
||||||
"InternalServerError",
|
"InternalServerError",
|
||||||
|
"ReadError", # httpx.ReadError: connection dropped mid-stream
|
||||||
|
"RemoteProtocolError", # httpx: server closed connection unexpectedly
|
||||||
}:
|
}:
|
||||||
return True, "transient"
|
return True, "transient"
|
||||||
if status_code in _RETRIABLE_STATUS_CODES:
|
if status_code in _RETRIABLE_STATUS_CODES:
|
||||||
@@ -104,6 +183,9 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
reason_text = "provider is busy" if reason == "busy" else "provider request failed temporarily"
|
reason_text = "provider is busy" if reason == "busy" else "provider request failed temporarily"
|
||||||
return f"LLM request retry {attempt}/{self.retry_max_attempts}: {reason_text}. Retrying in {seconds}s."
|
return f"LLM request retry {attempt}/{self.retry_max_attempts}: {reason_text}. Retrying in {seconds}s."
|
||||||
|
|
||||||
|
def _build_circuit_breaker_message(self) -> str:
|
||||||
|
return "The configured LLM provider is currently unavailable due to continuous failures. Circuit breaker is engaged to protect the system. Please wait a moment before trying again."
|
||||||
|
|
||||||
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
||||||
detail = _extract_error_detail(exc)
|
detail = _extract_error_detail(exc)
|
||||||
if reason == "quota":
|
if reason == "quota":
|
||||||
@@ -138,12 +220,20 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
request: ModelRequest,
|
request: ModelRequest,
|
||||||
handler: Callable[[ModelRequest], ModelResponse],
|
handler: Callable[[ModelRequest], ModelResponse],
|
||||||
) -> ModelCallResult:
|
) -> ModelCallResult:
|
||||||
|
if self._check_circuit():
|
||||||
|
return AIMessage(content=self._build_circuit_breaker_message())
|
||||||
|
|
||||||
attempt = 1
|
attempt = 1
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
return handler(request)
|
response = handler(request)
|
||||||
|
self._record_success()
|
||||||
|
return response
|
||||||
except GraphBubbleUp:
|
except GraphBubbleUp:
|
||||||
# Preserve LangGraph control-flow signals (interrupt/pause/resume).
|
# Preserve LangGraph control-flow signals (interrupt/pause/resume).
|
||||||
|
with self._circuit_lock:
|
||||||
|
if self._circuit_state == "half_open":
|
||||||
|
self._circuit_probe_in_flight = False
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
retriable, reason = self._classify_error(exc)
|
retriable, reason = self._classify_error(exc)
|
||||||
@@ -166,6 +256,8 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
_extract_error_detail(exc),
|
_extract_error_detail(exc),
|
||||||
exc_info=exc,
|
exc_info=exc,
|
||||||
)
|
)
|
||||||
|
if retriable:
|
||||||
|
self._record_failure()
|
||||||
return AIMessage(content=self._build_user_message(exc, reason))
|
return AIMessage(content=self._build_user_message(exc, reason))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -174,12 +266,20 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
request: ModelRequest,
|
request: ModelRequest,
|
||||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
) -> ModelCallResult:
|
) -> ModelCallResult:
|
||||||
|
if self._check_circuit():
|
||||||
|
return AIMessage(content=self._build_circuit_breaker_message())
|
||||||
|
|
||||||
attempt = 1
|
attempt = 1
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
return await handler(request)
|
response = await handler(request)
|
||||||
|
self._record_success()
|
||||||
|
return response
|
||||||
except GraphBubbleUp:
|
except GraphBubbleUp:
|
||||||
# Preserve LangGraph control-flow signals (interrupt/pause/resume).
|
# Preserve LangGraph control-flow signals (interrupt/pause/resume).
|
||||||
|
with self._circuit_lock:
|
||||||
|
if self._circuit_state == "half_open":
|
||||||
|
self._circuit_probe_in_flight = False
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
retriable, reason = self._classify_error(exc)
|
retriable, reason = self._classify_error(exc)
|
||||||
@@ -202,6 +302,8 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
_extract_error_detail(exc),
|
_extract_error_detail(exc),
|
||||||
exc_info=exc,
|
exc_info=exc,
|
||||||
)
|
)
|
||||||
|
if retriable:
|
||||||
|
self._record_failure()
|
||||||
return AIMessage(content=self._build_user_message(exc, reason))
|
return AIMessage(content=self._build_user_message(exc, reason))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+173
-29
@@ -17,6 +17,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from collections import OrderedDict, defaultdict
|
from collections import OrderedDict, defaultdict
|
||||||
|
from copy import deepcopy
|
||||||
from typing import override
|
from typing import override
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
@@ -24,6 +25,8 @@ from langchain.agents.middleware import AgentMiddleware
|
|||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
|
from deerflow.utils.runtime import get_thread_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Defaults — can be overridden via constructor
|
# Defaults — can be overridden via constructor
|
||||||
@@ -31,40 +34,110 @@ _DEFAULT_WARN_THRESHOLD = 3 # inject warning after 3 identical calls
|
|||||||
_DEFAULT_HARD_LIMIT = 5 # force-stop after 5 identical calls
|
_DEFAULT_HARD_LIMIT = 5 # force-stop after 5 identical calls
|
||||||
_DEFAULT_WINDOW_SIZE = 20 # track last N tool calls
|
_DEFAULT_WINDOW_SIZE = 20 # track last N tool calls
|
||||||
_DEFAULT_MAX_TRACKED_THREADS = 100 # LRU eviction limit
|
_DEFAULT_MAX_TRACKED_THREADS = 100 # LRU eviction limit
|
||||||
|
_DEFAULT_TOOL_FREQ_WARN = 30 # warn after 30 calls to the same tool type
|
||||||
|
_DEFAULT_TOOL_FREQ_HARD_LIMIT = 50 # force-stop after 50 calls to the same tool type
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tool_call_args(raw_args: object) -> tuple[dict, str | None]:
|
||||||
|
"""Normalize tool call args to a dict plus an optional fallback key.
|
||||||
|
|
||||||
|
Some providers serialize ``args`` as a JSON string instead of a dict.
|
||||||
|
We defensively parse those cases so loop detection does not crash while
|
||||||
|
still preserving a stable fallback key for non-dict payloads.
|
||||||
|
"""
|
||||||
|
if isinstance(raw_args, dict):
|
||||||
|
return raw_args, None
|
||||||
|
|
||||||
|
if isinstance(raw_args, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_args)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
return {}, raw_args
|
||||||
|
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed, None
|
||||||
|
return {}, json.dumps(parsed, sort_keys=True, default=str)
|
||||||
|
|
||||||
|
if raw_args is None:
|
||||||
|
return {}, None
|
||||||
|
|
||||||
|
return {}, json.dumps(raw_args, sort_keys=True, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_tool_key(name: str, args: dict, fallback_key: str | None) -> str:
|
||||||
|
"""Derive a stable key from salient args without overfitting to noise."""
|
||||||
|
if name == "read_file" and fallback_key is None:
|
||||||
|
path = args.get("path") or ""
|
||||||
|
start_line = args.get("start_line")
|
||||||
|
end_line = args.get("end_line")
|
||||||
|
|
||||||
|
bucket_size = 200
|
||||||
|
try:
|
||||||
|
start_line = int(start_line) if start_line is not None else 1
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
start_line = 1
|
||||||
|
try:
|
||||||
|
end_line = int(end_line) if end_line is not None else start_line
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
end_line = start_line
|
||||||
|
|
||||||
|
start_line, end_line = sorted((start_line, end_line))
|
||||||
|
bucket_start = max(start_line, 1)
|
||||||
|
bucket_end = max(end_line, 1)
|
||||||
|
bucket_start = (bucket_start - 1) // bucket_size
|
||||||
|
bucket_end = (bucket_end - 1) // bucket_size
|
||||||
|
return f"{path}:{bucket_start}-{bucket_end}"
|
||||||
|
|
||||||
|
# write_file / str_replace are content-sensitive: same path may be updated
|
||||||
|
# with different payloads during iteration. Using only salient fields (path)
|
||||||
|
# can collapse distinct calls, so we hash full args to reduce false positives.
|
||||||
|
if name in {"write_file", "str_replace"}:
|
||||||
|
if fallback_key is not None:
|
||||||
|
return fallback_key
|
||||||
|
return json.dumps(args, sort_keys=True, default=str)
|
||||||
|
|
||||||
|
salient_fields = ("path", "url", "query", "command", "pattern", "glob", "cmd")
|
||||||
|
stable_args = {field: args[field] for field in salient_fields if args.get(field) is not None}
|
||||||
|
if stable_args:
|
||||||
|
return json.dumps(stable_args, sort_keys=True, default=str)
|
||||||
|
|
||||||
|
if fallback_key is not None:
|
||||||
|
return fallback_key
|
||||||
|
|
||||||
|
return json.dumps(args, sort_keys=True, default=str)
|
||||||
|
|
||||||
|
|
||||||
def _hash_tool_calls(tool_calls: list[dict]) -> str:
|
def _hash_tool_calls(tool_calls: list[dict]) -> str:
|
||||||
"""Deterministic hash of a set of tool calls (name + args).
|
"""Deterministic hash of a set of tool calls (name + stable key).
|
||||||
|
|
||||||
This is intended to be order-independent: the same multiset of tool calls
|
This is intended to be order-independent: the same multiset of tool calls
|
||||||
should always produce the same hash, regardless of their input order.
|
should always produce the same hash, regardless of their input order.
|
||||||
"""
|
"""
|
||||||
# First normalize each tool call to a minimal (name, args) structure.
|
# Normalize each tool call to a stable (name, key) structure.
|
||||||
normalized: list[dict] = []
|
normalized: list[str] = []
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
normalized.append(
|
name = tc.get("name", "")
|
||||||
{
|
args, fallback_key = _normalize_tool_call_args(tc.get("args", {}))
|
||||||
"name": tc.get("name", ""),
|
key = _stable_tool_key(name, args, fallback_key)
|
||||||
"args": tc.get("args", {}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort by both name and a deterministic serialization of args so that
|
normalized.append(f"{name}:{key}")
|
||||||
# permutations of the same multiset of calls yield the same ordering.
|
|
||||||
normalized.sort(
|
# Sort so permutations of the same multiset of calls yield the same ordering.
|
||||||
key=lambda tc: (
|
normalized.sort()
|
||||||
tc["name"],
|
|
||||||
json.dumps(tc["args"], sort_keys=True, default=str),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
blob = json.dumps(normalized, sort_keys=True, default=str)
|
blob = json.dumps(normalized, sort_keys=True, default=str)
|
||||||
return hashlib.md5(blob.encode()).hexdigest()[:12]
|
return hashlib.md5(blob.encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
_WARNING_MSG = "[LOOP DETECTED] You are repeating the same tool calls. Stop calling tools and produce your final answer now. If you cannot complete the task, summarize what you accomplished so far."
|
_WARNING_MSG = "[LOOP DETECTED] You are repeating the same tool calls. Stop calling tools and produce your final answer now. If you cannot complete the task, summarize what you accomplished so far."
|
||||||
|
|
||||||
|
_TOOL_FREQ_WARNING_MSG = (
|
||||||
|
"[LOOP DETECTED] You have called {tool_name} {count} times without producing a final answer. Stop calling tools and produce your final answer now. If you cannot complete the task, summarize what you accomplished so far."
|
||||||
|
)
|
||||||
|
|
||||||
_HARD_STOP_MSG = "[FORCED STOP] Repeated tool calls exceeded the safety limit. Producing final answer with results collected so far."
|
_HARD_STOP_MSG = "[FORCED STOP] Repeated tool calls exceeded the safety limit. Producing final answer with results collected so far."
|
||||||
|
|
||||||
|
_TOOL_FREQ_HARD_STOP_MSG = "[FORCED STOP] Tool {tool_name} called {count} times — exceeded the per-tool safety limit. Producing final answer with results collected so far."
|
||||||
|
|
||||||
|
|
||||||
class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
||||||
"""Detects and breaks repetitive tool call loops.
|
"""Detects and breaks repetitive tool call loops.
|
||||||
@@ -78,6 +151,12 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
Default: 20.
|
Default: 20.
|
||||||
max_tracked_threads: Maximum number of threads to track before
|
max_tracked_threads: Maximum number of threads to track before
|
||||||
evicting the least recently used. Default: 100.
|
evicting the least recently used. Default: 100.
|
||||||
|
tool_freq_warn: Number of calls to the same tool *type* (regardless
|
||||||
|
of arguments) before injecting a frequency warning. Catches
|
||||||
|
cross-file read loops that hash-based detection misses.
|
||||||
|
Default: 30.
|
||||||
|
tool_freq_hard_limit: Number of calls to the same tool type before
|
||||||
|
forcing a stop. Default: 50.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -86,23 +165,27 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
hard_limit: int = _DEFAULT_HARD_LIMIT,
|
hard_limit: int = _DEFAULT_HARD_LIMIT,
|
||||||
window_size: int = _DEFAULT_WINDOW_SIZE,
|
window_size: int = _DEFAULT_WINDOW_SIZE,
|
||||||
max_tracked_threads: int = _DEFAULT_MAX_TRACKED_THREADS,
|
max_tracked_threads: int = _DEFAULT_MAX_TRACKED_THREADS,
|
||||||
|
tool_freq_warn: int = _DEFAULT_TOOL_FREQ_WARN,
|
||||||
|
tool_freq_hard_limit: int = _DEFAULT_TOOL_FREQ_HARD_LIMIT,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.warn_threshold = warn_threshold
|
self.warn_threshold = warn_threshold
|
||||||
self.hard_limit = hard_limit
|
self.hard_limit = hard_limit
|
||||||
self.window_size = window_size
|
self.window_size = window_size
|
||||||
self.max_tracked_threads = max_tracked_threads
|
self.max_tracked_threads = max_tracked_threads
|
||||||
|
self.tool_freq_warn = tool_freq_warn
|
||||||
|
self.tool_freq_hard_limit = tool_freq_hard_limit
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
# Per-thread tracking using OrderedDict for LRU eviction
|
# Per-thread tracking using OrderedDict for LRU eviction
|
||||||
self._history: OrderedDict[str, list[str]] = OrderedDict()
|
self._history: OrderedDict[str, list[str]] = OrderedDict()
|
||||||
self._warned: dict[str, set[str]] = defaultdict(set)
|
self._warned: dict[str, set[str]] = defaultdict(set)
|
||||||
|
# Per-thread, per-tool-type cumulative call counts
|
||||||
|
self._tool_freq: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||||
|
self._tool_freq_warned: dict[str, set[str]] = defaultdict(set)
|
||||||
|
|
||||||
def _get_thread_id(self, runtime: Runtime) -> str:
|
def _get_thread_id(self, runtime: Runtime) -> str:
|
||||||
"""Extract thread_id from runtime context for per-thread tracking."""
|
"""Extract thread_id from runtime context for per-thread tracking."""
|
||||||
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
return get_thread_id(runtime) or "default"
|
||||||
if thread_id:
|
|
||||||
return thread_id
|
|
||||||
return "default"
|
|
||||||
|
|
||||||
def _evict_if_needed(self) -> None:
|
def _evict_if_needed(self) -> None:
|
||||||
"""Evict least recently used threads if over the limit.
|
"""Evict least recently used threads if over the limit.
|
||||||
@@ -112,11 +195,19 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
while len(self._history) > self.max_tracked_threads:
|
while len(self._history) > self.max_tracked_threads:
|
||||||
evicted_id, _ = self._history.popitem(last=False)
|
evicted_id, _ = self._history.popitem(last=False)
|
||||||
self._warned.pop(evicted_id, None)
|
self._warned.pop(evicted_id, None)
|
||||||
|
self._tool_freq.pop(evicted_id, None)
|
||||||
|
self._tool_freq_warned.pop(evicted_id, None)
|
||||||
logger.debug("Evicted loop tracking for thread %s (LRU)", evicted_id)
|
logger.debug("Evicted loop tracking for thread %s (LRU)", evicted_id)
|
||||||
|
|
||||||
def _track_and_check(self, state: AgentState, runtime: Runtime) -> tuple[str | None, bool]:
|
def _track_and_check(self, state: AgentState, runtime: Runtime) -> tuple[str | None, bool]:
|
||||||
"""Track tool calls and check for loops.
|
"""Track tool calls and check for loops.
|
||||||
|
|
||||||
|
Two detection layers:
|
||||||
|
1. **Hash-based** (existing): catches identical tool call sets.
|
||||||
|
2. **Frequency-based** (new): catches the same *tool type* being
|
||||||
|
called many times with varying arguments (e.g. ``read_file``
|
||||||
|
on 40 different files).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(warning_message_or_none, should_hard_stop)
|
(warning_message_or_none, should_hard_stop)
|
||||||
"""
|
"""
|
||||||
@@ -151,6 +242,7 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
count = history.count(call_hash)
|
count = history.count(call_hash)
|
||||||
tool_names = [tc.get("name", "?") for tc in tool_calls]
|
tool_names = [tc.get("name", "?") for tc in tool_calls]
|
||||||
|
|
||||||
|
# --- Layer 1: hash-based (identical call sets) ---
|
||||||
if count >= self.hard_limit:
|
if count >= self.hard_limit:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Loop hard limit reached — forcing stop",
|
"Loop hard limit reached — forcing stop",
|
||||||
@@ -177,8 +269,40 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
return _WARNING_MSG, False
|
return _WARNING_MSG, False
|
||||||
# Warning already injected for this hash — suppress
|
|
||||||
return None, False
|
# --- Layer 2: per-tool-type frequency ---
|
||||||
|
freq = self._tool_freq[thread_id]
|
||||||
|
for tc in tool_calls:
|
||||||
|
name = tc.get("name", "")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
freq[name] += 1
|
||||||
|
tc_count = freq[name]
|
||||||
|
|
||||||
|
if tc_count >= self.tool_freq_hard_limit:
|
||||||
|
logger.error(
|
||||||
|
"Tool frequency hard limit reached — forcing stop",
|
||||||
|
extra={
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"tool_name": name,
|
||||||
|
"count": tc_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return _TOOL_FREQ_HARD_STOP_MSG.format(tool_name=name, count=tc_count), True
|
||||||
|
|
||||||
|
if tc_count >= self.tool_freq_warn:
|
||||||
|
warned = self._tool_freq_warned[thread_id]
|
||||||
|
if name not in warned:
|
||||||
|
warned.add(name)
|
||||||
|
logger.warning(
|
||||||
|
"Tool frequency warning — too many calls to same tool type",
|
||||||
|
extra={
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"tool_name": name,
|
||||||
|
"count": tc_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return _TOOL_FREQ_WARNING_MSG.format(tool_name=name, count=tc_count), False
|
||||||
|
|
||||||
return None, False
|
return None, False
|
||||||
|
|
||||||
@@ -199,6 +323,26 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
# Fallback: coerce unexpected types to str to avoid TypeError
|
# Fallback: coerce unexpected types to str to avoid TypeError
|
||||||
return str(content) + f"\n\n{text}"
|
return str(content) + f"\n\n{text}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_hard_stop_update(last_msg, content: str | list) -> dict:
|
||||||
|
"""Clear tool-call metadata so forced-stop messages serialize as plain assistant text."""
|
||||||
|
update = {
|
||||||
|
"tool_calls": [],
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
|
||||||
|
additional_kwargs = dict(getattr(last_msg, "additional_kwargs", {}) or {})
|
||||||
|
for key in ("tool_calls", "function_call"):
|
||||||
|
additional_kwargs.pop(key, None)
|
||||||
|
update["additional_kwargs"] = additional_kwargs
|
||||||
|
|
||||||
|
response_metadata = deepcopy(getattr(last_msg, "response_metadata", {}) or {})
|
||||||
|
if response_metadata.get("finish_reason") == "tool_calls":
|
||||||
|
response_metadata["finish_reason"] = "stop"
|
||||||
|
update["response_metadata"] = response_metadata
|
||||||
|
|
||||||
|
return update
|
||||||
|
|
||||||
def _apply(self, state: AgentState, runtime: Runtime) -> dict | None:
|
def _apply(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||||
warning, hard_stop = self._track_and_check(state, runtime)
|
warning, hard_stop = self._track_and_check(state, runtime)
|
||||||
|
|
||||||
@@ -206,12 +350,8 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
# Strip tool_calls from the last AIMessage to force text output
|
# Strip tool_calls from the last AIMessage to force text output
|
||||||
messages = state.get("messages", [])
|
messages = state.get("messages", [])
|
||||||
last_msg = messages[-1]
|
last_msg = messages[-1]
|
||||||
stripped_msg = last_msg.model_copy(
|
content = self._append_text(last_msg.content, warning or _HARD_STOP_MSG)
|
||||||
update={
|
stripped_msg = last_msg.model_copy(update=self._build_hard_stop_update(last_msg, content))
|
||||||
"tool_calls": [],
|
|
||||||
"content": self._append_text(last_msg.content, _HARD_STOP_MSG),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"messages": [stripped_msg]}
|
return {"messages": [stripped_msg]}
|
||||||
|
|
||||||
if warning:
|
if warning:
|
||||||
@@ -239,6 +379,10 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
if thread_id:
|
if thread_id:
|
||||||
self._history.pop(thread_id, None)
|
self._history.pop(thread_id, None)
|
||||||
self._warned.pop(thread_id, None)
|
self._warned.pop(thread_id, None)
|
||||||
|
self._tool_freq.pop(thread_id, None)
|
||||||
|
self._tool_freq_warned.pop(thread_id, None)
|
||||||
else:
|
else:
|
||||||
self._history.clear()
|
self._history.clear()
|
||||||
self._warned.clear()
|
self._warned.clear()
|
||||||
|
self._tool_freq.clear()
|
||||||
|
self._tool_freq_warned.clear()
|
||||||
|
|||||||
@@ -1,50 +1,19 @@
|
|||||||
"""Middleware for memory mechanism."""
|
"""Middleware for memory mechanism."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
from typing import override
|
||||||
from typing import Any, override
|
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
from langgraph.config import get_config
|
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
|
from deerflow.agents.memory.message_processing import detect_correction, detect_reinforcement, filter_messages_for_memory
|
||||||
from deerflow.agents.memory.queue import get_memory_queue
|
from deerflow.agents.memory.queue import get_memory_queue
|
||||||
from deerflow.config.memory_config import get_memory_config
|
from deerflow.config.memory_config import get_memory_config
|
||||||
|
from deerflow.utils.runtime import get_thread_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_UPLOAD_BLOCK_RE = re.compile(r"<uploaded_files>[\s\S]*?</uploaded_files>\n*", re.IGNORECASE)
|
|
||||||
_CORRECTION_PATTERNS = (
|
|
||||||
re.compile(r"\bthat(?:'s| is) (?:wrong|incorrect)\b", re.IGNORECASE),
|
|
||||||
re.compile(r"\byou misunderstood\b", re.IGNORECASE),
|
|
||||||
re.compile(r"\btry again\b", re.IGNORECASE),
|
|
||||||
re.compile(r"\bredo\b", re.IGNORECASE),
|
|
||||||
re.compile(r"不对"),
|
|
||||||
re.compile(r"你理解错了"),
|
|
||||||
re.compile(r"你理解有误"),
|
|
||||||
re.compile(r"重试"),
|
|
||||||
re.compile(r"重新来"),
|
|
||||||
re.compile(r"换一种"),
|
|
||||||
re.compile(r"改用"),
|
|
||||||
)
|
|
||||||
|
|
||||||
_REINFORCEMENT_PATTERNS = (
|
|
||||||
re.compile(r"\byes[,.]?\s+(?:exactly|perfect|that(?:'s| is) (?:right|correct|it))\b", re.IGNORECASE),
|
|
||||||
re.compile(r"\bperfect(?:[.!?]|$)", re.IGNORECASE),
|
|
||||||
re.compile(r"\bexactly\s+(?:right|correct)\b", re.IGNORECASE),
|
|
||||||
re.compile(r"\bthat(?:'s| is)\s+(?:exactly\s+)?(?:right|correct|what i (?:wanted|needed|meant))\b", re.IGNORECASE),
|
|
||||||
re.compile(r"\bkeep\s+(?:doing\s+)?that\b", re.IGNORECASE),
|
|
||||||
re.compile(r"\bjust\s+(?:like\s+)?(?:that|this)\b", re.IGNORECASE),
|
|
||||||
re.compile(r"\bthis is (?:great|helpful)\b(?:[.!?]|$)", re.IGNORECASE),
|
|
||||||
re.compile(r"\bthis is what i wanted\b(?:[.!?]|$)", re.IGNORECASE),
|
|
||||||
re.compile(r"对[,,]?\s*就是这样(?:[。!?!?.]|$)"),
|
|
||||||
re.compile(r"完全正确(?:[。!?!?.]|$)"),
|
|
||||||
re.compile(r"(?:对[,,]?\s*)?就是这个意思(?:[。!?!?.]|$)"),
|
|
||||||
re.compile(r"正是我想要的(?:[。!?!?.]|$)"),
|
|
||||||
re.compile(r"继续保持(?:[。!?!?.]|$)"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryMiddlewareState(AgentState):
|
class MemoryMiddlewareState(AgentState):
|
||||||
"""Compatible with the `ThreadState` schema."""
|
"""Compatible with the `ThreadState` schema."""
|
||||||
@@ -52,125 +21,6 @@ class MemoryMiddlewareState(AgentState):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _extract_message_text(message: Any) -> str:
|
|
||||||
"""Extract plain text from message content for filtering and signal detection."""
|
|
||||||
content = getattr(message, "content", "")
|
|
||||||
if isinstance(content, list):
|
|
||||||
text_parts: list[str] = []
|
|
||||||
for part in content:
|
|
||||||
if isinstance(part, str):
|
|
||||||
text_parts.append(part)
|
|
||||||
elif isinstance(part, dict):
|
|
||||||
text_val = part.get("text")
|
|
||||||
if isinstance(text_val, str):
|
|
||||||
text_parts.append(text_val)
|
|
||||||
return " ".join(text_parts)
|
|
||||||
return str(content)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
- The <uploaded_files> block injected by UploadsMiddleware into human messages
|
|
||||||
(file paths are session-scoped and must not persist in long-term memory).
|
|
||||||
The user's actual question is preserved; only turns whose content is entirely
|
|
||||||
the upload block (nothing remains after stripping) are dropped along with
|
|
||||||
their paired assistant response.
|
|
||||||
|
|
||||||
Only keeps:
|
|
||||||
- Human messages (with the ephemeral upload block removed)
|
|
||||||
- AI messages without tool_calls (final assistant responses), unless the
|
|
||||||
paired human turn was upload-only and had no real user text.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
messages: List of all conversation messages.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filtered list containing only user inputs and final assistant responses.
|
|
||||||
"""
|
|
||||||
filtered = []
|
|
||||||
skip_next_ai = False
|
|
||||||
for msg in messages:
|
|
||||||
msg_type = getattr(msg, "type", None)
|
|
||||||
|
|
||||||
if msg_type == "human":
|
|
||||||
content_str = _extract_message_text(msg)
|
|
||||||
if "<uploaded_files>" in content_str:
|
|
||||||
# Strip the ephemeral upload block; keep the user's real question.
|
|
||||||
stripped = _UPLOAD_BLOCK_RE.sub("", content_str).strip()
|
|
||||||
if not stripped:
|
|
||||||
# Nothing left — the entire turn was upload bookkeeping;
|
|
||||||
# skip it and the paired assistant response.
|
|
||||||
skip_next_ai = True
|
|
||||||
continue
|
|
||||||
# Rebuild the message with cleaned content so the user's question
|
|
||||||
# is still available for memory summarisation.
|
|
||||||
from copy import copy
|
|
||||||
|
|
||||||
clean_msg = copy(msg)
|
|
||||||
clean_msg.content = stripped
|
|
||||||
filtered.append(clean_msg)
|
|
||||||
skip_next_ai = False
|
|
||||||
else:
|
|
||||||
filtered.append(msg)
|
|
||||||
skip_next_ai = False
|
|
||||||
elif msg_type == "ai":
|
|
||||||
tool_calls = getattr(msg, "tool_calls", None)
|
|
||||||
if not tool_calls:
|
|
||||||
if skip_next_ai:
|
|
||||||
skip_next_ai = False
|
|
||||||
continue
|
|
||||||
filtered.append(msg)
|
|
||||||
# Skip tool messages and AI messages with tool_calls
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
|
|
||||||
|
|
||||||
def detect_correction(messages: list[Any]) -> bool:
|
|
||||||
"""Detect explicit user corrections in recent conversation turns.
|
|
||||||
|
|
||||||
The queue keeps only one pending context per thread, so callers pass the
|
|
||||||
latest filtered message list. Checking only recent user turns keeps signal
|
|
||||||
detection conservative while avoiding stale corrections from long histories.
|
|
||||||
"""
|
|
||||||
recent_user_msgs = [msg for msg in messages[-6:] if getattr(msg, "type", None) == "human"]
|
|
||||||
|
|
||||||
for msg in recent_user_msgs:
|
|
||||||
content = _extract_message_text(msg).strip()
|
|
||||||
if not content:
|
|
||||||
continue
|
|
||||||
if any(pattern.search(content) for pattern in _CORRECTION_PATTERNS):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def detect_reinforcement(messages: list[Any]) -> bool:
|
|
||||||
"""Detect explicit positive reinforcement signals in recent conversation turns.
|
|
||||||
|
|
||||||
Complements detect_correction() by identifying when the user confirms the
|
|
||||||
agent's approach was correct. This allows the memory system to record what
|
|
||||||
worked well, not just what went wrong.
|
|
||||||
|
|
||||||
The queue keeps only one pending context per thread, so callers pass the
|
|
||||||
latest filtered message list. Checking only recent user turns keeps signal
|
|
||||||
detection conservative while avoiding stale signals from long histories.
|
|
||||||
"""
|
|
||||||
recent_user_msgs = [msg for msg in messages[-6:] if getattr(msg, "type", None) == "human"]
|
|
||||||
|
|
||||||
for msg in recent_user_msgs:
|
|
||||||
content = _extract_message_text(msg).strip()
|
|
||||||
if not content:
|
|
||||||
continue
|
|
||||||
if any(pattern.search(content) for pattern in _REINFORCEMENT_PATTERNS):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
||||||
"""Middleware that queues conversation for memory update after agent execution.
|
"""Middleware that queues conversation for memory update after agent execution.
|
||||||
|
|
||||||
@@ -207,13 +57,10 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
|||||||
if not config.enabled:
|
if not config.enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get thread ID from runtime context first, then fall back to LangGraph's configurable metadata
|
# Resolve thread ID from the runtime or configured fallback sources
|
||||||
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
thread_id = get_thread_id(runtime)
|
||||||
if thread_id is None:
|
|
||||||
config_data = get_config()
|
|
||||||
thread_id = config_data.get("configurable", {}).get("thread_id")
|
|
||||||
if not thread_id:
|
if not thread_id:
|
||||||
logger.debug("No thread_id in context, skipping memory update")
|
logger.debug("No thread_id could be resolved from runtime/config, skipping memory update")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get messages from state
|
# Get messages from state
|
||||||
@@ -223,7 +70,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Filter to only keep user inputs and final assistant responses
|
# Filter to only keep user inputs and final assistant responses
|
||||||
filtered_messages = _filter_messages_for_memory(messages)
|
filtered_messages = filter_messages_for_memory(messages)
|
||||||
|
|
||||||
# Only queue if there's meaningful conversation
|
# Only queue if there's meaningful conversation
|
||||||
# At minimum need one user message and one assistant response
|
# At minimum need one user message and one assistant response
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from langgraph.prebuilt.tool_node import ToolCallRequest
|
|||||||
from langgraph.types import Command
|
from langgraph.types import Command
|
||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
|
from deerflow.utils.runtime import get_thread_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,25 +24,119 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Each pattern is compiled once at import time.
|
# Each pattern is compiled once at import time.
|
||||||
_HIGH_RISK_PATTERNS: list[re.Pattern[str]] = [
|
_HIGH_RISK_PATTERNS: list[re.Pattern[str]] = [
|
||||||
re.compile(r"rm\s+-[^\s]*r[^\s]*\s+(/\*?|~/?\*?|/home\b|/root\b)\s*$"), # rm -rf / /* ~ /home /root
|
# --- original rules (retained) ---
|
||||||
re.compile(r"(curl|wget).+\|\s*(ba)?sh"), # curl|sh, wget|sh
|
re.compile(r"rm\s+-[^\s]*r[^\s]*\s+(/\*?|~/?\*?|/home\b|/root\b)\s*$"),
|
||||||
re.compile(r"dd\s+if="),
|
re.compile(r"dd\s+if="),
|
||||||
re.compile(r"mkfs"),
|
re.compile(r"mkfs"),
|
||||||
re.compile(r"cat\s+/etc/shadow"),
|
re.compile(r"cat\s+/etc/shadow"),
|
||||||
re.compile(r">\s*/etc/"), # overwrite /etc/ files
|
re.compile(r">+\s*/etc/"),
|
||||||
|
# --- pipe to sh/bash (generalised, replaces old curl|sh rule) ---
|
||||||
|
re.compile(r"\|\s*(ba)?sh\b"),
|
||||||
|
# --- command substitution (targeted – only dangerous executables) ---
|
||||||
|
re.compile(r"[`$]\(?\s*(curl|wget|bash|sh|python|ruby|perl|base64)"),
|
||||||
|
# --- base64 decode piped to execution ---
|
||||||
|
re.compile(r"base64\s+.*-d.*\|"),
|
||||||
|
# --- overwrite system binaries ---
|
||||||
|
re.compile(r">+\s*(/usr/bin/|/bin/|/sbin/)"),
|
||||||
|
# --- overwrite shell startup files ---
|
||||||
|
re.compile(r">+\s*~/?\.(bashrc|profile|zshrc|bash_profile)"),
|
||||||
|
# --- process environment leakage ---
|
||||||
|
re.compile(r"/proc/[^/]+/environ"),
|
||||||
|
# --- dynamic linker hijack (one-step escalation) ---
|
||||||
|
re.compile(r"\b(LD_PRELOAD|LD_LIBRARY_PATH)\s*="),
|
||||||
|
# --- bash built-in networking (bypasses tool allowlists) ---
|
||||||
|
re.compile(r"/dev/tcp/"),
|
||||||
|
# --- fork bomb ---
|
||||||
|
re.compile(r"\S+\(\)\s*\{[^}]*\|\s*\S+\s*&"), # :(){ :|:& };:
|
||||||
|
re.compile(r"while\s+true.*&\s*done"), # while true; do bash & done
|
||||||
]
|
]
|
||||||
|
|
||||||
_MEDIUM_RISK_PATTERNS: list[re.Pattern[str]] = [
|
_MEDIUM_RISK_PATTERNS: list[re.Pattern[str]] = [
|
||||||
re.compile(r"chmod\s+777"), # overly permissive, but reversible
|
re.compile(r"chmod\s+777"),
|
||||||
re.compile(r"pip\s+install"),
|
re.compile(r"pip3?\s+install"),
|
||||||
re.compile(r"pip3\s+install"),
|
|
||||||
re.compile(r"apt(-get)?\s+install"),
|
re.compile(r"apt(-get)?\s+install"),
|
||||||
|
# sudo/su: no-op under Docker root; warn so LLM is aware
|
||||||
|
re.compile(r"\b(sudo|su)\b"),
|
||||||
|
# PATH modification: long attack chain, warn rather than block
|
||||||
|
re.compile(r"\bPATH\s*="),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _classify_command(command: str) -> str:
|
def _split_compound_command(command: str) -> list[str]:
|
||||||
"""Return 'block', 'warn', or 'pass'."""
|
"""Split a compound command into sub-commands (quote-aware).
|
||||||
# Normalize for matching (collapse whitespace)
|
|
||||||
|
Scans the raw command string so unquoted shell control operators are
|
||||||
|
recognised even when they are not surrounded by whitespace
|
||||||
|
(e.g. ``safe;rm -rf /`` or ``rm -rf /&&echo ok``). Operators inside
|
||||||
|
quotes are ignored. If the command ends with an unclosed quote or a
|
||||||
|
dangling escape, return the whole command unchanged (fail-closed —
|
||||||
|
safer to classify the unsplit string than silently drop parts).
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
in_single_quote = False
|
||||||
|
in_double_quote = False
|
||||||
|
escaping = False
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
while index < len(command):
|
||||||
|
char = command[index]
|
||||||
|
|
||||||
|
if escaping:
|
||||||
|
current.append(char)
|
||||||
|
escaping = False
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if char == "\\" and not in_single_quote:
|
||||||
|
current.append(char)
|
||||||
|
escaping = True
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if char == "'" and not in_double_quote:
|
||||||
|
in_single_quote = not in_single_quote
|
||||||
|
current.append(char)
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if char == '"' and not in_single_quote:
|
||||||
|
in_double_quote = not in_double_quote
|
||||||
|
current.append(char)
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not in_single_quote and not in_double_quote:
|
||||||
|
if command.startswith("&&", index) or command.startswith("||", index):
|
||||||
|
part = "".join(current).strip()
|
||||||
|
if part:
|
||||||
|
parts.append(part)
|
||||||
|
current = []
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
if char == ";":
|
||||||
|
part = "".join(current).strip()
|
||||||
|
if part:
|
||||||
|
parts.append(part)
|
||||||
|
current = []
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
current.append(char)
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
# Unclosed quote or dangling escape → fail-closed, return whole command
|
||||||
|
if in_single_quote or in_double_quote or escaping:
|
||||||
|
return [command]
|
||||||
|
|
||||||
|
part = "".join(current).strip()
|
||||||
|
if part:
|
||||||
|
parts.append(part)
|
||||||
|
return parts if parts else [command]
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_single_command(command: str) -> str:
|
||||||
|
"""Classify a single (non-compound) command. Return 'block', 'warn', or 'pass'."""
|
||||||
normalized = " ".join(command.split())
|
normalized = " ".join(command.split())
|
||||||
|
|
||||||
for pattern in _HIGH_RISK_PATTERNS:
|
for pattern in _HIGH_RISK_PATTERNS:
|
||||||
@@ -66,6 +161,35 @@ def _classify_command(command: str) -> str:
|
|||||||
return "pass"
|
return "pass"
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_command(command: str) -> str:
|
||||||
|
"""Return 'block', 'warn', or 'pass'.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. First scan the *whole* raw command against high-risk patterns. This
|
||||||
|
catches structural attacks like ``while true; do bash & done`` or
|
||||||
|
``:(){ :|:& };:`` that span multiple shell statements — splitting them
|
||||||
|
on ``;`` would destroy the pattern context.
|
||||||
|
2. Then split compound commands (e.g. ``cmd1 && cmd2 ; cmd3``) and
|
||||||
|
classify each sub-command independently. The most severe verdict wins.
|
||||||
|
"""
|
||||||
|
# Pass 1: whole-command high-risk scan (catches multi-statement patterns)
|
||||||
|
normalized = " ".join(command.split())
|
||||||
|
for pattern in _HIGH_RISK_PATTERNS:
|
||||||
|
if pattern.search(normalized):
|
||||||
|
return "block"
|
||||||
|
|
||||||
|
# Pass 2: per-sub-command classification
|
||||||
|
sub_commands = _split_compound_command(command)
|
||||||
|
worst = "pass"
|
||||||
|
for sub in sub_commands:
|
||||||
|
verdict = _classify_single_command(sub)
|
||||||
|
if verdict == "block":
|
||||||
|
return "block" # short-circuit: can't get worse
|
||||||
|
if verdict == "warn":
|
||||||
|
worst = "warn"
|
||||||
|
return worst
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Middleware
|
# Middleware
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -95,15 +219,7 @@ class SandboxAuditMiddleware(AgentMiddleware[ThreadState]):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _get_thread_id(self, request: ToolCallRequest) -> str | None:
|
def _get_thread_id(self, request: ToolCallRequest) -> str | None:
|
||||||
runtime = request.runtime # ToolRuntime; may be None-like in tests
|
return get_thread_id(request.runtime)
|
||||||
if runtime is None:
|
|
||||||
return None
|
|
||||||
ctx = getattr(runtime, "context", None) or {}
|
|
||||||
thread_id = ctx.get("thread_id") if isinstance(ctx, dict) else None
|
|
||||||
if thread_id is None:
|
|
||||||
cfg = getattr(runtime, "config", None) or {}
|
|
||||||
thread_id = cfg.get("configurable", {}).get("thread_id")
|
|
||||||
return thread_id
|
|
||||||
|
|
||||||
_AUDIT_COMMAND_LIMIT = 200
|
_AUDIT_COMMAND_LIMIT = 200
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""Summarization middleware extensions for DeerFlow."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Collection
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from langchain.agents import AgentState
|
||||||
|
from langchain.agents.middleware import SummarizationMiddleware
|
||||||
|
from langchain_core.messages import AIMessage, AnyMessage, RemoveMessage, ToolMessage
|
||||||
|
from langgraph.config import get_config
|
||||||
|
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
||||||
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
|
from deerflow.utils.runtime import get_thread_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SummarizationEvent:
|
||||||
|
"""Context emitted before conversation history is summarized away."""
|
||||||
|
|
||||||
|
messages_to_summarize: tuple[AnyMessage, ...]
|
||||||
|
preserved_messages: tuple[AnyMessage, ...]
|
||||||
|
thread_id: str | None
|
||||||
|
agent_name: str | None
|
||||||
|
runtime: Runtime
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class BeforeSummarizationHook(Protocol):
|
||||||
|
"""Hook invoked before summarization removes messages from state."""
|
||||||
|
|
||||||
|
def __call__(self, event: SummarizationEvent) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_agent_name(runtime: Runtime) -> str | None:
|
||||||
|
"""Resolve the current agent name from runtime context or LangGraph config."""
|
||||||
|
agent_name = runtime.context.get("agent_name") if runtime.context else None
|
||||||
|
if agent_name is None:
|
||||||
|
try:
|
||||||
|
config_data = get_config()
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
agent_name = config_data.get("configurable", {}).get("agent_name")
|
||||||
|
return agent_name
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_call_path(tool_call: dict[str, Any]) -> str | None:
|
||||||
|
"""Best-effort extraction of a file path argument from a read_file-like tool call."""
|
||||||
|
args = tool_call.get("args") or {}
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
return None
|
||||||
|
for key in ("path", "file_path", "filepath"):
|
||||||
|
value = args.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clone_ai_message(
|
||||||
|
message: AIMessage,
|
||||||
|
tool_calls: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
content: Any | None = None,
|
||||||
|
) -> AIMessage:
|
||||||
|
"""Clone an AIMessage while replacing its tool_calls list and optional content."""
|
||||||
|
update: dict[str, Any] = {"tool_calls": tool_calls}
|
||||||
|
if content is not None:
|
||||||
|
update["content"] = content
|
||||||
|
return message.model_copy(update=update)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _SkillBundle:
|
||||||
|
"""Skill-related tool calls and tool results associated with one AIMessage."""
|
||||||
|
|
||||||
|
ai_index: int
|
||||||
|
skill_tool_indices: tuple[int, ...]
|
||||||
|
skill_tool_call_ids: frozenset[str]
|
||||||
|
skill_tool_tokens: int
|
||||||
|
skill_key: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||||
|
"""Summarization middleware with pre-compression hook dispatch and skill rescue."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*args,
|
||||||
|
skills_container_path: str | None = None,
|
||||||
|
skill_file_read_tool_names: Collection[str] | None = None,
|
||||||
|
before_summarization: list[BeforeSummarizationHook] | None = None,
|
||||||
|
preserve_recent_skill_count: int = 5,
|
||||||
|
preserve_recent_skill_tokens: int = 25_000,
|
||||||
|
preserve_recent_skill_tokens_per_skill: int = 5_000,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._skills_container_path = skills_container_path or "/mnt/skills"
|
||||||
|
self._skill_file_read_tool_names = frozenset(skill_file_read_tool_names or {"read_file", "read", "view", "cat"})
|
||||||
|
self._before_summarization_hooks = before_summarization or []
|
||||||
|
self._preserve_recent_skill_count = max(0, preserve_recent_skill_count)
|
||||||
|
self._preserve_recent_skill_tokens = max(0, preserve_recent_skill_tokens)
|
||||||
|
self._preserve_recent_skill_tokens_per_skill = max(0, preserve_recent_skill_tokens_per_skill)
|
||||||
|
|
||||||
|
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||||
|
return self._maybe_summarize(state, runtime)
|
||||||
|
|
||||||
|
async def abefore_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||||
|
return await self._amaybe_summarize(state, runtime)
|
||||||
|
|
||||||
|
def _maybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||||
|
messages = state["messages"]
|
||||||
|
self._ensure_message_ids(messages)
|
||||||
|
|
||||||
|
total_tokens = self.token_counter(messages)
|
||||||
|
if not self._should_summarize(messages, total_tokens):
|
||||||
|
return None
|
||||||
|
|
||||||
|
cutoff_index = self._determine_cutoff_index(messages)
|
||||||
|
if cutoff_index <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index)
|
||||||
|
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
||||||
|
summary = self._create_summary(messages_to_summarize)
|
||||||
|
new_messages = self._build_new_messages(summary)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"messages": [
|
||||||
|
RemoveMessage(id=REMOVE_ALL_MESSAGES),
|
||||||
|
*new_messages,
|
||||||
|
*preserved_messages,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||||
|
messages = state["messages"]
|
||||||
|
self._ensure_message_ids(messages)
|
||||||
|
|
||||||
|
total_tokens = self.token_counter(messages)
|
||||||
|
if not self._should_summarize(messages, total_tokens):
|
||||||
|
return None
|
||||||
|
|
||||||
|
cutoff_index = self._determine_cutoff_index(messages)
|
||||||
|
if cutoff_index <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index)
|
||||||
|
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
||||||
|
summary = await self._acreate_summary(messages_to_summarize)
|
||||||
|
new_messages = self._build_new_messages(summary)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"messages": [
|
||||||
|
RemoveMessage(id=REMOVE_ALL_MESSAGES),
|
||||||
|
*new_messages,
|
||||||
|
*preserved_messages,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _partition_with_skill_rescue(
|
||||||
|
self,
|
||||||
|
messages: list[AnyMessage],
|
||||||
|
cutoff_index: int,
|
||||||
|
) -> tuple[list[AnyMessage], list[AnyMessage]]:
|
||||||
|
"""Partition like the parent, then rescue recently-loaded skill bundles."""
|
||||||
|
to_summarize, preserved = self._partition_messages(messages, cutoff_index)
|
||||||
|
|
||||||
|
if self._preserve_recent_skill_count == 0 or self._preserve_recent_skill_tokens == 0 or not to_summarize:
|
||||||
|
return to_summarize, preserved
|
||||||
|
|
||||||
|
try:
|
||||||
|
bundles = self._find_skill_bundles(to_summarize, self._skills_container_path)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Skill-preserving summarization rescue failed; falling back to default partition")
|
||||||
|
return to_summarize, preserved
|
||||||
|
|
||||||
|
if not bundles:
|
||||||
|
return to_summarize, preserved
|
||||||
|
|
||||||
|
rescue_bundles = self._select_bundles_to_rescue(bundles)
|
||||||
|
if not rescue_bundles:
|
||||||
|
return to_summarize, preserved
|
||||||
|
|
||||||
|
bundles_by_ai_index = {bundle.ai_index: bundle for bundle in rescue_bundles}
|
||||||
|
rescue_tool_indices = {idx for bundle in rescue_bundles for idx in bundle.skill_tool_indices}
|
||||||
|
rescued: list[AnyMessage] = []
|
||||||
|
remaining: list[AnyMessage] = []
|
||||||
|
for i, msg in enumerate(to_summarize):
|
||||||
|
bundle = bundles_by_ai_index.get(i)
|
||||||
|
if bundle is not None and isinstance(msg, AIMessage):
|
||||||
|
rescued_tool_calls = [tc for tc in msg.tool_calls if tc.get("id") in bundle.skill_tool_call_ids]
|
||||||
|
remaining_tool_calls = [tc for tc in msg.tool_calls if tc.get("id") not in bundle.skill_tool_call_ids]
|
||||||
|
|
||||||
|
if rescued_tool_calls:
|
||||||
|
rescued.append(_clone_ai_message(msg, rescued_tool_calls, content=""))
|
||||||
|
if remaining_tool_calls or msg.content:
|
||||||
|
remaining.append(_clone_ai_message(msg, remaining_tool_calls))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if i in rescue_tool_indices:
|
||||||
|
rescued.append(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
remaining.append(msg)
|
||||||
|
|
||||||
|
return remaining, rescued + preserved
|
||||||
|
|
||||||
|
def _find_skill_bundles(
|
||||||
|
self,
|
||||||
|
messages: list[AnyMessage],
|
||||||
|
skills_root: str,
|
||||||
|
) -> list[_SkillBundle]:
|
||||||
|
"""Locate AIMessage + paired ToolMessage groups that load skill files."""
|
||||||
|
bundles: list[_SkillBundle] = []
|
||||||
|
n = len(messages)
|
||||||
|
i = 0
|
||||||
|
while i < n:
|
||||||
|
msg = messages[i]
|
||||||
|
if not (isinstance(msg, AIMessage) and msg.tool_calls):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
tool_calls = list(msg.tool_calls)
|
||||||
|
skill_paths_by_id: dict[str, str] = {}
|
||||||
|
for tc in tool_calls:
|
||||||
|
if self._is_skill_tool_call(tc, skills_root):
|
||||||
|
tc_id = tc.get("id")
|
||||||
|
path = _tool_call_path(tc)
|
||||||
|
if tc_id and path:
|
||||||
|
skill_paths_by_id[tc_id] = path
|
||||||
|
|
||||||
|
if not skill_paths_by_id:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
skill_tool_tokens = 0
|
||||||
|
skill_key_parts: list[str] = []
|
||||||
|
skill_tool_indices: list[int] = []
|
||||||
|
matched_skill_call_ids: set[str] = set()
|
||||||
|
|
||||||
|
j = i + 1
|
||||||
|
while j < n and isinstance(messages[j], ToolMessage):
|
||||||
|
j += 1
|
||||||
|
|
||||||
|
for k in range(i + 1, j):
|
||||||
|
tool_msg = messages[k]
|
||||||
|
if isinstance(tool_msg, ToolMessage) and tool_msg.tool_call_id in skill_paths_by_id:
|
||||||
|
skill_tool_tokens += self.token_counter([tool_msg])
|
||||||
|
skill_key_parts.append(skill_paths_by_id[tool_msg.tool_call_id])
|
||||||
|
skill_tool_indices.append(k)
|
||||||
|
matched_skill_call_ids.add(tool_msg.tool_call_id)
|
||||||
|
|
||||||
|
if not skill_tool_indices:
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
|
||||||
|
bundles.append(
|
||||||
|
_SkillBundle(
|
||||||
|
ai_index=i,
|
||||||
|
skill_tool_indices=tuple(skill_tool_indices),
|
||||||
|
skill_tool_call_ids=frozenset(matched_skill_call_ids),
|
||||||
|
skill_tool_tokens=skill_tool_tokens,
|
||||||
|
skill_key="|".join(sorted(skill_key_parts)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
i = j
|
||||||
|
|
||||||
|
return bundles
|
||||||
|
|
||||||
|
def _select_bundles_to_rescue(self, bundles: list[_SkillBundle]) -> list[_SkillBundle]:
|
||||||
|
"""Pick bundles to keep, walking newest-first under count/token budgets."""
|
||||||
|
selected: list[_SkillBundle] = []
|
||||||
|
if not bundles:
|
||||||
|
return selected
|
||||||
|
|
||||||
|
seen_skill_keys: set[str] = set()
|
||||||
|
total_tokens = 0
|
||||||
|
kept = 0
|
||||||
|
|
||||||
|
for bundle in reversed(bundles):
|
||||||
|
if kept >= self._preserve_recent_skill_count:
|
||||||
|
break
|
||||||
|
if bundle.skill_key in seen_skill_keys:
|
||||||
|
continue
|
||||||
|
if bundle.skill_tool_tokens > self._preserve_recent_skill_tokens_per_skill:
|
||||||
|
continue
|
||||||
|
if total_tokens + bundle.skill_tool_tokens > self._preserve_recent_skill_tokens:
|
||||||
|
continue
|
||||||
|
|
||||||
|
selected.append(bundle)
|
||||||
|
total_tokens += bundle.skill_tool_tokens
|
||||||
|
kept += 1
|
||||||
|
seen_skill_keys.add(bundle.skill_key)
|
||||||
|
|
||||||
|
selected.reverse()
|
||||||
|
return selected
|
||||||
|
|
||||||
|
def _is_skill_tool_call(self, tool_call: dict[str, Any], skills_root: str) -> bool:
|
||||||
|
"""Return True when ``tool_call`` reads a file under the configured skills root."""
|
||||||
|
name = tool_call.get("name") or ""
|
||||||
|
if name not in self._skill_file_read_tool_names:
|
||||||
|
return False
|
||||||
|
path = _tool_call_path(tool_call)
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
normalized_root = skills_root.rstrip("/")
|
||||||
|
return path == normalized_root or path.startswith(normalized_root + "/")
|
||||||
|
|
||||||
|
def _fire_hooks(
|
||||||
|
self,
|
||||||
|
messages_to_summarize: list[AnyMessage],
|
||||||
|
preserved_messages: list[AnyMessage],
|
||||||
|
runtime: Runtime,
|
||||||
|
) -> None:
|
||||||
|
if not self._before_summarization_hooks:
|
||||||
|
return
|
||||||
|
|
||||||
|
event = SummarizationEvent(
|
||||||
|
messages_to_summarize=tuple(messages_to_summarize),
|
||||||
|
preserved_messages=tuple(preserved_messages),
|
||||||
|
thread_id=get_thread_id(runtime),
|
||||||
|
agent_name=_resolve_agent_name(runtime),
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
for hook in self._before_summarization_hooks:
|
||||||
|
try:
|
||||||
|
hook(event)
|
||||||
|
except Exception:
|
||||||
|
hook_name = getattr(hook, "__name__", None) or type(hook).__name__
|
||||||
|
logger.exception("before_summarization hook %s failed", hook_name)
|
||||||
@@ -3,11 +3,11 @@ from typing import NotRequired, override
|
|||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
from langgraph.config import get_config
|
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadDataState
|
from deerflow.agents.thread_state import ThreadDataState
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
|
from deerflow.utils.runtime import get_thread_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -75,11 +75,7 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None:
|
def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None:
|
||||||
context = runtime.context or {}
|
thread_id = get_thread_id(runtime)
|
||||||
thread_id = context.get("thread_id")
|
|
||||||
if thread_id is None:
|
|
||||||
config = get_config()
|
|
||||||
thread_id = config.get("configurable", {}).get("thread_id")
|
|
||||||
|
|
||||||
if thread_id is None:
|
if thread_id is None:
|
||||||
raise ValueError("Thread ID is required in runtime context or config.configurable")
|
raise ValueError("Thread ID is required in runtime context or config.configurable")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Middleware for automatic thread title generation."""
|
"""Middleware for automatic thread title generation."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import NotRequired, override
|
from typing import NotRequired, override
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
@@ -77,7 +78,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
assistant_msg_content = next((m.content for m in messages if m.type == "ai"), "")
|
assistant_msg_content = next((m.content for m in messages if m.type == "ai"), "")
|
||||||
|
|
||||||
user_msg = self._normalize_content(user_msg_content)
|
user_msg = self._normalize_content(user_msg_content)
|
||||||
assistant_msg = self._normalize_content(assistant_msg_content)
|
assistant_msg = self._strip_think_tags(self._normalize_content(assistant_msg_content))
|
||||||
|
|
||||||
prompt = config.prompt_template.format(
|
prompt = config.prompt_template.format(
|
||||||
max_words=config.max_words,
|
max_words=config.max_words,
|
||||||
@@ -86,10 +87,15 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
)
|
)
|
||||||
return prompt, user_msg
|
return prompt, user_msg
|
||||||
|
|
||||||
|
def _strip_think_tags(self, text: str) -> str:
|
||||||
|
"""Remove <think>...</think> blocks emitted by reasoning models (e.g. minimax, DeepSeek-R1)."""
|
||||||
|
return re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
def _parse_title(self, content: object) -> str:
|
def _parse_title(self, content: object) -> str:
|
||||||
"""Normalize model output into a clean title string."""
|
"""Normalize model output into a clean title string."""
|
||||||
config = get_title_config()
|
config = get_title_config()
|
||||||
title_content = self._normalize_content(content)
|
title_content = self._normalize_content(content)
|
||||||
|
title_content = self._strip_think_tags(title_content)
|
||||||
title = title_content.strip().strip('"').strip("'")
|
title = title_content.strip().strip('"').strip("'")
|
||||||
return title[: config.max_chars] if len(title) > config.max_chars else title
|
return title[: config.max_chars] if len(title) > config.max_chars else title
|
||||||
|
|
||||||
@@ -121,7 +127,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
model = create_chat_model(name=config.model_name, thinking_enabled=False)
|
model = create_chat_model(name=config.model_name, thinking_enabled=False)
|
||||||
else:
|
else:
|
||||||
model = create_chat_model(thinking_enabled=False)
|
model = create_chat_model(thinking_enabled=False)
|
||||||
response = await model.ainvoke(prompt)
|
response = await model.ainvoke(prompt, config={"run_name": "title_agent"})
|
||||||
title = self._parse_title(response.content)
|
title = self._parse_title(response.content)
|
||||||
if title:
|
if title:
|
||||||
return {"title": title}
|
return {"title": title}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""Middleware that extends TodoListMiddleware with context-loss detection.
|
"""Middleware that extends TodoListMiddleware with context-loss detection and premature-exit prevention.
|
||||||
|
|
||||||
When the message history is truncated (e.g., by SummarizationMiddleware), the
|
When the message history is truncated (e.g., by SummarizationMiddleware), the
|
||||||
original `write_todos` tool call and its ToolMessage can be scrolled out of the
|
original `write_todos` tool call and its ToolMessage can be scrolled out of the
|
||||||
active context window. This middleware detects that situation and injects a
|
active context window. This middleware detects that situation and injects a
|
||||||
reminder message so the model still knows about the outstanding todo list.
|
reminder message so the model still knows about the outstanding todo list.
|
||||||
|
|
||||||
|
Additionally, this middleware prevents the agent from exiting the loop while
|
||||||
|
there are still incomplete todo items. When the model produces a final response
|
||||||
|
(no tool calls) but todos are not yet complete, the middleware injects a reminder
|
||||||
|
and jumps back to the model node to force continued engagement.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,6 +17,7 @@ from typing import Any, override
|
|||||||
|
|
||||||
from langchain.agents.middleware import TodoListMiddleware
|
from langchain.agents.middleware import TodoListMiddleware
|
||||||
from langchain.agents.middleware.todo import PlanningState, Todo
|
from langchain.agents.middleware.todo import PlanningState, Todo
|
||||||
|
from langchain.agents.middleware.types import hook_config
|
||||||
from langchain_core.messages import AIMessage, HumanMessage
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
@@ -34,6 +40,11 @@ def _reminder_in_messages(messages: list[Any]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _completion_reminder_count(messages: list[Any]) -> int:
|
||||||
|
"""Return the number of todo_completion_reminder HumanMessages in *messages*."""
|
||||||
|
return sum(1 for msg in messages if isinstance(msg, HumanMessage) and getattr(msg, "name", None) == "todo_completion_reminder")
|
||||||
|
|
||||||
|
|
||||||
def _format_todos(todos: list[Todo]) -> str:
|
def _format_todos(todos: list[Todo]) -> str:
|
||||||
"""Format a list of Todo items into a human-readable string."""
|
"""Format a list of Todo items into a human-readable string."""
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
@@ -57,7 +68,7 @@ class TodoMiddleware(TodoListMiddleware):
|
|||||||
def before_model(
|
def before_model(
|
||||||
self,
|
self,
|
||||||
state: PlanningState,
|
state: PlanningState,
|
||||||
runtime: Runtime, # noqa: ARG002
|
runtime: Runtime,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Inject a todo-list reminder when write_todos has left the context window."""
|
"""Inject a todo-list reminder when write_todos has left the context window."""
|
||||||
todos: list[Todo] = state.get("todos") or [] # type: ignore[assignment]
|
todos: list[Todo] = state.get("todos") or [] # type: ignore[assignment]
|
||||||
@@ -98,3 +109,71 @@ class TodoMiddleware(TodoListMiddleware):
|
|||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Async version of before_model."""
|
"""Async version of before_model."""
|
||||||
return self.before_model(state, runtime)
|
return self.before_model(state, runtime)
|
||||||
|
|
||||||
|
# Maximum number of completion reminders before allowing the agent to exit.
|
||||||
|
# This prevents infinite loops when the agent cannot make further progress.
|
||||||
|
_MAX_COMPLETION_REMINDERS = 2
|
||||||
|
|
||||||
|
@hook_config(can_jump_to=["model"])
|
||||||
|
@override
|
||||||
|
def after_model(
|
||||||
|
self,
|
||||||
|
state: PlanningState,
|
||||||
|
runtime: Runtime,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Prevent premature agent exit when todo items are still incomplete.
|
||||||
|
|
||||||
|
In addition to the base class check for parallel ``write_todos`` calls,
|
||||||
|
this override intercepts model responses that have no tool calls while
|
||||||
|
there are still incomplete todo items. It injects a reminder
|
||||||
|
``HumanMessage`` and jumps back to the model node so the agent
|
||||||
|
continues working through the todo list.
|
||||||
|
|
||||||
|
A retry cap of ``_MAX_COMPLETION_REMINDERS`` (default 2) prevents
|
||||||
|
infinite loops when the agent cannot make further progress.
|
||||||
|
"""
|
||||||
|
# 1. Preserve base class logic (parallel write_todos detection).
|
||||||
|
base_result = super().after_model(state, runtime)
|
||||||
|
if base_result is not None:
|
||||||
|
return base_result
|
||||||
|
|
||||||
|
# 2. Only intervene when the agent wants to exit (no tool calls).
|
||||||
|
messages = state.get("messages") or []
|
||||||
|
last_ai = next((m for m in reversed(messages) if isinstance(m, AIMessage)), None)
|
||||||
|
if not last_ai or last_ai.tool_calls:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 3. Allow exit when all todos are completed or there are no todos.
|
||||||
|
todos: list[Todo] = state.get("todos") or [] # type: ignore[assignment]
|
||||||
|
if not todos or all(t.get("status") == "completed" for t in todos):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 4. Enforce a reminder cap to prevent infinite re-engagement loops.
|
||||||
|
if _completion_reminder_count(messages) >= self._MAX_COMPLETION_REMINDERS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 5. Inject a reminder and force the agent back to the model.
|
||||||
|
incomplete = [t for t in todos if t.get("status") != "completed"]
|
||||||
|
incomplete_text = "\n".join(f"- [{t.get('status', 'pending')}] {t.get('content', '')}" for t in incomplete)
|
||||||
|
reminder = HumanMessage(
|
||||||
|
name="todo_completion_reminder",
|
||||||
|
content=(
|
||||||
|
"<system_reminder>\n"
|
||||||
|
"You have incomplete todo items that must be finished before giving your final response:\n\n"
|
||||||
|
f"{incomplete_text}\n\n"
|
||||||
|
"Please continue working on these tasks. Call `write_todos` to mark items as completed "
|
||||||
|
"as you finish them, and only respond when all items are done.\n"
|
||||||
|
"</system_reminder>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"jump_to": "model", "messages": [reminder]}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@hook_config(can_jump_to=["model"])
|
||||||
|
async def aafter_model(
|
||||||
|
self,
|
||||||
|
state: PlanningState,
|
||||||
|
runtime: Runtime,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Async version of after_model."""
|
||||||
|
return self.after_model(state, runtime)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from langgraph.runtime import Runtime
|
|||||||
|
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
from deerflow.utils.file_conversion import extract_outline
|
from deerflow.utils.file_conversion import extract_outline
|
||||||
|
from deerflow.utils.runtime import get_thread_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -213,14 +214,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Resolve uploads directory for existence checks
|
# Resolve uploads directory for existence checks
|
||||||
thread_id = (runtime.context or {}).get("thread_id")
|
thread_id = get_thread_id(runtime)
|
||||||
if thread_id is None:
|
|
||||||
try:
|
|
||||||
from langgraph.config import get_config
|
|
||||||
|
|
||||||
thread_id = get_config().get("configurable", {}).get("thread_id")
|
|
||||||
except RuntimeError:
|
|
||||||
pass # get_config() raises outside a runnable context (e.g. unit tests)
|
|
||||||
uploads_dir = self._paths.sandbox_uploads_dir(thread_id) if thread_id else None
|
uploads_dir = self._paths.sandbox_uploads_dir(thread_id) if thread_id else None
|
||||||
|
|
||||||
# Get newly uploaded files from the current message's additional_kwargs.files
|
# Get newly uploaded files from the current message's additional_kwargs.files
|
||||||
@@ -262,21 +256,25 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
|||||||
files_message = self._create_files_message(new_files, historical_files)
|
files_message = self._create_files_message(new_files, historical_files)
|
||||||
|
|
||||||
# Extract original content - handle both string and list formats
|
# Extract original content - handle both string and list formats
|
||||||
original_content = ""
|
original_content = last_message.content
|
||||||
if isinstance(last_message.content, str):
|
if isinstance(original_content, str):
|
||||||
original_content = last_message.content
|
# Simple case: string content, just prepend files message
|
||||||
elif isinstance(last_message.content, list):
|
updated_content = f"{files_message}\n\n{original_content}"
|
||||||
text_parts = []
|
elif isinstance(original_content, list):
|
||||||
for block in last_message.content:
|
# Complex case: list content (multimodal), preserve all blocks
|
||||||
if isinstance(block, dict) and block.get("type") == "text":
|
# Prepend files message as the first text block
|
||||||
text_parts.append(block.get("text", ""))
|
files_block = {"type": "text", "text": f"{files_message}\n\n"}
|
||||||
original_content = "\n".join(text_parts)
|
# Keep all original blocks (including images)
|
||||||
|
updated_content = [files_block, *original_content]
|
||||||
|
else:
|
||||||
|
# Other types, preserve as-is
|
||||||
|
updated_content = original_content
|
||||||
|
|
||||||
# Create new message with combined content.
|
# Create new message with combined content.
|
||||||
# Preserve additional_kwargs (including files metadata) so the frontend
|
# Preserve additional_kwargs (including files metadata) so the frontend
|
||||||
# can read structured file info from the streamed message.
|
# can read structured file info from the streamed message.
|
||||||
updated_message = HumanMessage(
|
updated_message = HumanMessage(
|
||||||
content=f"{files_message}\n\n{original_content}",
|
content=updated_content,
|
||||||
id=last_message.id,
|
id=last_message.id,
|
||||||
additional_kwargs=last_message.additional_kwargs,
|
additional_kwargs=last_message.additional_kwargs,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import uuid
|
|||||||
from collections.abc import Generator, Sequence
|
from collections.abc import Generator, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from langchain.agents import create_agent
|
from langchain.agents import create_agent
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
@@ -55,6 +55,9 @@ from deerflow.uploads.manager import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
StreamEventType = Literal["values", "messages-tuple", "custom", "end"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StreamEvent:
|
class StreamEvent:
|
||||||
"""A single event from the streaming agent response.
|
"""A single event from the streaming agent response.
|
||||||
@@ -69,7 +72,7 @@ class StreamEvent:
|
|||||||
data: Event payload. Contents vary by type.
|
data: Event payload. Contents vary by type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
type: str
|
type: StreamEventType
|
||||||
data: dict[str, Any] = field(default_factory=dict)
|
data: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -254,13 +257,53 @@ class DeerFlowClient:
|
|||||||
|
|
||||||
return get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled)
|
return get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_tool_calls(tool_calls) -> list[dict]:
|
||||||
|
"""Reshape LangChain tool_calls into the wire format used in events."""
|
||||||
|
return [{"name": tc["name"], "args": tc["args"], "id": tc.get("id")} for tc in tool_calls]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ai_text_event(msg_id: str | None, text: str, usage: dict | None) -> "StreamEvent":
|
||||||
|
"""Build a ``messages-tuple`` AI text event, attaching usage when present."""
|
||||||
|
data: dict[str, Any] = {"type": "ai", "content": text, "id": msg_id}
|
||||||
|
if usage:
|
||||||
|
data["usage_metadata"] = usage
|
||||||
|
return StreamEvent(type="messages-tuple", data=data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ai_tool_calls_event(msg_id: str | None, tool_calls) -> "StreamEvent":
|
||||||
|
"""Build a ``messages-tuple`` AI tool-calls event."""
|
||||||
|
return StreamEvent(
|
||||||
|
type="messages-tuple",
|
||||||
|
data={
|
||||||
|
"type": "ai",
|
||||||
|
"content": "",
|
||||||
|
"id": msg_id,
|
||||||
|
"tool_calls": DeerFlowClient._serialize_tool_calls(tool_calls),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tool_message_event(msg: ToolMessage) -> "StreamEvent":
|
||||||
|
"""Build a ``messages-tuple`` tool-result event from a ToolMessage."""
|
||||||
|
return StreamEvent(
|
||||||
|
type="messages-tuple",
|
||||||
|
data={
|
||||||
|
"type": "tool",
|
||||||
|
"content": DeerFlowClient._extract_text(msg.content),
|
||||||
|
"name": msg.name,
|
||||||
|
"tool_call_id": msg.tool_call_id,
|
||||||
|
"id": msg.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_message(msg) -> dict:
|
def _serialize_message(msg) -> dict:
|
||||||
"""Serialize a LangChain message to a plain dict for values events."""
|
"""Serialize a LangChain message to a plain dict for values events."""
|
||||||
if isinstance(msg, AIMessage):
|
if isinstance(msg, AIMessage):
|
||||||
d: dict[str, Any] = {"type": "ai", "content": msg.content, "id": getattr(msg, "id", None)}
|
d: dict[str, Any] = {"type": "ai", "content": msg.content, "id": getattr(msg, "id", None)}
|
||||||
if msg.tool_calls:
|
if msg.tool_calls:
|
||||||
d["tool_calls"] = [{"name": tc["name"], "args": tc["args"], "id": tc.get("id")} for tc in msg.tool_calls]
|
d["tool_calls"] = DeerFlowClient._serialize_tool_calls(msg.tool_calls)
|
||||||
if getattr(msg, "usage_metadata", None):
|
if getattr(msg, "usage_metadata", None):
|
||||||
d["usage_metadata"] = msg.usage_metadata
|
d["usage_metadata"] = msg.usage_metadata
|
||||||
return d
|
return d
|
||||||
@@ -315,6 +358,108 @@ class DeerFlowClient:
|
|||||||
return "\n".join(pieces) if pieces else ""
|
return "\n".join(pieces) if pieces else ""
|
||||||
return str(content)
|
return str(content)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API — threads
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_threads(self, limit: int = 10) -> dict:
|
||||||
|
"""List the recent N threads.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of threads to return. Default is 10.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with "thread_list" key containing list of thread info dicts,
|
||||||
|
sorted by thread creation time descending.
|
||||||
|
"""
|
||||||
|
checkpointer = self._checkpointer
|
||||||
|
if checkpointer is None:
|
||||||
|
from deerflow.agents.checkpointer.provider import get_checkpointer
|
||||||
|
|
||||||
|
checkpointer = get_checkpointer()
|
||||||
|
|
||||||
|
thread_info_map = {}
|
||||||
|
|
||||||
|
for cp in checkpointer.list(config=None, limit=limit):
|
||||||
|
cfg = cp.config.get("configurable", {})
|
||||||
|
thread_id = cfg.get("thread_id")
|
||||||
|
if not thread_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ts = cp.checkpoint.get("ts")
|
||||||
|
checkpoint_id = cfg.get("checkpoint_id")
|
||||||
|
|
||||||
|
if thread_id not in thread_info_map:
|
||||||
|
channel_values = cp.checkpoint.get("channel_values", {})
|
||||||
|
thread_info_map[thread_id] = {
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"created_at": ts,
|
||||||
|
"updated_at": ts,
|
||||||
|
"latest_checkpoint_id": checkpoint_id,
|
||||||
|
"title": channel_values.get("title"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Explicitly compare timestamps to ensure accuracy when iterating over unordered namespaces.
|
||||||
|
# Treat None as "missing" and only compare when existing values are non-None.
|
||||||
|
if ts is not None:
|
||||||
|
current_created = thread_info_map[thread_id]["created_at"]
|
||||||
|
if current_created is None or ts < current_created:
|
||||||
|
thread_info_map[thread_id]["created_at"] = ts
|
||||||
|
|
||||||
|
current_updated = thread_info_map[thread_id]["updated_at"]
|
||||||
|
if current_updated is None or ts > current_updated:
|
||||||
|
thread_info_map[thread_id]["updated_at"] = ts
|
||||||
|
thread_info_map[thread_id]["latest_checkpoint_id"] = checkpoint_id
|
||||||
|
channel_values = cp.checkpoint.get("channel_values", {})
|
||||||
|
thread_info_map[thread_id]["title"] = channel_values.get("title")
|
||||||
|
|
||||||
|
threads = list(thread_info_map.values())
|
||||||
|
threads.sort(key=lambda x: x.get("created_at") or "", reverse=True)
|
||||||
|
|
||||||
|
return {"thread_list": threads[:limit]}
|
||||||
|
|
||||||
|
def get_thread(self, thread_id: str) -> dict:
|
||||||
|
"""Get the complete thread record, including all node execution records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
thread_id: Thread ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing the thread's full checkpoint history.
|
||||||
|
"""
|
||||||
|
checkpointer = self._checkpointer
|
||||||
|
if checkpointer is None:
|
||||||
|
from deerflow.agents.checkpointer.provider import get_checkpointer
|
||||||
|
|
||||||
|
checkpointer = get_checkpointer()
|
||||||
|
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
checkpoints = []
|
||||||
|
|
||||||
|
for cp in checkpointer.list(config):
|
||||||
|
channel_values = dict(cp.checkpoint.get("channel_values", {}))
|
||||||
|
if "messages" in channel_values:
|
||||||
|
channel_values["messages"] = [self._serialize_message(m) if hasattr(m, "content") else m for m in channel_values["messages"]]
|
||||||
|
|
||||||
|
cfg = cp.config.get("configurable", {})
|
||||||
|
parent_cfg = cp.parent_config.get("configurable", {}) if cp.parent_config else {}
|
||||||
|
|
||||||
|
checkpoints.append(
|
||||||
|
{
|
||||||
|
"checkpoint_id": cfg.get("checkpoint_id"),
|
||||||
|
"parent_checkpoint_id": parent_cfg.get("checkpoint_id"),
|
||||||
|
"ts": cp.checkpoint.get("ts"),
|
||||||
|
"metadata": cp.metadata,
|
||||||
|
"values": channel_values,
|
||||||
|
"pending_writes": [{"task_id": w[0], "channel": w[1], "value": w[2]} for w in getattr(cp, "pending_writes", [])],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort globally by timestamp to prevent partial ordering issues caused by different namespaces (e.g., subgraphs)
|
||||||
|
checkpoints.sort(key=lambda x: x["ts"] if x["ts"] else "")
|
||||||
|
|
||||||
|
return {"thread_id": thread_id, "checkpoints": checkpoints}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public API — conversation
|
# Public API — conversation
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -336,6 +481,53 @@ class DeerFlowClient:
|
|||||||
consumers can switch between HTTP streaming and embedded mode
|
consumers can switch between HTTP streaming and embedded mode
|
||||||
without changing their event-handling logic.
|
without changing their event-handling logic.
|
||||||
|
|
||||||
|
Token-level streaming
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
This method subscribes to LangGraph's ``messages`` stream mode, so
|
||||||
|
``messages-tuple`` events for AI text are emitted as **deltas** as
|
||||||
|
the model generates tokens, not as one cumulative dump at node
|
||||||
|
completion. Each delta carries a stable ``id`` — consumers that
|
||||||
|
want the full text must accumulate ``content`` per ``id``.
|
||||||
|
``chat()`` already does this for you.
|
||||||
|
|
||||||
|
Tool calls and tool results are still emitted once per logical
|
||||||
|
message. ``values`` events continue to carry full state snapshots
|
||||||
|
after each graph node finishes; AI text already delivered via the
|
||||||
|
``messages`` stream is **not** re-synthesized from the snapshot to
|
||||||
|
avoid duplicate deliveries.
|
||||||
|
|
||||||
|
Why not reuse Gateway's ``run_agent``?
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
Gateway (``runtime/runs/worker.py``) has a complete streaming
|
||||||
|
pipeline: ``run_agent`` → ``StreamBridge`` → ``sse_consumer``. It
|
||||||
|
looks like this client duplicates that work, but the two paths
|
||||||
|
serve different audiences and **cannot** share execution:
|
||||||
|
|
||||||
|
* ``run_agent`` is ``async def`` and uses ``agent.astream()``;
|
||||||
|
this method is a sync generator using ``agent.stream()`` so
|
||||||
|
callers can write ``for event in client.stream(...)`` without
|
||||||
|
touching asyncio. Bridging the two would require spinning up
|
||||||
|
an event loop + thread per call.
|
||||||
|
* Gateway events are JSON-serialized by ``serialize()`` for SSE
|
||||||
|
wire transmission. This client yields in-process stream event
|
||||||
|
payloads directly as Python data structures (``StreamEvent``
|
||||||
|
with ``data`` as a plain ``dict``), without the extra
|
||||||
|
JSON/SSE serialization layer used for HTTP delivery.
|
||||||
|
* ``StreamBridge`` is an asyncio-queue decoupling producers from
|
||||||
|
consumers across an HTTP boundary (``Last-Event-ID`` replay,
|
||||||
|
heartbeats, multi-subscriber fan-out). A single in-process
|
||||||
|
caller with a direct iterator needs none of that.
|
||||||
|
|
||||||
|
So ``DeerFlowClient.stream()`` is a parallel, sync, in-process
|
||||||
|
consumer of the same ``create_agent()`` factory — not a wrapper
|
||||||
|
around Gateway. The two paths **should** stay in sync on which
|
||||||
|
LangGraph stream modes they subscribe to; that invariant is
|
||||||
|
enforced by ``tests/test_client.py::test_messages_mode_emits_token_deltas``
|
||||||
|
rather than by a shared constant, because the three layers
|
||||||
|
(Graph, Platform SDK, HTTP) each use their own naming
|
||||||
|
(``messages`` vs ``messages-tuple``) and cannot literally share
|
||||||
|
a string.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message: User message text.
|
message: User message text.
|
||||||
thread_id: Thread ID for conversation context. Auto-generated if None.
|
thread_id: Thread ID for conversation context. Auto-generated if None.
|
||||||
@@ -346,8 +538,8 @@ class DeerFlowClient:
|
|||||||
StreamEvent with one of:
|
StreamEvent with one of:
|
||||||
- type="values" data={"title": str|None, "messages": [...], "artifacts": [...]}
|
- type="values" data={"title": str|None, "messages": [...], "artifacts": [...]}
|
||||||
- type="custom" data={...}
|
- type="custom" data={...}
|
||||||
- type="messages-tuple" data={"type": "ai", "content": str, "id": str}
|
- type="messages-tuple" data={"type": "ai", "content": <delta>, "id": str}
|
||||||
- type="messages-tuple" data={"type": "ai", "content": str, "id": str, "usage_metadata": {...}}
|
- type="messages-tuple" data={"type": "ai", "content": <delta>, "id": str, "usage_metadata": {...}}
|
||||||
- type="messages-tuple" data={"type": "ai", "content": "", "id": str, "tool_calls": [...]}
|
- type="messages-tuple" data={"type": "ai", "content": "", "id": str, "tool_calls": [...]}
|
||||||
- type="messages-tuple" data={"type": "tool", "content": str, "name": str, "tool_call_id": str, "id": str}
|
- type="messages-tuple" data={"type": "tool", "content": str, "name": str, "tool_call_id": str, "id": str}
|
||||||
- type="end" data={"usage": {"input_tokens": int, "output_tokens": int, "total_tokens": int}}
|
- type="end" data={"usage": {"input_tokens": int, "output_tokens": int, "total_tokens": int}}
|
||||||
@@ -364,13 +556,47 @@ class DeerFlowClient:
|
|||||||
context["agent_name"] = self._agent_name
|
context["agent_name"] = self._agent_name
|
||||||
|
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
|
# Cross-mode handoff: ids already streamed via LangGraph ``messages``
|
||||||
|
# mode so the ``values`` path skips re-synthesis of the same message.
|
||||||
|
streamed_ids: set[str] = set()
|
||||||
|
# The same message id carries identical cumulative ``usage_metadata``
|
||||||
|
# in both the final ``messages`` chunk and the values snapshot —
|
||||||
|
# count it only on whichever arrives first.
|
||||||
|
counted_usage_ids: set[str] = set()
|
||||||
cumulative_usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
cumulative_usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||||
|
|
||||||
|
def _account_usage(msg_id: str | None, usage: Any) -> dict | None:
|
||||||
|
"""Add *usage* to cumulative totals if this id has not been counted.
|
||||||
|
|
||||||
|
``usage`` is a ``langchain_core.messages.UsageMetadata`` TypedDict
|
||||||
|
or ``None``; typed as ``Any`` because TypedDicts are not
|
||||||
|
structurally assignable to plain ``dict`` under strict type
|
||||||
|
checking. Returns the normalized usage dict (for attaching
|
||||||
|
to an event) when we accepted it, otherwise ``None``.
|
||||||
|
"""
|
||||||
|
if not usage:
|
||||||
|
return None
|
||||||
|
if msg_id and msg_id in counted_usage_ids:
|
||||||
|
return None
|
||||||
|
if msg_id:
|
||||||
|
counted_usage_ids.add(msg_id)
|
||||||
|
input_tokens = usage.get("input_tokens", 0) or 0
|
||||||
|
output_tokens = usage.get("output_tokens", 0) or 0
|
||||||
|
total_tokens = usage.get("total_tokens", 0) or 0
|
||||||
|
cumulative_usage["input_tokens"] += input_tokens
|
||||||
|
cumulative_usage["output_tokens"] += output_tokens
|
||||||
|
cumulative_usage["total_tokens"] += total_tokens
|
||||||
|
return {
|
||||||
|
"input_tokens": input_tokens,
|
||||||
|
"output_tokens": output_tokens,
|
||||||
|
"total_tokens": total_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
for item in self._agent.stream(
|
for item in self._agent.stream(
|
||||||
state,
|
state,
|
||||||
config=config,
|
config=config,
|
||||||
context=context,
|
context=context,
|
||||||
stream_mode=["values", "custom"],
|
stream_mode=["values", "messages", "custom"],
|
||||||
):
|
):
|
||||||
if isinstance(item, tuple) and len(item) == 2:
|
if isinstance(item, tuple) and len(item) == 2:
|
||||||
mode, chunk = item
|
mode, chunk = item
|
||||||
@@ -382,6 +608,36 @@ class DeerFlowClient:
|
|||||||
yield StreamEvent(type="custom", data=chunk)
|
yield StreamEvent(type="custom", data=chunk)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if mode == "messages":
|
||||||
|
# LangGraph ``messages`` mode emits ``(message_chunk, metadata)``.
|
||||||
|
if isinstance(chunk, tuple) and len(chunk) == 2:
|
||||||
|
msg_chunk, _metadata = chunk
|
||||||
|
else:
|
||||||
|
msg_chunk = chunk
|
||||||
|
|
||||||
|
msg_id = getattr(msg_chunk, "id", None)
|
||||||
|
|
||||||
|
if isinstance(msg_chunk, AIMessage):
|
||||||
|
text = self._extract_text(msg_chunk.content)
|
||||||
|
counted_usage = _account_usage(msg_id, msg_chunk.usage_metadata)
|
||||||
|
|
||||||
|
if text:
|
||||||
|
if msg_id:
|
||||||
|
streamed_ids.add(msg_id)
|
||||||
|
yield self._ai_text_event(msg_id, text, counted_usage)
|
||||||
|
|
||||||
|
if msg_chunk.tool_calls:
|
||||||
|
if msg_id:
|
||||||
|
streamed_ids.add(msg_id)
|
||||||
|
yield self._ai_tool_calls_event(msg_id, msg_chunk.tool_calls)
|
||||||
|
|
||||||
|
elif isinstance(msg_chunk, ToolMessage):
|
||||||
|
if msg_id:
|
||||||
|
streamed_ids.add(msg_id)
|
||||||
|
yield self._tool_message_event(msg_chunk)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# mode == "values"
|
||||||
messages = chunk.get("messages", [])
|
messages = chunk.get("messages", [])
|
||||||
|
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
@@ -391,47 +647,25 @@ class DeerFlowClient:
|
|||||||
if msg_id:
|
if msg_id:
|
||||||
seen_ids.add(msg_id)
|
seen_ids.add(msg_id)
|
||||||
|
|
||||||
|
# Already streamed via ``messages`` mode; only (defensively)
|
||||||
|
# capture usage here and skip re-synthesizing the event.
|
||||||
|
if msg_id and msg_id in streamed_ids:
|
||||||
|
if isinstance(msg, AIMessage):
|
||||||
|
_account_usage(msg_id, getattr(msg, "usage_metadata", None))
|
||||||
|
continue
|
||||||
|
|
||||||
if isinstance(msg, AIMessage):
|
if isinstance(msg, AIMessage):
|
||||||
# Track token usage from AI messages
|
counted_usage = _account_usage(msg_id, msg.usage_metadata)
|
||||||
usage = getattr(msg, "usage_metadata", None)
|
|
||||||
if usage:
|
|
||||||
cumulative_usage["input_tokens"] += usage.get("input_tokens", 0) or 0
|
|
||||||
cumulative_usage["output_tokens"] += usage.get("output_tokens", 0) or 0
|
|
||||||
cumulative_usage["total_tokens"] += usage.get("total_tokens", 0) or 0
|
|
||||||
|
|
||||||
if msg.tool_calls:
|
if msg.tool_calls:
|
||||||
yield StreamEvent(
|
yield self._ai_tool_calls_event(msg_id, msg.tool_calls)
|
||||||
type="messages-tuple",
|
|
||||||
data={
|
|
||||||
"type": "ai",
|
|
||||||
"content": "",
|
|
||||||
"id": msg_id,
|
|
||||||
"tool_calls": [{"name": tc["name"], "args": tc["args"], "id": tc.get("id")} for tc in msg.tool_calls],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
text = self._extract_text(msg.content)
|
text = self._extract_text(msg.content)
|
||||||
if text:
|
if text:
|
||||||
event_data: dict[str, Any] = {"type": "ai", "content": text, "id": msg_id}
|
yield self._ai_text_event(msg_id, text, counted_usage)
|
||||||
if usage:
|
|
||||||
event_data["usage_metadata"] = {
|
|
||||||
"input_tokens": usage.get("input_tokens", 0) or 0,
|
|
||||||
"output_tokens": usage.get("output_tokens", 0) or 0,
|
|
||||||
"total_tokens": usage.get("total_tokens", 0) or 0,
|
|
||||||
}
|
|
||||||
yield StreamEvent(type="messages-tuple", data=event_data)
|
|
||||||
|
|
||||||
elif isinstance(msg, ToolMessage):
|
elif isinstance(msg, ToolMessage):
|
||||||
yield StreamEvent(
|
yield self._tool_message_event(msg)
|
||||||
type="messages-tuple",
|
|
||||||
data={
|
|
||||||
"type": "tool",
|
|
||||||
"content": self._extract_text(msg.content),
|
|
||||||
"name": getattr(msg, "name", None),
|
|
||||||
"tool_call_id": getattr(msg, "tool_call_id", None),
|
|
||||||
"id": msg_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Emit a values event for each state snapshot
|
# Emit a values event for each state snapshot
|
||||||
yield StreamEvent(
|
yield StreamEvent(
|
||||||
@@ -448,10 +682,12 @@ class DeerFlowClient:
|
|||||||
def chat(self, message: str, *, thread_id: str | None = None, **kwargs) -> str:
|
def chat(self, message: str, *, thread_id: str | None = None, **kwargs) -> str:
|
||||||
"""Send a message and return the final text response.
|
"""Send a message and return the final text response.
|
||||||
|
|
||||||
Convenience wrapper around :meth:`stream` that returns only the
|
Convenience wrapper around :meth:`stream` that accumulates delta
|
||||||
**last** AI text from ``messages-tuple`` events. If the agent emits
|
``messages-tuple`` events per ``id`` and returns the text of the
|
||||||
multiple text segments in one turn, intermediate segments are
|
**last** AI message to complete. Intermediate AI messages (e.g.
|
||||||
discarded. Use :meth:`stream` directly to capture all events.
|
planner drafts) are discarded — only the final id's accumulated
|
||||||
|
text is returned. Use :meth:`stream` directly if you need every
|
||||||
|
delta as it arrives.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message: User message text.
|
message: User message text.
|
||||||
@@ -459,15 +695,21 @@ class DeerFlowClient:
|
|||||||
**kwargs: Override client defaults (same as stream()).
|
**kwargs: Override client defaults (same as stream()).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The last AI message text, or empty string if no response.
|
The accumulated text of the last AI message, or empty string
|
||||||
|
if no AI text was produced.
|
||||||
"""
|
"""
|
||||||
last_text = ""
|
# Per-id delta lists joined once at the end — avoids the O(n²) cost
|
||||||
|
# of repeated ``str + str`` on a growing buffer for long responses.
|
||||||
|
chunks: dict[str, list[str]] = {}
|
||||||
|
last_id: str = ""
|
||||||
for event in self.stream(message, thread_id=thread_id, **kwargs):
|
for event in self.stream(message, thread_id=thread_id, **kwargs):
|
||||||
if event.type == "messages-tuple" and event.data.get("type") == "ai":
|
if event.type == "messages-tuple" and event.data.get("type") == "ai":
|
||||||
content = event.data.get("content", "")
|
msg_id = event.data.get("id") or ""
|
||||||
if content:
|
delta = event.data.get("content", "")
|
||||||
last_text = content
|
if delta:
|
||||||
return last_text
|
chunks.setdefault(msg_id, []).append(delta)
|
||||||
|
last_id = msg_id
|
||||||
|
return "".join(chunks.get(last_id, ()))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public API — configuration queries
|
# Public API — configuration queries
|
||||||
@@ -480,6 +722,10 @@ class DeerFlowClient:
|
|||||||
Dict with "models" key containing list of model info dicts,
|
Dict with "models" key containing list of model info dicts,
|
||||||
matching the Gateway API ``ModelsListResponse`` schema.
|
matching the Gateway API ``ModelsListResponse`` schema.
|
||||||
"""
|
"""
|
||||||
|
token_usage_enabled = getattr(getattr(self._app_config, "token_usage", None), "enabled", False)
|
||||||
|
if not isinstance(token_usage_enabled, bool):
|
||||||
|
token_usage_enabled = False
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
@@ -491,7 +737,8 @@ class DeerFlowClient:
|
|||||||
"supports_reasoning_effort": getattr(model, "supports_reasoning_effort", False),
|
"supports_reasoning_effort": getattr(model, "supports_reasoning_effort", False),
|
||||||
}
|
}
|
||||||
for model in self._app_config.models
|
for model in self._app_config.models
|
||||||
]
|
],
|
||||||
|
"token_usage": {"enabled": token_usage_enabled},
|
||||||
}
|
}
|
||||||
|
|
||||||
def list_skills(self, enabled_only: bool = False) -> dict:
|
def list_skills(self, enabled_only: bool = False) -> dict:
|
||||||
|
|||||||
@@ -112,10 +112,23 @@ class AioSandboxProvider(SandboxProvider):
|
|||||||
atexit.register(self.shutdown)
|
atexit.register(self.shutdown)
|
||||||
self._register_signal_handlers()
|
self._register_signal_handlers()
|
||||||
|
|
||||||
|
# Reconcile orphaned containers from previous process lifecycles
|
||||||
|
self._reconcile_orphans()
|
||||||
|
|
||||||
# Start idle checker if enabled
|
# Start idle checker if enabled
|
||||||
if self._config.get("idle_timeout", DEFAULT_IDLE_TIMEOUT) > 0:
|
if self._config.get("idle_timeout", DEFAULT_IDLE_TIMEOUT) > 0:
|
||||||
self._start_idle_checker()
|
self._start_idle_checker()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uses_thread_data_mounts(self) -> bool:
|
||||||
|
"""Whether thread workspace/uploads/outputs are visible via mounts.
|
||||||
|
|
||||||
|
Local container backends bind-mount the thread data directories, so files
|
||||||
|
written by the gateway are already visible when the sandbox starts.
|
||||||
|
Remote backends may require explicit file sync.
|
||||||
|
"""
|
||||||
|
return isinstance(self._backend, LocalContainerBackend)
|
||||||
|
|
||||||
# ── Factory methods ──────────────────────────────────────────────────
|
# ── Factory methods ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def _create_backend(self) -> SandboxBackend:
|
def _create_backend(self) -> SandboxBackend:
|
||||||
@@ -175,6 +188,51 @@ class AioSandboxProvider(SandboxProvider):
|
|||||||
resolved[key] = str(value)
|
resolved[key] = str(value)
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
# ── Startup reconciliation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _reconcile_orphans(self) -> None:
|
||||||
|
"""Reconcile orphaned containers left by previous process lifecycles.
|
||||||
|
|
||||||
|
On startup, enumerate all running containers matching our prefix
|
||||||
|
and adopt them all into the warm pool. The idle checker will reclaim
|
||||||
|
containers that nobody re-acquires within ``idle_timeout``.
|
||||||
|
|
||||||
|
All containers are adopted unconditionally because we cannot
|
||||||
|
distinguish "orphaned" from "actively used by another process"
|
||||||
|
based on age alone — ``idle_timeout`` represents inactivity, not
|
||||||
|
uptime. Adopting into the warm pool and letting the idle checker
|
||||||
|
decide avoids destroying containers that a concurrent process may
|
||||||
|
still be using.
|
||||||
|
|
||||||
|
This closes the fundamental gap where in-memory state loss (process
|
||||||
|
restart, crash, SIGKILL) leaves Docker containers running forever.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
running = self._backend.list_running()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to enumerate running containers during startup reconciliation: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not running:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
adopted = 0
|
||||||
|
|
||||||
|
for info in running:
|
||||||
|
age = current_time - info.created_at if info.created_at > 0 else float("inf")
|
||||||
|
# Single lock acquisition per container: atomic check-and-insert.
|
||||||
|
# Avoids a TOCTOU window between the "already tracked?" check and
|
||||||
|
# the warm-pool insert.
|
||||||
|
with self._lock:
|
||||||
|
if info.sandbox_id in self._sandboxes or info.sandbox_id in self._warm_pool:
|
||||||
|
continue
|
||||||
|
self._warm_pool[info.sandbox_id] = (info, current_time)
|
||||||
|
adopted += 1
|
||||||
|
logger.info(f"Adopted container {info.sandbox_id} into warm pool (age: {age:.0f}s)")
|
||||||
|
|
||||||
|
logger.info(f"Startup reconciliation complete: {adopted} adopted into warm pool, {len(running)} total found")
|
||||||
|
|
||||||
# ── Deterministic ID ─────────────────────────────────────────────────
|
# ── Deterministic ID ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -316,13 +374,23 @@ class AioSandboxProvider(SandboxProvider):
|
|||||||
# ── Signal handling ──────────────────────────────────────────────────
|
# ── Signal handling ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def _register_signal_handlers(self) -> None:
|
def _register_signal_handlers(self) -> None:
|
||||||
"""Register signal handlers for graceful shutdown."""
|
"""Register signal handlers for graceful shutdown.
|
||||||
|
|
||||||
|
Handles SIGTERM, SIGINT, and SIGHUP (terminal close) to ensure
|
||||||
|
sandbox containers are cleaned up even when the user closes the terminal.
|
||||||
|
"""
|
||||||
self._original_sigterm = signal.getsignal(signal.SIGTERM)
|
self._original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||||
self._original_sigint = signal.getsignal(signal.SIGINT)
|
self._original_sigint = signal.getsignal(signal.SIGINT)
|
||||||
|
self._original_sighup = signal.getsignal(signal.SIGHUP) if hasattr(signal, "SIGHUP") else None
|
||||||
|
|
||||||
def signal_handler(signum, frame):
|
def signal_handler(signum, frame):
|
||||||
self.shutdown()
|
self.shutdown()
|
||||||
original = self._original_sigterm if signum == signal.SIGTERM else self._original_sigint
|
if signum == signal.SIGTERM:
|
||||||
|
original = self._original_sigterm
|
||||||
|
elif hasattr(signal, "SIGHUP") and signum == signal.SIGHUP:
|
||||||
|
original = self._original_sighup
|
||||||
|
else:
|
||||||
|
original = self._original_sigint
|
||||||
if callable(original):
|
if callable(original):
|
||||||
original(signum, frame)
|
original(signum, frame)
|
||||||
elif original == signal.SIG_DFL:
|
elif original == signal.SIG_DFL:
|
||||||
@@ -332,6 +400,8 @@ class AioSandboxProvider(SandboxProvider):
|
|||||||
try:
|
try:
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
if hasattr(signal, "SIGHUP"):
|
||||||
|
signal.signal(signal.SIGHUP, signal_handler)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.debug("Could not register signal handlers (not main thread)")
|
logger.debug("Could not register signal handlers (not main thread)")
|
||||||
|
|
||||||
|
|||||||
@@ -96,3 +96,19 @@ class SandboxBackend(ABC):
|
|||||||
SandboxInfo if found and healthy, None otherwise.
|
SandboxInfo if found and healthy, None otherwise.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def list_running(self) -> list[SandboxInfo]:
|
||||||
|
"""Enumerate all running sandboxes managed by this backend.
|
||||||
|
|
||||||
|
Used for startup reconciliation: when the process restarts, it needs
|
||||||
|
to discover containers started by previous processes so they can be
|
||||||
|
adopted into the warm pool or destroyed if idle too long.
|
||||||
|
|
||||||
|
The default implementation returns an empty list, which is correct
|
||||||
|
for backends that don't manage local containers (e.g., RemoteSandboxBackend
|
||||||
|
delegates lifecycle to the provisioner which handles its own cleanup).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of SandboxInfo for all currently running sandboxes.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ Handles container lifecycle, port allocation, and cross-process container discov
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from deerflow.utils.network import get_free_port, release_port
|
from deerflow.utils.network import get_free_port, release_port
|
||||||
|
|
||||||
@@ -18,6 +20,52 @@ from .sandbox_info import SandboxInfo
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_docker_timestamp(raw: str) -> float:
|
||||||
|
"""Parse Docker's ISO 8601 timestamp into a Unix epoch float.
|
||||||
|
|
||||||
|
Docker returns timestamps with nanosecond precision and a trailing ``Z``
|
||||||
|
(e.g. ``2026-04-08T01:22:50.123456789Z``). Python's ``fromisoformat``
|
||||||
|
accepts at most microseconds and (pre-3.11) does not accept ``Z``, so the
|
||||||
|
string is normalized before parsing. Returns ``0.0`` on empty input or
|
||||||
|
parse failure so callers can use ``0.0`` as a sentinel for "unknown age".
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
s = raw.strip()
|
||||||
|
if "." in s:
|
||||||
|
dot_pos = s.index(".")
|
||||||
|
tz_start = dot_pos + 1
|
||||||
|
while tz_start < len(s) and s[tz_start].isdigit():
|
||||||
|
tz_start += 1
|
||||||
|
frac = s[dot_pos + 1 : tz_start][:6] # truncate to microseconds
|
||||||
|
tz_suffix = s[tz_start:]
|
||||||
|
s = s[: dot_pos + 1] + frac + tz_suffix
|
||||||
|
if s.endswith("Z"):
|
||||||
|
s = s[:-1] + "+00:00"
|
||||||
|
return datetime.fromisoformat(s).timestamp()
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.debug(f"Could not parse docker timestamp {raw!r}: {e}")
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_host_port(inspect_entry: dict, container_port: int) -> int | None:
|
||||||
|
"""Extract the host port mapped to ``container_port/tcp`` from a docker inspect entry.
|
||||||
|
|
||||||
|
Returns None if the container has no port mapping for that port.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ports = (inspect_entry.get("NetworkSettings") or {}).get("Ports") or {}
|
||||||
|
bindings = ports.get(f"{container_port}/tcp") or []
|
||||||
|
if bindings:
|
||||||
|
host_port = bindings[0].get("HostPort")
|
||||||
|
if host_port:
|
||||||
|
return int(host_port)
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _format_container_mount(runtime: str, host_path: str, container_path: str, read_only: bool) -> list[str]:
|
def _format_container_mount(runtime: str, host_path: str, container_path: str, read_only: bool) -> list[str]:
|
||||||
"""Format a bind-mount argument for the selected runtime.
|
"""Format a bind-mount argument for the selected runtime.
|
||||||
|
|
||||||
@@ -172,8 +220,12 @@ class LocalContainerBackend(SandboxBackend):
|
|||||||
|
|
||||||
def destroy(self, info: SandboxInfo) -> None:
|
def destroy(self, info: SandboxInfo) -> None:
|
||||||
"""Stop the container and release its port."""
|
"""Stop the container and release its port."""
|
||||||
if info.container_id:
|
# Prefer container_id, fall back to container_name (both accepted by docker stop).
|
||||||
self._stop_container(info.container_id)
|
# This ensures containers discovered via list_running() (which only has the name)
|
||||||
|
# can also be stopped.
|
||||||
|
stop_target = info.container_id or info.container_name
|
||||||
|
if stop_target:
|
||||||
|
self._stop_container(stop_target)
|
||||||
# Extract port from sandbox_url for release
|
# Extract port from sandbox_url for release
|
||||||
try:
|
try:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -222,6 +274,129 @@ class LocalContainerBackend(SandboxBackend):
|
|||||||
container_name=container_name,
|
container_name=container_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_running(self) -> list[SandboxInfo]:
|
||||||
|
"""Enumerate all running containers matching the configured prefix.
|
||||||
|
|
||||||
|
Uses a single ``docker ps`` call to list container names, then a
|
||||||
|
single batched ``docker inspect`` call to retrieve creation timestamp
|
||||||
|
and port mapping for all containers at once. Total subprocess calls:
|
||||||
|
2 (down from 2N+1 in the naive per-container approach).
|
||||||
|
|
||||||
|
Note: Docker's ``--filter name=`` performs *substring* matching,
|
||||||
|
so a secondary ``startswith`` check is applied to ensure only
|
||||||
|
containers with the exact prefix are included.
|
||||||
|
|
||||||
|
Containers without port mappings are still included (with empty
|
||||||
|
sandbox_url) so that startup reconciliation can adopt orphans
|
||||||
|
regardless of their port state.
|
||||||
|
"""
|
||||||
|
# Step 1: enumerate container names via docker ps
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
self._runtime,
|
||||||
|
"ps",
|
||||||
|
"--filter",
|
||||||
|
f"name={self._container_prefix}-",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = (result.stderr or "").strip()
|
||||||
|
logger.warning(
|
||||||
|
"Failed to list running containers with %s ps (returncode=%s, stderr=%s)",
|
||||||
|
self._runtime,
|
||||||
|
result.returncode,
|
||||||
|
stderr or "<empty>",
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
if not result.stdout.strip():
|
||||||
|
return []
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||||
|
logger.warning(f"Failed to list running containers: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Filter to names matching our exact prefix (docker filter is substring-based)
|
||||||
|
container_names = [name.strip() for name in result.stdout.strip().splitlines() if name.strip().startswith(self._container_prefix + "-")]
|
||||||
|
if not container_names:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Step 2: batched docker inspect — single subprocess call for all containers
|
||||||
|
inspections = self._batch_inspect(container_names)
|
||||||
|
|
||||||
|
infos: list[SandboxInfo] = []
|
||||||
|
sandbox_host = os.environ.get("DEER_FLOW_SANDBOX_HOST", "localhost")
|
||||||
|
for container_name in container_names:
|
||||||
|
data = inspections.get(container_name)
|
||||||
|
if data is None:
|
||||||
|
# Container disappeared between ps and inspect, or inspect failed
|
||||||
|
continue
|
||||||
|
created_at, host_port = data
|
||||||
|
sandbox_id = container_name[len(self._container_prefix) + 1 :]
|
||||||
|
sandbox_url = f"http://{sandbox_host}:{host_port}" if host_port else ""
|
||||||
|
|
||||||
|
infos.append(
|
||||||
|
SandboxInfo(
|
||||||
|
sandbox_id=sandbox_id,
|
||||||
|
sandbox_url=sandbox_url,
|
||||||
|
container_name=container_name,
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(infos)} running sandbox container(s)")
|
||||||
|
return infos
|
||||||
|
|
||||||
|
def _batch_inspect(self, container_names: list[str]) -> dict[str, tuple[float, int | None]]:
|
||||||
|
"""Batch-inspect containers in a single subprocess call.
|
||||||
|
|
||||||
|
Returns a mapping of ``container_name -> (created_at, host_port)``.
|
||||||
|
Missing containers or parse failures are silently dropped from the result.
|
||||||
|
"""
|
||||||
|
if not container_names:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[self._runtime, "inspect", *container_names],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||||
|
logger.warning(f"Failed to batch-inspect containers: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = (result.stderr or "").strip()
|
||||||
|
logger.warning(
|
||||||
|
"Failed to batch-inspect containers with %s inspect (returncode=%s, stderr=%s)",
|
||||||
|
self._runtime,
|
||||||
|
result.returncode,
|
||||||
|
stderr or "<empty>",
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(result.stdout or "[]")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Failed to parse docker inspect output as JSON: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
out: dict[str, tuple[float, int | None]] = {}
|
||||||
|
for entry in payload:
|
||||||
|
# ``Name`` is prefixed with ``/`` in the docker inspect response
|
||||||
|
name = (entry.get("Name") or "").lstrip("/")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
created_at = _parse_docker_timestamp(entry.get("Created", ""))
|
||||||
|
host_port = _extract_host_port(entry, 8080)
|
||||||
|
out[name] = (created_at, host_port)
|
||||||
|
return out
|
||||||
|
|
||||||
# ── Container operations ─────────────────────────────────────────────
|
# ── Container operations ─────────────────────────────────────────────
|
||||||
|
|
||||||
def _start_container(
|
def _start_container(
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from exa_py import Exa
|
||||||
|
from langchain.tools import tool
|
||||||
|
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
|
||||||
|
|
||||||
|
def _get_exa_client(tool_name: str = "web_search") -> Exa:
|
||||||
|
config = get_app_config().get_tool_config(tool_name)
|
||||||
|
api_key = None
|
||||||
|
if config is not None and "api_key" in config.model_extra:
|
||||||
|
api_key = config.model_extra.get("api_key")
|
||||||
|
return Exa(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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = get_app_config().get_tool_config("web_search")
|
||||||
|
max_results = 5
|
||||||
|
search_type = "auto"
|
||||||
|
contents_max_characters = 1000
|
||||||
|
if config is not None:
|
||||||
|
max_results = config.model_extra.get("max_results", max_results)
|
||||||
|
search_type = config.model_extra.get("search_type", search_type)
|
||||||
|
contents_max_characters = config.model_extra.get("contents_max_characters", contents_max_characters)
|
||||||
|
|
||||||
|
client = _get_exa_client()
|
||||||
|
res = client.search(
|
||||||
|
query,
|
||||||
|
type=search_type,
|
||||||
|
num_results=max_results,
|
||||||
|
contents={"highlights": {"max_characters": contents_max_characters}},
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_results = [
|
||||||
|
{
|
||||||
|
"title": result.title or "",
|
||||||
|
"url": result.url or "",
|
||||||
|
"snippet": "\n".join(result.highlights) if result.highlights else "",
|
||||||
|
}
|
||||||
|
for result in res.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_exa_client("web_fetch")
|
||||||
|
res = client.get_contents([url], text={"max_characters": 4096})
|
||||||
|
|
||||||
|
if res.results:
|
||||||
|
result = res.results[0]
|
||||||
|
title = result.title or "Untitled"
|
||||||
|
text = result.text or ""
|
||||||
|
return f"# {title}\n\n{text[:4096]}"
|
||||||
|
else:
|
||||||
|
return "Error: No results found"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
@@ -6,10 +6,10 @@ from langchain.tools import tool
|
|||||||
from deerflow.config import get_app_config
|
from deerflow.config import get_app_config
|
||||||
|
|
||||||
|
|
||||||
def _get_firecrawl_client() -> FirecrawlApp:
|
def _get_firecrawl_client(tool_name: str = "web_search") -> FirecrawlApp:
|
||||||
config = get_app_config().get_tool_config("web_search")
|
config = get_app_config().get_tool_config(tool_name)
|
||||||
api_key = None
|
api_key = None
|
||||||
if config is not None:
|
if config is not None and "api_key" in config.model_extra:
|
||||||
api_key = config.model_extra.get("api_key")
|
api_key = config.model_extra.get("api_key")
|
||||||
return FirecrawlApp(api_key=api_key) # type: ignore[arg-type]
|
return FirecrawlApp(api_key=api_key) # type: ignore[arg-type]
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ def web_search_tool(query: str) -> str:
|
|||||||
if config is not None:
|
if config is not None:
|
||||||
max_results = config.model_extra.get("max_results", max_results)
|
max_results = config.model_extra.get("max_results", max_results)
|
||||||
|
|
||||||
client = _get_firecrawl_client()
|
client = _get_firecrawl_client("web_search")
|
||||||
result = client.search(query, limit=max_results)
|
result = client.search(query, limit=max_results)
|
||||||
|
|
||||||
# result.web contains list of SearchResultWeb objects
|
# result.web contains list of SearchResultWeb objects
|
||||||
@@ -58,7 +58,7 @@ def web_fetch_tool(url: str) -> str:
|
|||||||
url: The URL to fetch the contents of.
|
url: The URL to fetch the contents of.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = _get_firecrawl_client()
|
client = _get_firecrawl_client("web_fetch")
|
||||||
result = client.scrape(url, formats=["markdown"])
|
result = client.scrape(url, formats=["markdown"])
|
||||||
|
|
||||||
markdown_content = result.markdown or ""
|
markdown_content = result.markdown or ""
|
||||||
|
|||||||
@@ -38,6 +38,6 @@ class JinaClient:
|
|||||||
|
|
||||||
return response.text
|
return response.text
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = f"Request to Jina API failed: {str(e)}"
|
error_message = f"Request to Jina API failed: {type(e).__name__}: {e}"
|
||||||
logger.exception(error_message)
|
logger.warning(error_message)
|
||||||
return f"Error: {error_message}"
|
return f"Error: {error_message}"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
from langchain.tools import tool
|
from langchain.tools import tool
|
||||||
|
|
||||||
from deerflow.community.jina_ai.jina_client import JinaClient
|
from deerflow.community.jina_ai.jina_client import JinaClient
|
||||||
@@ -26,5 +28,5 @@ async def web_fetch_tool(url: str) -> str:
|
|||||||
html_content = await jina_client.crawl(url, return_format="html", timeout=timeout)
|
html_content = await jina_client.crawl(url, return_format="html", timeout=timeout)
|
||||||
if isinstance(html_content, str) and html_content.startswith("Error:"):
|
if isinstance(html_content, str) and html_content.startswith("Error:"):
|
||||||
return html_content
|
return html_content
|
||||||
article = readability_extractor.extract_article(html_content)
|
article = await asyncio.to_thread(readability_extractor.extract_article, html_content)
|
||||||
return article.to_markdown()[:4096]
|
return article.to_markdown()[:4096]
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Configuration for the custom agents management API."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AgentsApiConfig(BaseModel):
|
||||||
|
"""Configuration for custom-agent and user-profile management routes."""
|
||||||
|
|
||||||
|
enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=("Whether to expose the custom-agent management API over HTTP. When disabled, the gateway rejects read/write access to custom agent SOUL.md, config, and USER.md prompt-management routes."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_agents_api_config: AgentsApiConfig = AgentsApiConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def get_agents_api_config() -> AgentsApiConfig:
|
||||||
|
"""Get the current agents API configuration."""
|
||||||
|
return _agents_api_config
|
||||||
|
|
||||||
|
|
||||||
|
def set_agents_api_config(config: AgentsApiConfig) -> None:
|
||||||
|
"""Set the agents API configuration."""
|
||||||
|
global _agents_api_config
|
||||||
|
_agents_api_config = config
|
||||||
|
|
||||||
|
|
||||||
|
def load_agents_api_config_from_dict(config_dict: dict) -> None:
|
||||||
|
"""Load agents API configuration from a dictionary."""
|
||||||
|
global _agents_api_config
|
||||||
|
_agents_api_config = AgentsApiConfig(**config_dict)
|
||||||
@@ -15,6 +15,17 @@ SOUL_FILENAME = "SOUL.md"
|
|||||||
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_agent_name(name: str | None) -> str | None:
|
||||||
|
"""Validate a custom agent name before using it in filesystem paths."""
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise ValueError("Invalid agent name. Expected a string or None.")
|
||||||
|
if not AGENT_NAME_PATTERN.fullmatch(name):
|
||||||
|
raise ValueError(f"Invalid agent name '{name}'. Must match pattern: {AGENT_NAME_PATTERN.pattern}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
class AgentConfig(BaseModel):
|
class AgentConfig(BaseModel):
|
||||||
"""Configuration for a custom agent."""
|
"""Configuration for a custom agent."""
|
||||||
|
|
||||||
@@ -46,8 +57,7 @@ def load_agent_config(name: str | None) -> AgentConfig | None:
|
|||||||
if name is None:
|
if name is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not AGENT_NAME_PATTERN.match(name):
|
name = validate_agent_name(name)
|
||||||
raise ValueError(f"Invalid agent name '{name}'. Must match pattern: {AGENT_NAME_PATTERN.pattern}")
|
|
||||||
agent_dir = get_paths().agent_dir(name)
|
agent_dir = get_paths().agent_dir(name)
|
||||||
config_file = agent_dir / "config.yaml"
|
config_file = agent_dir / "config.yaml"
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from dotenv import load_dotenv
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from deerflow.config.acp_config import load_acp_config_from_dict
|
from deerflow.config.acp_config import load_acp_config_from_dict
|
||||||
|
from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict
|
||||||
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
|
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig
|
from deerflow.config.extensions_config import ExtensionsConfig
|
||||||
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
|
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
|
||||||
@@ -30,6 +31,13 @@ load_dotenv()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitBreakerConfig(BaseModel):
|
||||||
|
"""Configuration for the LLM Circuit Breaker."""
|
||||||
|
|
||||||
|
failure_threshold: int = Field(default=5, description="Number of consecutive failures before tripping the circuit")
|
||||||
|
recovery_timeout_sec: int = Field(default=60, description="Time in seconds before attempting to recover the circuit")
|
||||||
|
|
||||||
|
|
||||||
def _default_config_candidates() -> tuple[Path, ...]:
|
def _default_config_candidates() -> tuple[Path, ...]:
|
||||||
"""Return deterministic config.yaml locations without relying on cwd."""
|
"""Return deterministic config.yaml locations without relying on cwd."""
|
||||||
backend_dir = Path(__file__).resolve().parents[4]
|
backend_dir = Path(__file__).resolve().parents[4]
|
||||||
@@ -53,8 +61,10 @@ class AppConfig(BaseModel):
|
|||||||
title: TitleConfig = Field(default_factory=TitleConfig, description="Automatic title generation configuration")
|
title: TitleConfig = Field(default_factory=TitleConfig, description="Automatic title generation configuration")
|
||||||
summarization: SummarizationConfig = Field(default_factory=SummarizationConfig, description="Conversation summarization configuration")
|
summarization: SummarizationConfig = Field(default_factory=SummarizationConfig, description="Conversation summarization configuration")
|
||||||
memory: MemoryConfig = Field(default_factory=MemoryConfig, description="Memory subsystem configuration")
|
memory: MemoryConfig = Field(default_factory=MemoryConfig, description="Memory subsystem configuration")
|
||||||
|
agents_api: AgentsApiConfig = Field(default_factory=AgentsApiConfig, description="Custom-agent management API configuration")
|
||||||
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
|
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
|
||||||
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
|
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
|
||||||
|
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
|
||||||
model_config = ConfigDict(extra="allow", frozen=False)
|
model_config = ConfigDict(extra="allow", frozen=False)
|
||||||
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
|
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
|
||||||
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
|
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
|
||||||
@@ -117,6 +127,10 @@ class AppConfig(BaseModel):
|
|||||||
if "memory" in config_data:
|
if "memory" in config_data:
|
||||||
load_memory_config_from_dict(config_data["memory"])
|
load_memory_config_from_dict(config_data["memory"])
|
||||||
|
|
||||||
|
# Always refresh agents API config so removed config sections reset
|
||||||
|
# singleton-backed state to its default/disabled values on reload.
|
||||||
|
load_agents_api_config_from_dict(config_data.get("agents_api") or {})
|
||||||
|
|
||||||
# Load subagents config if present
|
# Load subagents config if present
|
||||||
if "subagents" in config_data:
|
if "subagents" in config_data:
|
||||||
load_subagents_config_from_dict(config_data["subagents"])
|
load_subagents_config_from_dict(config_data["subagents"])
|
||||||
@@ -129,6 +143,10 @@ class AppConfig(BaseModel):
|
|||||||
if "guardrails" in config_data:
|
if "guardrails" in config_data:
|
||||||
load_guardrails_config_from_dict(config_data["guardrails"])
|
load_guardrails_config_from_dict(config_data["guardrails"])
|
||||||
|
|
||||||
|
# Load circuit_breaker config if present
|
||||||
|
if "circuit_breaker" in config_data:
|
||||||
|
config_data["circuit_breaker"] = config_data["circuit_breaker"]
|
||||||
|
|
||||||
# Load checkpointer config if present
|
# Load checkpointer config if present
|
||||||
if "checkpointer" in config_data:
|
if "checkpointer" in config_data:
|
||||||
load_checkpointer_config_from_dict(config_data["checkpointer"])
|
load_checkpointer_config_from_dict(config_data["checkpointer"])
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class ModelConfig(BaseModel):
|
|||||||
default_factory=lambda: None,
|
default_factory=lambda: None,
|
||||||
description="Extra settings to be passed to the model when thinking is enabled",
|
description="Extra settings to be passed to the model when thinking is enabled",
|
||||||
)
|
)
|
||||||
|
when_thinking_disabled: dict | None = Field(
|
||||||
|
default_factory=lambda: None,
|
||||||
|
description="Extra settings to be passed to the model when thinking is disabled",
|
||||||
|
)
|
||||||
supports_vision: bool = Field(default_factory=lambda: False, description="Whether the model supports vision/image inputs")
|
supports_vision: bool = Field(default_factory=lambda: False, description="Whether the model supports vision/image inputs")
|
||||||
thinking: dict | None = Field(
|
thinking: dict | None = Field(
|
||||||
default_factory=lambda: None,
|
default_factory=lambda: None,
|
||||||
|
|||||||
@@ -20,6 +20,52 @@ class SubagentOverrideConfig(BaseModel):
|
|||||||
ge=1,
|
ge=1,
|
||||||
description="Maximum turns for this subagent (None = use global or builtin default)",
|
description="Maximum turns for this subagent (None = use global or builtin default)",
|
||||||
)
|
)
|
||||||
|
model: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
min_length=1,
|
||||||
|
description="Model name for this subagent (None = inherit from parent agent)",
|
||||||
|
)
|
||||||
|
skills: list[str] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Skill names whitelist for this subagent (None = inherit all enabled skills, [] = no skills)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomSubagentConfig(BaseModel):
|
||||||
|
"""User-defined subagent type declared in config.yaml."""
|
||||||
|
|
||||||
|
description: str = Field(
|
||||||
|
description="When the lead agent should delegate to this subagent",
|
||||||
|
)
|
||||||
|
system_prompt: str = Field(
|
||||||
|
description="System prompt that guides the subagent's behavior",
|
||||||
|
)
|
||||||
|
tools: list[str] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Tool names whitelist (None = inherit all tools from parent)",
|
||||||
|
)
|
||||||
|
disallowed_tools: list[str] | None = Field(
|
||||||
|
default_factory=lambda: ["task", "ask_clarification", "present_files"],
|
||||||
|
description="Tool names to deny",
|
||||||
|
)
|
||||||
|
skills: list[str] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Skill names whitelist (None = inherit all enabled skills, [] = no skills)",
|
||||||
|
)
|
||||||
|
model: str = Field(
|
||||||
|
default="inherit",
|
||||||
|
description="Model to use - 'inherit' uses parent's model",
|
||||||
|
)
|
||||||
|
max_turns: int = Field(
|
||||||
|
default=50,
|
||||||
|
ge=1,
|
||||||
|
description="Maximum number of agent turns before stopping",
|
||||||
|
)
|
||||||
|
timeout_seconds: int = Field(
|
||||||
|
default=900,
|
||||||
|
ge=1,
|
||||||
|
description="Maximum execution time in seconds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SubagentsAppConfig(BaseModel):
|
class SubagentsAppConfig(BaseModel):
|
||||||
@@ -39,6 +85,10 @@ class SubagentsAppConfig(BaseModel):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Per-agent configuration overrides keyed by agent name",
|
description="Per-agent configuration overrides keyed by agent name",
|
||||||
)
|
)
|
||||||
|
custom_agents: dict[str, CustomSubagentConfig] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="User-defined subagent types keyed by agent name",
|
||||||
|
)
|
||||||
|
|
||||||
def get_timeout_for(self, agent_name: str) -> int:
|
def get_timeout_for(self, agent_name: str) -> int:
|
||||||
"""Get the effective timeout for a specific agent.
|
"""Get the effective timeout for a specific agent.
|
||||||
@@ -54,6 +104,20 @@ class SubagentsAppConfig(BaseModel):
|
|||||||
return override.timeout_seconds
|
return override.timeout_seconds
|
||||||
return self.timeout_seconds
|
return self.timeout_seconds
|
||||||
|
|
||||||
|
def get_model_for(self, agent_name: str) -> str | None:
|
||||||
|
"""Get the model override for a specific agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_name: The name of the subagent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Model name if overridden, None otherwise (subagent will inherit parent model).
|
||||||
|
"""
|
||||||
|
override = self.agents.get(agent_name)
|
||||||
|
if override is not None and override.model is not None:
|
||||||
|
return override.model
|
||||||
|
return None
|
||||||
|
|
||||||
def get_max_turns_for(self, agent_name: str, builtin_default: int) -> int:
|
def get_max_turns_for(self, agent_name: str, builtin_default: int) -> int:
|
||||||
"""Get the effective max_turns for a specific agent."""
|
"""Get the effective max_turns for a specific agent."""
|
||||||
override = self.agents.get(agent_name)
|
override = self.agents.get(agent_name)
|
||||||
@@ -63,6 +127,20 @@ class SubagentsAppConfig(BaseModel):
|
|||||||
return self.max_turns
|
return self.max_turns
|
||||||
return builtin_default
|
return builtin_default
|
||||||
|
|
||||||
|
def get_skills_for(self, agent_name: str) -> list[str] | None:
|
||||||
|
"""Get the skills override for a specific agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_name: The name of the subagent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill names whitelist if overridden, None otherwise (subagent will inherit all enabled skills).
|
||||||
|
"""
|
||||||
|
override = self.agents.get(agent_name)
|
||||||
|
if override is not None and override.skills is not None:
|
||||||
|
return override.skills
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
_subagents_config: SubagentsAppConfig = SubagentsAppConfig()
|
_subagents_config: SubagentsAppConfig = SubagentsAppConfig()
|
||||||
|
|
||||||
@@ -84,15 +162,22 @@ def load_subagents_config_from_dict(config_dict: dict) -> None:
|
|||||||
parts.append(f"timeout={override.timeout_seconds}s")
|
parts.append(f"timeout={override.timeout_seconds}s")
|
||||||
if override.max_turns is not None:
|
if override.max_turns is not None:
|
||||||
parts.append(f"max_turns={override.max_turns}")
|
parts.append(f"max_turns={override.max_turns}")
|
||||||
|
if override.model is not None:
|
||||||
|
parts.append(f"model={override.model}")
|
||||||
|
if override.skills is not None:
|
||||||
|
parts.append(f"skills={override.skills}")
|
||||||
if parts:
|
if parts:
|
||||||
overrides_summary[name] = ", ".join(parts)
|
overrides_summary[name] = ", ".join(parts)
|
||||||
|
|
||||||
if overrides_summary:
|
custom_agents_names = list(_subagents_config.custom_agents.keys())
|
||||||
|
|
||||||
|
if overrides_summary or custom_agents_names:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s",
|
"Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s, custom_agents=%s",
|
||||||
_subagents_config.timeout_seconds,
|
_subagents_config.timeout_seconds,
|
||||||
_subagents_config.max_turns,
|
_subagents_config.max_turns,
|
||||||
overrides_summary,
|
overrides_summary or "none",
|
||||||
|
custom_agents_names or "none",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -51,6 +51,25 @@ class SummarizationConfig(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Custom prompt template for generating summaries. If not provided, uses the default LangChain prompt.",
|
description="Custom prompt template for generating summaries. If not provided, uses the default LangChain prompt.",
|
||||||
)
|
)
|
||||||
|
preserve_recent_skill_count: int = Field(
|
||||||
|
default=5,
|
||||||
|
ge=0,
|
||||||
|
description="Number of most-recently-loaded skill files to exclude from summarization. Set to 0 to disable skill preservation.",
|
||||||
|
)
|
||||||
|
preserve_recent_skill_tokens: int = Field(
|
||||||
|
default=25000,
|
||||||
|
ge=0,
|
||||||
|
description="Total token budget reserved for recently-loaded skill files that must be preserved across summarization.",
|
||||||
|
)
|
||||||
|
preserve_recent_skill_tokens_per_skill: int = Field(
|
||||||
|
default=5000,
|
||||||
|
ge=0,
|
||||||
|
description="Per-skill token cap when preserving skill files across summarization. Skill reads above this size are not rescued.",
|
||||||
|
)
|
||||||
|
skill_file_read_tool_names: list[str] = Field(
|
||||||
|
default_factory=lambda: ["read_file", "read", "view", "cat"],
|
||||||
|
description="Tool names treated as skill file reads when preserving recently-loaded skills across summarization.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global configuration instance
|
# Global configuration instance
|
||||||
|
|||||||
@@ -118,9 +118,13 @@ def get_cached_mcp_tools() -> list[BaseTool]:
|
|||||||
loop.run_until_complete(initialize_mcp_tools())
|
loop.run_until_complete(initialize_mcp_tools())
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# No event loop exists, create one
|
# No event loop exists, create one
|
||||||
asyncio.run(initialize_mcp_tools())
|
try:
|
||||||
except Exception as e:
|
asyncio.run(initialize_mcp_tools())
|
||||||
logger.error(f"Failed to lazy-initialize MCP tools: {e}")
|
except Exception:
|
||||||
|
logger.exception("Failed to lazy-initialize MCP tools")
|
||||||
|
return []
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to lazy-initialize MCP tools")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return _mcp_tools_cache or []
|
return _mcp_tools_cache or []
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from langchain_core.tools import BaseTool
|
|||||||
from deerflow.config.extensions_config import ExtensionsConfig
|
from deerflow.config.extensions_config import ExtensionsConfig
|
||||||
from deerflow.mcp.client import build_servers_config
|
from deerflow.mcp.client import build_servers_config
|
||||||
from deerflow.mcp.oauth import build_oauth_tool_interceptor, get_initial_oauth_headers
|
from deerflow.mcp.oauth import build_oauth_tool_interceptor, get_initial_oauth_headers
|
||||||
|
from deerflow.reflection import resolve_variable
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -95,6 +96,27 @@ async def get_mcp_tools() -> list[BaseTool]:
|
|||||||
if oauth_interceptor is not None:
|
if oauth_interceptor is not None:
|
||||||
tool_interceptors.append(oauth_interceptor)
|
tool_interceptors.append(oauth_interceptor)
|
||||||
|
|
||||||
|
# Load custom interceptors declared in extensions_config.json
|
||||||
|
# Format: "mcpInterceptors": ["pkg.module:builder_func", ...]
|
||||||
|
raw_interceptor_paths = (extensions_config.model_extra or {}).get("mcpInterceptors")
|
||||||
|
if isinstance(raw_interceptor_paths, str):
|
||||||
|
raw_interceptor_paths = [raw_interceptor_paths]
|
||||||
|
elif not isinstance(raw_interceptor_paths, list):
|
||||||
|
if raw_interceptor_paths is not None:
|
||||||
|
logger.warning(f"mcpInterceptors must be a list of strings, got {type(raw_interceptor_paths).__name__}; skipping")
|
||||||
|
raw_interceptor_paths = []
|
||||||
|
for interceptor_path in raw_interceptor_paths:
|
||||||
|
try:
|
||||||
|
builder = resolve_variable(interceptor_path)
|
||||||
|
interceptor = builder()
|
||||||
|
if callable(interceptor):
|
||||||
|
tool_interceptors.append(interceptor)
|
||||||
|
logger.info(f"Loaded MCP interceptor: {interceptor_path}")
|
||||||
|
elif interceptor is not None:
|
||||||
|
logger.warning(f"Builder {interceptor_path} returned non-callable {type(interceptor).__name__}; skipping")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load MCP interceptor {interceptor_path}: {e}", exc_info=True)
|
||||||
|
|
||||||
client = MultiServerMCPClient(servers_config, tool_interceptors=tool_interceptors, tool_name_prefix=True)
|
client = MultiServerMCPClient(servers_config, tool_interceptors=tool_interceptors, tool_name_prefix=True)
|
||||||
|
|
||||||
# Get all tools from all servers
|
# Get all tools from all servers
|
||||||
|
|||||||
@@ -190,23 +190,33 @@ class ClaudeChatModel(ChatAnthropic):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _apply_prompt_caching(self, payload: dict) -> None:
|
def _apply_prompt_caching(self, payload: dict) -> None:
|
||||||
"""Apply ephemeral cache_control to system and recent messages."""
|
"""Apply ephemeral cache_control to system, recent messages, and last tool definition.
|
||||||
# Cache system messages
|
|
||||||
|
Uses a budget of MAX_CACHE_BREAKPOINTS (4) breakpoints — the hard limit
|
||||||
|
enforced by both the Anthropic API and AWS Bedrock. Breakpoints are
|
||||||
|
placed on the *last* eligible blocks because later breakpoints cover a
|
||||||
|
larger prefix and yield better cache hit rates.
|
||||||
|
"""
|
||||||
|
MAX_CACHE_BREAKPOINTS = 4
|
||||||
|
|
||||||
|
# Collect candidate blocks in document order:
|
||||||
|
# 1. system text blocks
|
||||||
|
# 2. content blocks of the last prompt_cache_size messages
|
||||||
|
# 3. the last tool definition
|
||||||
|
candidates: list[dict] = []
|
||||||
|
|
||||||
|
# 1. System blocks
|
||||||
system = payload.get("system")
|
system = payload.get("system")
|
||||||
if system and isinstance(system, list):
|
if system and isinstance(system, list):
|
||||||
for block in system:
|
for block in system:
|
||||||
if isinstance(block, dict) and block.get("type") == "text":
|
if isinstance(block, dict) and block.get("type") == "text":
|
||||||
block["cache_control"] = {"type": "ephemeral"}
|
candidates.append(block)
|
||||||
elif system and isinstance(system, str):
|
elif system and isinstance(system, str):
|
||||||
payload["system"] = [
|
new_block: dict = {"type": "text", "text": system}
|
||||||
{
|
payload["system"] = [new_block]
|
||||||
"type": "text",
|
candidates.append(new_block)
|
||||||
"text": system,
|
|
||||||
"cache_control": {"type": "ephemeral"},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Cache recent messages
|
# 2. Recent message blocks
|
||||||
messages = payload.get("messages", [])
|
messages = payload.get("messages", [])
|
||||||
cache_start = max(0, len(messages) - self.prompt_cache_size)
|
cache_start = max(0, len(messages) - self.prompt_cache_size)
|
||||||
for i in range(cache_start, len(messages)):
|
for i in range(cache_start, len(messages)):
|
||||||
@@ -217,20 +227,21 @@ class ClaudeChatModel(ChatAnthropic):
|
|||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
for block in content:
|
for block in content:
|
||||||
if isinstance(block, dict):
|
if isinstance(block, dict):
|
||||||
block["cache_control"] = {"type": "ephemeral"}
|
candidates.append(block)
|
||||||
elif isinstance(content, str) and content:
|
elif isinstance(content, str) and content:
|
||||||
msg["content"] = [
|
new_block = {"type": "text", "text": content}
|
||||||
{
|
msg["content"] = [new_block]
|
||||||
"type": "text",
|
candidates.append(new_block)
|
||||||
"text": content,
|
|
||||||
"cache_control": {"type": "ephemeral"},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Cache the last tool definition
|
# 3. Last tool definition
|
||||||
tools = payload.get("tools", [])
|
tools = payload.get("tools", [])
|
||||||
if tools and isinstance(tools[-1], dict):
|
if tools and isinstance(tools[-1], dict):
|
||||||
tools[-1]["cache_control"] = {"type": "ephemeral"}
|
candidates.append(tools[-1])
|
||||||
|
|
||||||
|
# Apply cache_control only to the last MAX_CACHE_BREAKPOINTS candidates
|
||||||
|
# to stay within the API limit.
|
||||||
|
for block in candidates[-MAX_CACHE_BREAKPOINTS:]:
|
||||||
|
block["cache_control"] = {"type": "ephemeral"}
|
||||||
|
|
||||||
def _apply_thinking_budget(self, payload: dict) -> None:
|
def _apply_thinking_budget(self, payload: dict) -> None:
|
||||||
"""Auto-allocate thinking budget (80% of max_tokens)."""
|
"""Auto-allocate thinking budget (80% of max_tokens)."""
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ def _vllm_disable_chat_template_kwargs(chat_template_kwargs: dict) -> dict:
|
|||||||
return disable_kwargs
|
return disable_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_config: dict) -> None:
|
||||||
|
"""Enable stream usage for OpenAI-compatible models unless explicitly configured.
|
||||||
|
|
||||||
|
LangChain only auto-enables ``stream_usage`` for OpenAI models when no custom
|
||||||
|
base URL or client is configured. DeerFlow frequently uses OpenAI-compatible
|
||||||
|
gateways, so token usage tracking would otherwise stay empty and the
|
||||||
|
TokenUsageMiddleware would have nothing to log.
|
||||||
|
"""
|
||||||
|
if model_use_path != "langchain_openai:ChatOpenAI":
|
||||||
|
return
|
||||||
|
if "stream_usage" in model_settings_from_config:
|
||||||
|
return
|
||||||
|
if "base_url" in model_settings_from_config or "openai_api_base" in model_settings_from_config:
|
||||||
|
model_settings_from_config["stream_usage"] = True
|
||||||
|
|
||||||
|
|
||||||
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, **kwargs) -> BaseChatModel:
|
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, **kwargs) -> BaseChatModel:
|
||||||
"""Create a chat model instance from the config.
|
"""Create a chat model instance from the config.
|
||||||
|
|
||||||
@@ -56,6 +72,7 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
|
|||||||
"supports_thinking",
|
"supports_thinking",
|
||||||
"supports_reasoning_effort",
|
"supports_reasoning_effort",
|
||||||
"when_thinking_enabled",
|
"when_thinking_enabled",
|
||||||
|
"when_thinking_disabled",
|
||||||
"thinking",
|
"thinking",
|
||||||
"supports_vision",
|
"supports_vision",
|
||||||
},
|
},
|
||||||
@@ -72,27 +89,32 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
|
|||||||
raise ValueError(f"Model {name} does not support thinking. Set `supports_thinking` to true in the `config.yaml` to enable thinking.") from None
|
raise ValueError(f"Model {name} does not support thinking. Set `supports_thinking` to true in the `config.yaml` to enable thinking.") from None
|
||||||
if effective_wte:
|
if effective_wte:
|
||||||
model_settings_from_config.update(effective_wte)
|
model_settings_from_config.update(effective_wte)
|
||||||
if not thinking_enabled and has_thinking_settings:
|
if not thinking_enabled:
|
||||||
if effective_wte.get("extra_body", {}).get("thinking", {}).get("type"):
|
if model_config.when_thinking_disabled is not None:
|
||||||
|
# User-provided disable settings take full precedence
|
||||||
|
model_settings_from_config.update(model_config.when_thinking_disabled)
|
||||||
|
elif has_thinking_settings and effective_wte.get("extra_body", {}).get("thinking", {}).get("type"):
|
||||||
# OpenAI-compatible gateway: thinking is nested under extra_body
|
# OpenAI-compatible gateway: thinking is nested under extra_body
|
||||||
model_settings_from_config["extra_body"] = _deep_merge_dicts(
|
model_settings_from_config["extra_body"] = _deep_merge_dicts(
|
||||||
model_settings_from_config.get("extra_body"),
|
model_settings_from_config.get("extra_body"),
|
||||||
{"thinking": {"type": "disabled"}},
|
{"thinking": {"type": "disabled"}},
|
||||||
)
|
)
|
||||||
model_settings_from_config["reasoning_effort"] = "minimal"
|
model_settings_from_config["reasoning_effort"] = "minimal"
|
||||||
elif disable_chat_template_kwargs := _vllm_disable_chat_template_kwargs(effective_wte.get("extra_body", {}).get("chat_template_kwargs") or {}):
|
elif has_thinking_settings and (disable_chat_template_kwargs := _vllm_disable_chat_template_kwargs(effective_wte.get("extra_body", {}).get("chat_template_kwargs") or {})):
|
||||||
# vLLM uses chat template kwargs to switch thinking on/off.
|
# vLLM uses chat template kwargs to switch thinking on/off.
|
||||||
model_settings_from_config["extra_body"] = _deep_merge_dicts(
|
model_settings_from_config["extra_body"] = _deep_merge_dicts(
|
||||||
model_settings_from_config.get("extra_body"),
|
model_settings_from_config.get("extra_body"),
|
||||||
{"chat_template_kwargs": disable_chat_template_kwargs},
|
{"chat_template_kwargs": disable_chat_template_kwargs},
|
||||||
)
|
)
|
||||||
elif effective_wte.get("thinking", {}).get("type"):
|
elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"):
|
||||||
# Native langchain_anthropic: thinking is a direct constructor parameter
|
# Native langchain_anthropic: thinking is a direct constructor parameter
|
||||||
model_settings_from_config["thinking"] = {"type": "disabled"}
|
model_settings_from_config["thinking"] = {"type": "disabled"}
|
||||||
if not model_config.supports_reasoning_effort:
|
if not model_config.supports_reasoning_effort:
|
||||||
kwargs.pop("reasoning_effort", None)
|
kwargs.pop("reasoning_effort", None)
|
||||||
model_settings_from_config.pop("reasoning_effort", None)
|
model_settings_from_config.pop("reasoning_effort", None)
|
||||||
|
|
||||||
|
_enable_stream_usage_by_default(model_config.use, model_settings_from_config)
|
||||||
|
|
||||||
# For Codex Responses API models: map thinking mode to reasoning_effort
|
# For Codex Responses API models: map thinking mode to reasoning_effort
|
||||||
from deerflow.models.openai_codex_provider import CodexChatModel
|
from deerflow.models.openai_codex_provider import CodexChatModel
|
||||||
|
|
||||||
@@ -109,7 +131,13 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
|
|||||||
elif "reasoning_effort" not in model_settings_from_config:
|
elif "reasoning_effort" not in model_settings_from_config:
|
||||||
model_settings_from_config["reasoning_effort"] = "medium"
|
model_settings_from_config["reasoning_effort"] = "medium"
|
||||||
|
|
||||||
model_instance = model_class(**kwargs, **model_settings_from_config)
|
# For MindIE models: enforce conservative retry defaults.
|
||||||
|
# Timeout normalization is handled inside MindIEChatModel itself.
|
||||||
|
if getattr(model_class, "__name__", "") == "MindIEChatModel":
|
||||||
|
# Enforce max_retries constraint to prevent cascading timeouts.
|
||||||
|
model_settings_from_config["max_retries"] = model_settings_from_config.get("max_retries", 1)
|
||||||
|
|
||||||
|
model_instance = model_class(**{**model_settings_from_config, **kwargs})
|
||||||
|
|
||||||
callbacks = build_tracing_callbacks()
|
callbacks = build_tracing_callbacks()
|
||||||
if callbacks:
|
if callbacks:
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
|
||||||
|
from langchain_core.outputs import ChatGenerationChunk, ChatResult
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_messages(messages: list) -> list:
|
||||||
|
"""Sanitize incoming messages for MindIE compatibility.
|
||||||
|
|
||||||
|
MindIE's chat template may fail to parse LangChain's native tool_calls
|
||||||
|
or ToolMessage roles, resulting in 0-token generation errors. This function
|
||||||
|
flattens multi-modal list contents into strings and converts tool-related
|
||||||
|
messages into raw text with XML tags expected by the underlying model.
|
||||||
|
"""
|
||||||
|
fixed = []
|
||||||
|
for msg in messages:
|
||||||
|
# Flatten content if it's a list of blocks
|
||||||
|
if isinstance(msg.content, list):
|
||||||
|
parts = []
|
||||||
|
for block in msg.content:
|
||||||
|
if isinstance(block, str):
|
||||||
|
parts.append(block)
|
||||||
|
elif isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
parts.append(block.get("text", ""))
|
||||||
|
text = "".join(parts)
|
||||||
|
else:
|
||||||
|
text = msg.content or ""
|
||||||
|
|
||||||
|
# Convert AIMessage with tool_calls to raw XML text format
|
||||||
|
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", []):
|
||||||
|
xml_parts = []
|
||||||
|
for tool in msg.tool_calls:
|
||||||
|
args_xml = " ".join(f"<parameter={k}>{json.dumps(v, ensure_ascii=False)}</parameter>" for k, v in tool.get("args", {}).items())
|
||||||
|
xml_parts.append(f"<tool_call> <function={tool['name']}> {args_xml} </function> </tool_call>")
|
||||||
|
full_text = f"{text}\n" + "\n".join(xml_parts) if text else "\n".join(xml_parts)
|
||||||
|
fixed.append(AIMessage(content=full_text.strip() or " "))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Wrap tool execution results in XML tags and convert to HumanMessage
|
||||||
|
if isinstance(msg, ToolMessage):
|
||||||
|
tool_result_text = f"<tool_response>\n{text}\n</tool_response>"
|
||||||
|
fixed.append(HumanMessage(content=tool_result_text))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fallback to prevent completely empty message content
|
||||||
|
if not text.strip():
|
||||||
|
text = " "
|
||||||
|
|
||||||
|
fixed.append(msg.model_copy(update={"content": text}))
|
||||||
|
|
||||||
|
return fixed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_xml_tool_call_to_dict(content: str) -> tuple[str, list[dict]]:
|
||||||
|
"""Parse XML-style tool calls from model output into LangChain dicts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The raw text output from the model.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple containing the cleaned text (with XML blocks removed) and
|
||||||
|
a list of tool call dictionaries formatted for LangChain.
|
||||||
|
"""
|
||||||
|
if not isinstance(content, str) or "<tool_call>" not in content:
|
||||||
|
return content, []
|
||||||
|
|
||||||
|
tool_calls = []
|
||||||
|
clean_parts: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
for start, end, inner_content in _iter_tool_call_blocks(content):
|
||||||
|
clean_parts.append(content[cursor:start])
|
||||||
|
cursor = end
|
||||||
|
|
||||||
|
func_match = re.search(r"<function=([^>]+)>", inner_content)
|
||||||
|
if not func_match:
|
||||||
|
continue
|
||||||
|
function_name = func_match.group(1).strip()
|
||||||
|
|
||||||
|
args = {}
|
||||||
|
param_pattern = re.compile(r"<parameter=([^>]+)>(.*?)</parameter>", re.DOTALL)
|
||||||
|
for param_match in param_pattern.finditer(inner_content):
|
||||||
|
key = param_match.group(1).strip()
|
||||||
|
raw_value = param_match.group(2).strip()
|
||||||
|
|
||||||
|
# Attempt to deserialize string values into native Python types
|
||||||
|
# to satisfy downstream Pydantic validation.
|
||||||
|
parsed_value = raw_value
|
||||||
|
if raw_value.startswith(("[", "{")) or raw_value in ("true", "false", "null") or raw_value.isdigit():
|
||||||
|
try:
|
||||||
|
parsed_value = json.loads(raw_value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
try:
|
||||||
|
parsed_value = ast.literal_eval(raw_value)
|
||||||
|
except (ValueError, SyntaxError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
args[key] = parsed_value
|
||||||
|
|
||||||
|
tool_calls.append({"name": function_name, "args": args, "id": f"call_{uuid.uuid4().hex[:10]}"})
|
||||||
|
clean_parts.append(content[cursor:])
|
||||||
|
|
||||||
|
return "".join(clean_parts).strip(), tool_calls
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_tool_call_blocks(content: str) -> Iterator[tuple[int, int, str]]:
|
||||||
|
"""Iterate `<tool_call>...</tool_call>` blocks and tolerate nesting."""
|
||||||
|
token_pattern = re.compile(r"</?tool_call>")
|
||||||
|
depth = 0
|
||||||
|
block_start = -1
|
||||||
|
|
||||||
|
for match in token_pattern.finditer(content):
|
||||||
|
token = match.group(0)
|
||||||
|
if token == "<tool_call>":
|
||||||
|
if depth == 0:
|
||||||
|
block_start = match.start()
|
||||||
|
depth += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if depth == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0 and block_start != -1:
|
||||||
|
block_end = match.end()
|
||||||
|
inner_start = block_start + len("<tool_call>")
|
||||||
|
inner_end = match.start()
|
||||||
|
yield block_start, block_end, content[inner_start:inner_end]
|
||||||
|
block_start = -1
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_escaped_newlines_outside_fences(content: str) -> str:
|
||||||
|
"""Decode literal `\\n` outside fenced code blocks."""
|
||||||
|
if "\\n" not in content:
|
||||||
|
return content
|
||||||
|
|
||||||
|
parts = re.split(r"(```[\s\S]*?```)", content)
|
||||||
|
for idx, part in enumerate(parts):
|
||||||
|
if part.startswith("```"):
|
||||||
|
continue
|
||||||
|
parts[idx] = part.replace("\\n", "\n")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class MindIEChatModel(ChatOpenAI):
|
||||||
|
"""Chat model adapter for MindIE engine.
|
||||||
|
|
||||||
|
Addresses compatibility issues including:
|
||||||
|
- Flattening multimodal list contents to strings.
|
||||||
|
- Intercepting and parsing hardcoded XML tool calls into LangChain standard.
|
||||||
|
- Handling stream=True dropping choices when tools are present by falling back
|
||||||
|
to non-streaming generation and yielding simulated chunks.
|
||||||
|
- Fixing over-escaped newline characters from gateway responses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
"""Normalize timeout kwargs without creating long-lived clients."""
|
||||||
|
connect_timeout = kwargs.pop("connect_timeout", 30.0)
|
||||||
|
read_timeout = kwargs.pop("read_timeout", 900.0)
|
||||||
|
write_timeout = kwargs.pop("write_timeout", 60.0)
|
||||||
|
pool_timeout = kwargs.pop("pool_timeout", 30.0)
|
||||||
|
|
||||||
|
kwargs.setdefault(
|
||||||
|
"timeout",
|
||||||
|
httpx.Timeout(
|
||||||
|
connect=connect_timeout,
|
||||||
|
read=read_timeout,
|
||||||
|
write=write_timeout,
|
||||||
|
pool=pool_timeout,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def _patch_result_with_tools(self, result: ChatResult) -> ChatResult:
|
||||||
|
"""Apply post-generation fixes to the model result."""
|
||||||
|
for gen in result.generations:
|
||||||
|
msg = gen.message
|
||||||
|
|
||||||
|
if isinstance(msg.content, str):
|
||||||
|
# Keep escaped newlines inside fenced code blocks untouched.
|
||||||
|
msg.content = _decode_escaped_newlines_outside_fences(msg.content)
|
||||||
|
|
||||||
|
if "<tool_call>" in msg.content:
|
||||||
|
clean_content, extracted_tools = _parse_xml_tool_call_to_dict(msg.content)
|
||||||
|
|
||||||
|
if extracted_tools:
|
||||||
|
msg.content = clean_content
|
||||||
|
if getattr(msg, "tool_calls", None) is None:
|
||||||
|
msg.tool_calls = []
|
||||||
|
msg.tool_calls.extend(extracted_tools)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||||
|
result = super()._generate(_fix_messages(messages), stop=stop, run_manager=run_manager, **kwargs)
|
||||||
|
return self._patch_result_with_tools(result)
|
||||||
|
|
||||||
|
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||||
|
result = await super()._agenerate(_fix_messages(messages), stop=stop, run_manager=run_manager, **kwargs)
|
||||||
|
return self._patch_result_with_tools(result)
|
||||||
|
|
||||||
|
async def _astream(self, messages, stop=None, run_manager=None, **kwargs):
|
||||||
|
# Route standard queries to native streaming for lower TTFB
|
||||||
|
if not kwargs.get("tools"):
|
||||||
|
async for chunk in super()._astream(_fix_messages(messages), stop=stop, run_manager=run_manager, **kwargs):
|
||||||
|
if isinstance(chunk.message.content, str):
|
||||||
|
chunk.message.content = _decode_escaped_newlines_outside_fences(chunk.message.content)
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fallback for tool-enabled requests:
|
||||||
|
# MindIE currently drops choices when stream=True and tools are present.
|
||||||
|
# We await the full generation and yield chunks to simulate streaming.
|
||||||
|
result = await self._agenerate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
||||||
|
|
||||||
|
for gen in result.generations:
|
||||||
|
msg = gen.message
|
||||||
|
content = msg.content
|
||||||
|
standard_tool_calls = getattr(msg, "tool_calls", [])
|
||||||
|
|
||||||
|
# Yield text in chunks to allow downstream UI/Markdown parsers to render smoothly
|
||||||
|
if isinstance(content, str) and content:
|
||||||
|
chunk_size = 15
|
||||||
|
for i in range(0, len(content), chunk_size):
|
||||||
|
chunk_text = content[i : i + chunk_size]
|
||||||
|
chunk_msg = AIMessageChunk(content=chunk_text, id=msg.id, response_metadata=msg.response_metadata if i == 0 else {})
|
||||||
|
yield ChatGenerationChunk(message=chunk_msg, generation_info=gen.generation_info if i == 0 else None)
|
||||||
|
|
||||||
|
if standard_tool_calls:
|
||||||
|
yield ChatGenerationChunk(message=AIMessageChunk(content="", id=msg.id, tool_calls=standard_tool_calls, invalid_tool_calls=getattr(msg, "invalid_tool_calls", [])))
|
||||||
|
else:
|
||||||
|
chunk_msg = AIMessageChunk(content=content, id=msg.id, tool_calls=standard_tool_calls, invalid_tool_calls=getattr(msg, "invalid_tool_calls", []))
|
||||||
|
yield ChatGenerationChunk(message=chunk_msg, generation_info=gen.generation_info)
|
||||||
@@ -48,6 +48,10 @@ class CodexChatModel(BaseChatModel):
|
|||||||
|
|
||||||
model_config = {"arbitrary_types_allowed": True}
|
model_config = {"arbitrary_types_allowed": True}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_lc_serializable(cls) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _llm_type(self) -> str:
|
def _llm_type(self) -> str:
|
||||||
return "codex-responses"
|
return "codex-responses"
|
||||||
@@ -216,18 +220,48 @@ class CodexChatModel(BaseChatModel):
|
|||||||
def _stream_response(self, headers: dict, payload: dict) -> dict:
|
def _stream_response(self, headers: dict, payload: dict) -> dict:
|
||||||
"""Stream SSE from Codex API and collect the final response."""
|
"""Stream SSE from Codex API and collect the final response."""
|
||||||
completed_response = None
|
completed_response = None
|
||||||
|
streamed_output_items: dict[int, dict[str, Any]] = {}
|
||||||
|
|
||||||
with httpx.Client(timeout=300) as client:
|
with httpx.Client(timeout=300) as client:
|
||||||
with client.stream("POST", f"{CODEX_BASE_URL}/responses", headers=headers, json=payload) as resp:
|
with client.stream("POST", f"{CODEX_BASE_URL}/responses", headers=headers, json=payload) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
for line in resp.iter_lines():
|
for line in resp.iter_lines():
|
||||||
data = self._parse_sse_data_line(line)
|
data = self._parse_sse_data_line(line)
|
||||||
if data and data.get("type") == "response.completed":
|
if not data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_type = data.get("type")
|
||||||
|
if event_type == "response.output_item.done":
|
||||||
|
output_index = data.get("output_index")
|
||||||
|
output_item = data.get("item")
|
||||||
|
if isinstance(output_index, int) and isinstance(output_item, dict):
|
||||||
|
streamed_output_items[output_index] = output_item
|
||||||
|
elif event_type == "response.completed":
|
||||||
completed_response = data["response"]
|
completed_response = data["response"]
|
||||||
|
|
||||||
if not completed_response:
|
if not completed_response:
|
||||||
raise RuntimeError("Codex API stream ended without response.completed event")
|
raise RuntimeError("Codex API stream ended without response.completed event")
|
||||||
|
|
||||||
|
# ChatGPT Codex can emit the final assistant content only in stream events.
|
||||||
|
# When response.completed arrives, response.output may still be empty.
|
||||||
|
if streamed_output_items:
|
||||||
|
merged_output = []
|
||||||
|
response_output = completed_response.get("output")
|
||||||
|
if isinstance(response_output, list):
|
||||||
|
merged_output = list(response_output)
|
||||||
|
|
||||||
|
max_index = max(max(streamed_output_items), len(merged_output) - 1)
|
||||||
|
if max_index >= 0 and len(merged_output) <= max_index:
|
||||||
|
merged_output.extend([None] * (max_index + 1 - len(merged_output)))
|
||||||
|
|
||||||
|
for output_index, output_item in streamed_output_items.items():
|
||||||
|
existing_item = merged_output[output_index]
|
||||||
|
if not isinstance(existing_item, dict):
|
||||||
|
merged_output[output_index] = output_item
|
||||||
|
|
||||||
|
completed_response = dict(completed_response)
|
||||||
|
completed_response["output"] = [item for item in merged_output if isinstance(item, dict)]
|
||||||
|
|
||||||
return completed_response
|
return completed_response
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ class PatchedChatDeepSeek(ChatDeepSeek):
|
|||||||
request payload.
|
request payload.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_lc_serializable(cls) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lc_secrets(self) -> dict[str, str]:
|
||||||
|
return {"api_key": "DEEPSEEK_API_KEY", "openai_api_key": "DEEPSEEK_API_KEY"}
|
||||||
|
|
||||||
def _get_request_payload(
|
def _get_request_payload(
|
||||||
self,
|
self,
|
||||||
input_: LanguageModelInput,
|
input_: LanguageModelInput,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ internal checkpoint callbacks that are not exposed in the Python public API.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
@@ -51,6 +53,9 @@ async def run_agent(
|
|||||||
run_id = record.run_id
|
run_id = record.run_id
|
||||||
thread_id = record.thread_id
|
thread_id = record.thread_id
|
||||||
requested_modes: set[str] = set(stream_modes or ["values"])
|
requested_modes: set[str] = set(stream_modes or ["values"])
|
||||||
|
pre_run_checkpoint_id: str | None = None
|
||||||
|
pre_run_snapshot: dict[str, Any] | None = None
|
||||||
|
snapshot_capture_failed = False
|
||||||
|
|
||||||
# Track whether "events" was requested but skipped
|
# Track whether "events" was requested but skipped
|
||||||
if "events" in requested_modes:
|
if "events" in requested_modes:
|
||||||
@@ -63,15 +68,23 @@ async def run_agent(
|
|||||||
# 1. Mark running
|
# 1. Mark running
|
||||||
await run_manager.set_status(run_id, RunStatus.running)
|
await run_manager.set_status(run_id, RunStatus.running)
|
||||||
|
|
||||||
# Record pre-run checkpoint_id to support rollback (Phase 2).
|
# Snapshot the latest pre-run checkpoint so rollback can restore it.
|
||||||
pre_run_checkpoint_id = None
|
if checkpointer is not None:
|
||||||
try:
|
try:
|
||||||
config_for_check = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
config_for_check = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||||
ckpt_tuple = await checkpointer.aget_tuple(config_for_check)
|
ckpt_tuple = await checkpointer.aget_tuple(config_for_check)
|
||||||
if ckpt_tuple is not None:
|
if ckpt_tuple is not None:
|
||||||
pre_run_checkpoint_id = getattr(ckpt_tuple, "config", {}).get("configurable", {}).get("checkpoint_id")
|
ckpt_config = getattr(ckpt_tuple, "config", {}).get("configurable", {})
|
||||||
except Exception:
|
pre_run_checkpoint_id = ckpt_config.get("checkpoint_id")
|
||||||
logger.debug("Could not get pre-run checkpoint_id for run %s", run_id)
|
pre_run_snapshot = {
|
||||||
|
"checkpoint_ns": ckpt_config.get("checkpoint_ns", ""),
|
||||||
|
"checkpoint": copy.deepcopy(getattr(ckpt_tuple, "checkpoint", {})),
|
||||||
|
"metadata": copy.deepcopy(getattr(ckpt_tuple, "metadata", {})),
|
||||||
|
"pending_writes": copy.deepcopy(getattr(ckpt_tuple, "pending_writes", []) or []),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
snapshot_capture_failed = True
|
||||||
|
logger.warning("Could not capture pre-run checkpoint snapshot for run %s", run_id, exc_info=True)
|
||||||
|
|
||||||
# 2. Publish metadata — useStream needs both run_id AND thread_id
|
# 2. Publish metadata — useStream needs both run_id AND thread_id
|
||||||
await bridge.publish(
|
await bridge.publish(
|
||||||
@@ -172,17 +185,18 @@ async def run_agent(
|
|||||||
action = record.abort_action
|
action = record.abort_action
|
||||||
if action == "rollback":
|
if action == "rollback":
|
||||||
await run_manager.set_status(run_id, RunStatus.error, error="Rolled back by user")
|
await run_manager.set_status(run_id, RunStatus.error, error="Rolled back by user")
|
||||||
# TODO(Phase 2): Implement full checkpoint rollback.
|
|
||||||
# Use pre_run_checkpoint_id to revert the thread's checkpoint
|
|
||||||
# to the state before this run started. Requires a
|
|
||||||
# checkpointer.adelete() or equivalent API.
|
|
||||||
try:
|
try:
|
||||||
if checkpointer is not None and pre_run_checkpoint_id is not None:
|
await _rollback_to_pre_run_checkpoint(
|
||||||
# Phase 2: roll back to pre_run_checkpoint_id
|
checkpointer=checkpointer,
|
||||||
pass
|
thread_id=thread_id,
|
||||||
logger.info("Run %s rolled back", run_id)
|
run_id=run_id,
|
||||||
|
pre_run_checkpoint_id=pre_run_checkpoint_id,
|
||||||
|
pre_run_snapshot=pre_run_snapshot,
|
||||||
|
snapshot_capture_failed=snapshot_capture_failed,
|
||||||
|
)
|
||||||
|
logger.info("Run %s rolled back to pre-run checkpoint %s", run_id, pre_run_checkpoint_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to rollback checkpoint for run %s", run_id)
|
logger.warning("Failed to rollback checkpoint for run %s", run_id, exc_info=True)
|
||||||
else:
|
else:
|
||||||
await run_manager.set_status(run_id, RunStatus.interrupted)
|
await run_manager.set_status(run_id, RunStatus.interrupted)
|
||||||
else:
|
else:
|
||||||
@@ -192,7 +206,18 @@ async def run_agent(
|
|||||||
action = record.abort_action
|
action = record.abort_action
|
||||||
if action == "rollback":
|
if action == "rollback":
|
||||||
await run_manager.set_status(run_id, RunStatus.error, error="Rolled back by user")
|
await run_manager.set_status(run_id, RunStatus.error, error="Rolled back by user")
|
||||||
logger.info("Run %s was cancelled (rollback)", run_id)
|
try:
|
||||||
|
await _rollback_to_pre_run_checkpoint(
|
||||||
|
checkpointer=checkpointer,
|
||||||
|
thread_id=thread_id,
|
||||||
|
run_id=run_id,
|
||||||
|
pre_run_checkpoint_id=pre_run_checkpoint_id,
|
||||||
|
pre_run_snapshot=pre_run_snapshot,
|
||||||
|
snapshot_capture_failed=snapshot_capture_failed,
|
||||||
|
)
|
||||||
|
logger.info("Run %s was cancelled and rolled back", run_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Run %s cancellation rollback failed", run_id, exc_info=True)
|
||||||
else:
|
else:
|
||||||
await run_manager.set_status(run_id, RunStatus.interrupted)
|
await run_manager.set_status(run_id, RunStatus.interrupted)
|
||||||
logger.info("Run %s was cancelled", run_id)
|
logger.info("Run %s was cancelled", run_id)
|
||||||
@@ -220,6 +245,104 @@ async def run_agent(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_checkpointer_method(checkpointer: Any, async_name: str, sync_name: str, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
"""Call a checkpointer method, supporting async and sync variants."""
|
||||||
|
method = getattr(checkpointer, async_name, None) or getattr(checkpointer, sync_name, None)
|
||||||
|
if method is None:
|
||||||
|
raise AttributeError(f"Missing checkpointer method: {async_name}/{sync_name}")
|
||||||
|
result = method(*args, **kwargs)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
return await result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _rollback_to_pre_run_checkpoint(
|
||||||
|
*,
|
||||||
|
checkpointer: Any,
|
||||||
|
thread_id: str,
|
||||||
|
run_id: str,
|
||||||
|
pre_run_checkpoint_id: str | None,
|
||||||
|
pre_run_snapshot: dict[str, Any] | None,
|
||||||
|
snapshot_capture_failed: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Restore thread state to the checkpoint snapshot captured before run start."""
|
||||||
|
if checkpointer is None:
|
||||||
|
logger.info("Run %s rollback requested but no checkpointer is configured", run_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if snapshot_capture_failed:
|
||||||
|
logger.warning("Run %s rollback skipped: pre-run checkpoint snapshot capture failed", run_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if pre_run_snapshot is None:
|
||||||
|
await _call_checkpointer_method(checkpointer, "adelete_thread", "delete_thread", thread_id)
|
||||||
|
logger.info("Run %s rollback reset thread %s to empty state", run_id, thread_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
checkpoint_to_restore = None
|
||||||
|
metadata_to_restore: dict[str, Any] = {}
|
||||||
|
checkpoint_ns = ""
|
||||||
|
checkpoint = pre_run_snapshot.get("checkpoint")
|
||||||
|
if not isinstance(checkpoint, dict):
|
||||||
|
logger.warning("Run %s rollback skipped: invalid pre-run checkpoint snapshot", run_id)
|
||||||
|
return
|
||||||
|
checkpoint_to_restore = checkpoint
|
||||||
|
if checkpoint_to_restore.get("id") is None and pre_run_checkpoint_id is not None:
|
||||||
|
checkpoint_to_restore = {**checkpoint_to_restore, "id": pre_run_checkpoint_id}
|
||||||
|
if checkpoint_to_restore.get("id") is None:
|
||||||
|
logger.warning("Run %s rollback skipped: pre-run checkpoint has no checkpoint id", run_id)
|
||||||
|
return
|
||||||
|
metadata = pre_run_snapshot.get("metadata", {})
|
||||||
|
metadata_to_restore = metadata if isinstance(metadata, dict) else {}
|
||||||
|
raw_checkpoint_ns = pre_run_snapshot.get("checkpoint_ns")
|
||||||
|
checkpoint_ns = raw_checkpoint_ns if isinstance(raw_checkpoint_ns, str) else ""
|
||||||
|
|
||||||
|
channel_versions = checkpoint_to_restore.get("channel_versions")
|
||||||
|
new_versions = dict(channel_versions) if isinstance(channel_versions, dict) else {}
|
||||||
|
|
||||||
|
restore_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": checkpoint_ns}}
|
||||||
|
restored_config = await _call_checkpointer_method(
|
||||||
|
checkpointer,
|
||||||
|
"aput",
|
||||||
|
"put",
|
||||||
|
restore_config,
|
||||||
|
checkpoint_to_restore,
|
||||||
|
metadata_to_restore if isinstance(metadata_to_restore, dict) else {},
|
||||||
|
new_versions,
|
||||||
|
)
|
||||||
|
if not isinstance(restored_config, dict):
|
||||||
|
raise RuntimeError(f"Run {run_id} rollback restore returned invalid config: expected dict")
|
||||||
|
restored_configurable = restored_config.get("configurable", {})
|
||||||
|
if not isinstance(restored_configurable, dict):
|
||||||
|
raise RuntimeError(f"Run {run_id} rollback restore returned invalid config payload")
|
||||||
|
restored_checkpoint_id = restored_configurable.get("checkpoint_id")
|
||||||
|
if not restored_checkpoint_id:
|
||||||
|
raise RuntimeError(f"Run {run_id} rollback restore did not return checkpoint_id")
|
||||||
|
|
||||||
|
pending_writes = pre_run_snapshot.get("pending_writes", [])
|
||||||
|
if not pending_writes:
|
||||||
|
return
|
||||||
|
|
||||||
|
writes_by_task: dict[str, list[tuple[str, Any]]] = {}
|
||||||
|
for item in pending_writes:
|
||||||
|
if not isinstance(item, (tuple, list)) or len(item) != 3:
|
||||||
|
raise RuntimeError(f"Run {run_id} rollback failed: pending_write is not a 3-tuple: {item!r}")
|
||||||
|
task_id, channel, value = item
|
||||||
|
if not isinstance(channel, str):
|
||||||
|
raise RuntimeError(f"Run {run_id} rollback failed: pending_write has non-string channel: task_id={task_id!r}, channel={channel!r}")
|
||||||
|
writes_by_task.setdefault(str(task_id), []).append((channel, value))
|
||||||
|
|
||||||
|
for task_id, writes in writes_by_task.items():
|
||||||
|
await _call_checkpointer_method(
|
||||||
|
checkpointer,
|
||||||
|
"aput_writes",
|
||||||
|
"put_writes",
|
||||||
|
restored_config,
|
||||||
|
writes,
|
||||||
|
task_id=task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _lg_mode_to_sse_event(mode: str) -> str:
|
def _lg_mode_to_sse_event(mode: str) -> str:
|
||||||
"""Map LangGraph internal stream_mode name to SSE event name.
|
"""Map LangGraph internal stream_mode name to SSE event name.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import threading
|
import threading
|
||||||
|
import weakref
|
||||||
|
|
||||||
from deerflow.sandbox.sandbox import Sandbox
|
from deerflow.sandbox.sandbox import Sandbox
|
||||||
|
|
||||||
_FILE_OPERATION_LOCKS: dict[tuple[str, str], threading.Lock] = {}
|
# Use WeakValueDictionary to prevent memory leak in long-running processes.
|
||||||
|
# Locks are automatically removed when no longer referenced by any thread.
|
||||||
|
_LockKey = tuple[str, str]
|
||||||
|
_FILE_OPERATION_LOCKS: weakref.WeakValueDictionary[_LockKey, threading.Lock] = weakref.WeakValueDictionary()
|
||||||
_FILE_OPERATION_LOCKS_GUARD = threading.Lock()
|
_FILE_OPERATION_LOCKS_GUARD = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ class LocalSandbox(Sandbox):
|
|||||||
"""
|
"""
|
||||||
super().__init__(id)
|
super().__init__(id)
|
||||||
self.path_mappings = path_mappings or []
|
self.path_mappings = path_mappings or []
|
||||||
|
# Track files written through write_file so read_file only
|
||||||
|
# reverse-resolves paths in agent-authored content.
|
||||||
|
self._agent_written_paths: set[str] = set()
|
||||||
|
|
||||||
def _is_read_only_path(self, resolved_path: str) -> bool:
|
def _is_read_only_path(self, resolved_path: str) -> bool:
|
||||||
"""Check if a resolved path is under a read-only mount.
|
"""Check if a resolved path is under a read-only mount.
|
||||||
@@ -205,6 +208,39 @@ class LocalSandbox(Sandbox):
|
|||||||
|
|
||||||
return pattern.sub(replace_match, command)
|
return pattern.sub(replace_match, command)
|
||||||
|
|
||||||
|
def _resolve_paths_in_content(self, content: str) -> str:
|
||||||
|
"""Resolve container paths to local paths in arbitrary file content.
|
||||||
|
|
||||||
|
Unlike ``_resolve_paths_in_command`` which uses shell-aware boundary
|
||||||
|
characters, this method treats the content as plain text and resolves
|
||||||
|
every occurrence of a container path prefix. Resolved paths are
|
||||||
|
normalized to forward slashes to avoid backslash-escape issues on
|
||||||
|
Windows hosts (e.g. ``C:\\Users\\..`` breaking Python string literals).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: File content that may contain container paths.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Content with container paths resolved to local paths (forward slashes).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
sorted_mappings = sorted(self.path_mappings, key=lambda m: len(m.container_path), reverse=True)
|
||||||
|
if not sorted_mappings:
|
||||||
|
return content
|
||||||
|
|
||||||
|
patterns = [re.escape(m.container_path) + r"(?=/|$|[^\w./-])(?:/[^\s\"';&|<>()]*)?" for m in sorted_mappings]
|
||||||
|
pattern = re.compile("|".join(f"({p})" for p in patterns))
|
||||||
|
|
||||||
|
def replace_match(match: re.Match) -> str:
|
||||||
|
matched_path = match.group(0)
|
||||||
|
resolved = self._resolve_path(matched_path)
|
||||||
|
# Normalize to forward slashes so that Windows backslash paths
|
||||||
|
# don't create invalid escape sequences in source files.
|
||||||
|
return resolved.replace("\\", "/")
|
||||||
|
|
||||||
|
return pattern.sub(replace_match, content)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_shell() -> str:
|
def _get_shell() -> str:
|
||||||
"""Detect available shell executable with fallback."""
|
"""Detect available shell executable with fallback."""
|
||||||
@@ -252,10 +288,10 @@ class LocalSandbox(Sandbox):
|
|||||||
timeout=600,
|
timeout=600,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
args = [shell, "-c", resolved_command]
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
resolved_command,
|
args,
|
||||||
executable=shell,
|
shell=False,
|
||||||
shell=True,
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=600,
|
timeout=600,
|
||||||
@@ -280,7 +316,14 @@ class LocalSandbox(Sandbox):
|
|||||||
resolved_path = self._resolve_path(path)
|
resolved_path = self._resolve_path(path)
|
||||||
try:
|
try:
|
||||||
with open(resolved_path, encoding="utf-8") as f:
|
with open(resolved_path, encoding="utf-8") as f:
|
||||||
return f.read()
|
content = f.read()
|
||||||
|
# Only reverse-resolve paths in files that were previously written
|
||||||
|
# by write_file (agent-authored content). User-uploaded files,
|
||||||
|
# external tool output, and other non-agent content should not be
|
||||||
|
# silently rewritten — see discussion on PR #1935.
|
||||||
|
if resolved_path in self._agent_written_paths:
|
||||||
|
content = self._reverse_resolve_paths_in_output(content)
|
||||||
|
return content
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
# Re-raise with the original path for clearer error messages, hiding internal resolved paths
|
# Re-raise with the original path for clearer error messages, hiding internal resolved paths
|
||||||
raise type(e)(e.errno, e.strerror, path) from None
|
raise type(e)(e.errno, e.strerror, path) from None
|
||||||
@@ -293,9 +336,16 @@ class LocalSandbox(Sandbox):
|
|||||||
dir_path = os.path.dirname(resolved_path)
|
dir_path = os.path.dirname(resolved_path)
|
||||||
if dir_path:
|
if dir_path:
|
||||||
os.makedirs(dir_path, exist_ok=True)
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
|
# Resolve container paths in content to local paths
|
||||||
|
# using the content-specific resolver (forward-slash safe)
|
||||||
|
resolved_content = self._resolve_paths_in_content(content)
|
||||||
mode = "a" if append else "w"
|
mode = "a" if append else "w"
|
||||||
with open(resolved_path, mode, encoding="utf-8") as f:
|
with open(resolved_path, mode, encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(resolved_content)
|
||||||
|
# Track this path so read_file knows to reverse-resolve on read.
|
||||||
|
# Only agent-written files get reverse-resolved; user uploads and
|
||||||
|
# external tool output are left untouched.
|
||||||
|
self._agent_written_paths.add(resolved_path)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
# Re-raise with the original path for clearer error messages, hiding internal resolved paths
|
# Re-raise with the original path for clearer error messages, hiding internal resolved paths
|
||||||
raise type(e)(e.errno, e.strerror, path) from None
|
raise type(e)(e.errno, e.strerror, path) from None
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ _singleton: LocalSandbox | None = None
|
|||||||
|
|
||||||
|
|
||||||
class LocalSandboxProvider(SandboxProvider):
|
class LocalSandboxProvider(SandboxProvider):
|
||||||
|
uses_thread_data_mounts = True
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the local sandbox provider with path mappings."""
|
"""Initialize the local sandbox provider with path mappings."""
|
||||||
self._path_mappings = self._setup_path_mappings()
|
self._path_mappings = self._setup_path_mappings()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from langgraph.runtime import Runtime
|
|||||||
|
|
||||||
from deerflow.agents.thread_state import SandboxState, ThreadDataState
|
from deerflow.agents.thread_state import SandboxState, ThreadDataState
|
||||||
from deerflow.sandbox import get_sandbox_provider
|
from deerflow.sandbox import get_sandbox_provider
|
||||||
|
from deerflow.utils.runtime import get_thread_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]):
|
|||||||
|
|
||||||
# Eager initialization (original behavior)
|
# Eager initialization (original behavior)
|
||||||
if "sandbox" not in state or state["sandbox"] is None:
|
if "sandbox" not in state or state["sandbox"] is None:
|
||||||
thread_id = (runtime.context or {}).get("thread_id")
|
thread_id = get_thread_id(runtime)
|
||||||
if thread_id is None:
|
if thread_id is None:
|
||||||
return super().before_agent(state, runtime)
|
return super().before_agent(state, runtime)
|
||||||
sandbox_id = self._acquire_sandbox(thread_id)
|
sandbox_id = self._acquire_sandbox(thread_id)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from deerflow.sandbox.sandbox import Sandbox
|
|||||||
class SandboxProvider(ABC):
|
class SandboxProvider(ABC):
|
||||||
"""Abstract base class for sandbox providers"""
|
"""Abstract base class for sandbox providers"""
|
||||||
|
|
||||||
|
uses_thread_data_mounts: bool = False
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def acquire(self, thread_id: str | None = None) -> str:
|
def acquire(self, thread_id: str | None = None) -> str:
|
||||||
"""Acquire a sandbox environment and return its ID.
|
"""Acquire a sandbox environment and return its ID.
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def is_host_bash_allowed(config=None) -> bool:
|
|||||||
|
|
||||||
sandbox_cfg = getattr(config, "sandbox", None)
|
sandbox_cfg = getattr(config, "sandbox", None)
|
||||||
if sandbox_cfg is None:
|
if sandbox_cfg is None:
|
||||||
return True
|
return False
|
||||||
if not uses_local_sandbox_provider(config):
|
if not uses_local_sandbox_provider(config):
|
||||||
return True
|
return True
|
||||||
return bool(getattr(sandbox_cfg, "allow_host_bash", False))
|
return bool(getattr(sandbox_cfg, "allow_host_bash", False))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from deerflow.sandbox.sandbox import Sandbox
|
|||||||
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
|
||||||
from deerflow.sandbox.search import GrepMatch
|
from deerflow.sandbox.search import GrepMatch
|
||||||
from deerflow.sandbox.security import LOCAL_HOST_BASH_DISABLED_MESSAGE, is_host_bash_allowed
|
from deerflow.sandbox.security import LOCAL_HOST_BASH_DISABLED_MESSAGE, is_host_bash_allowed
|
||||||
|
from deerflow.utils.runtime import get_thread_id
|
||||||
|
|
||||||
_ABSOLUTE_PATH_PATTERN = re.compile(r"(?<![:\w])(?<!:/)/(?:[^\s\"'`;&|<>()]+)")
|
_ABSOLUTE_PATH_PATTERN = re.compile(r"(?<![:\w])(?<!:/)/(?:[^\s\"'`;&|<>()]+)")
|
||||||
_FILE_URL_PATTERN = re.compile(r"\bfile://\S+", re.IGNORECASE)
|
_FILE_URL_PATTERN = re.compile(r"\bfile://\S+", re.IGNORECASE)
|
||||||
@@ -851,11 +852,9 @@ def ensure_sandbox_initialized(runtime: ToolRuntime[ContextT, ThreadState] | Non
|
|||||||
# Sandbox was released, fall through to acquire new one
|
# Sandbox was released, fall through to acquire new one
|
||||||
|
|
||||||
# Lazy acquisition: get thread_id and acquire sandbox
|
# Lazy acquisition: get thread_id and acquire sandbox
|
||||||
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
thread_id = get_thread_id(runtime)
|
||||||
if thread_id is None:
|
if thread_id is None:
|
||||||
thread_id = runtime.config.get("configurable", {}).get("thread_id") if runtime.config else None
|
raise SandboxRuntimeError("Thread ID not available in runtime context, runtime config, or LangGraph config")
|
||||||
if thread_id is None:
|
|
||||||
raise SandboxRuntimeError("Thread ID not available in runtime context")
|
|
||||||
|
|
||||||
provider = get_sandbox_provider()
|
provider = get_sandbox_provider()
|
||||||
sandbox_id = provider.acquire(thread_id)
|
sandbox_id = provider.acquire(thread_id)
|
||||||
@@ -1047,6 +1046,7 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path:
|
|||||||
sandbox = ensure_sandbox_initialized(runtime)
|
sandbox = ensure_sandbox_initialized(runtime)
|
||||||
ensure_thread_directories_exist(runtime)
|
ensure_thread_directories_exist(runtime)
|
||||||
requested_path = path
|
requested_path = path
|
||||||
|
thread_data = None
|
||||||
if is_local_sandbox(runtime):
|
if is_local_sandbox(runtime):
|
||||||
thread_data = get_thread_data(runtime)
|
thread_data = get_thread_data(runtime)
|
||||||
validate_local_tool_path(path, thread_data, read_only=True)
|
validate_local_tool_path(path, thread_data, read_only=True)
|
||||||
@@ -1061,6 +1061,8 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path:
|
|||||||
if not children:
|
if not children:
|
||||||
return "(empty)"
|
return "(empty)"
|
||||||
output = "\n".join(children)
|
output = "\n".join(children)
|
||||||
|
if thread_data is not None:
|
||||||
|
output = mask_local_paths_in_output(output, thread_data)
|
||||||
try:
|
try:
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import get_app_config
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,24 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from .types import Skill
|
from .types import Skill
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def parse_skill_file(skill_file: Path, category: str, relative_path: Path | None = None) -> Skill | None:
|
def parse_skill_file(skill_file: Path, category: str, relative_path: Path | None = None) -> Skill | None:
|
||||||
"""
|
"""Parse a SKILL.md file and extract metadata.
|
||||||
Parse a SKILL.md file and extract metadata.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
skill_file: Path to the SKILL.md file
|
skill_file: Path to the SKILL.md file.
|
||||||
category: Category of the skill ('public' or 'custom')
|
category: Category of the skill ('public' or 'custom').
|
||||||
|
relative_path: Relative path from the category root to the skill
|
||||||
|
directory. Defaults to the skill directory name when omitted.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Skill object if parsing succeeds, None otherwise
|
Skill object if parsing succeeds, None otherwise.
|
||||||
"""
|
"""
|
||||||
if not skill_file.exists() or skill_file.name != "SKILL.md":
|
if not skill_file.exists() or skill_file.name != "SKILL.md":
|
||||||
return None
|
return None
|
||||||
@@ -24,90 +27,42 @@ def parse_skill_file(skill_file: Path, category: str, relative_path: Path | None
|
|||||||
try:
|
try:
|
||||||
content = skill_file.read_text(encoding="utf-8")
|
content = skill_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# Extract YAML front matter
|
# Extract YAML front-matter block between leading ``---`` fences.
|
||||||
# Pattern: ---\nkey: value\n---
|
|
||||||
front_matter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
front_matter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
||||||
|
|
||||||
if not front_matter_match:
|
if not front_matter_match:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
front_matter = front_matter_match.group(1)
|
front_matter_text = front_matter_match.group(1)
|
||||||
|
|
||||||
# Parse YAML front matter with basic multiline string support
|
try:
|
||||||
metadata = {}
|
metadata = yaml.safe_load(front_matter_text)
|
||||||
lines = front_matter.split("\n")
|
except yaml.YAMLError as exc:
|
||||||
current_key = None
|
logger.error("Invalid YAML front-matter in %s: %s", skill_file, exc)
|
||||||
current_value = []
|
return None
|
||||||
is_multiline = False
|
|
||||||
multiline_style = None
|
|
||||||
indent_level = None
|
|
||||||
|
|
||||||
for line in lines:
|
if not isinstance(metadata, dict):
|
||||||
if is_multiline:
|
logger.error("Front-matter in %s is not a YAML mapping", skill_file)
|
||||||
if not line.strip():
|
return None
|
||||||
current_value.append("")
|
|
||||||
continue
|
|
||||||
|
|
||||||
current_indent = len(line) - len(line.lstrip())
|
# Extract required fields. Both must be non-empty strings.
|
||||||
|
|
||||||
if indent_level is None:
|
|
||||||
if current_indent > 0:
|
|
||||||
indent_level = current_indent
|
|
||||||
current_value.append(line[indent_level:])
|
|
||||||
continue
|
|
||||||
elif current_indent >= indent_level:
|
|
||||||
current_value.append(line[indent_level:])
|
|
||||||
continue
|
|
||||||
|
|
||||||
# If we reach here, it's either a new key or the end of multiline
|
|
||||||
if current_key and is_multiline:
|
|
||||||
if multiline_style == "|":
|
|
||||||
metadata[current_key] = "\n".join(current_value).rstrip()
|
|
||||||
else:
|
|
||||||
text = "\n".join(current_value).rstrip()
|
|
||||||
# Replace single newlines with spaces for folded blocks
|
|
||||||
metadata[current_key] = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
|
|
||||||
|
|
||||||
current_key = None
|
|
||||||
current_value = []
|
|
||||||
is_multiline = False
|
|
||||||
multiline_style = None
|
|
||||||
indent_level = None
|
|
||||||
|
|
||||||
if not line.strip():
|
|
||||||
continue
|
|
||||||
|
|
||||||
if ":" in line:
|
|
||||||
# Handle nested dicts simply by ignoring indentation for now,
|
|
||||||
# or just extracting top-level keys
|
|
||||||
key, value = line.split(":", 1)
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip()
|
|
||||||
|
|
||||||
if value in (">", "|"):
|
|
||||||
current_key = key
|
|
||||||
is_multiline = True
|
|
||||||
multiline_style = value
|
|
||||||
current_value = []
|
|
||||||
indent_level = None
|
|
||||||
else:
|
|
||||||
metadata[key] = value
|
|
||||||
|
|
||||||
if current_key and is_multiline:
|
|
||||||
if multiline_style == "|":
|
|
||||||
metadata[current_key] = "\n".join(current_value).rstrip()
|
|
||||||
else:
|
|
||||||
text = "\n".join(current_value).rstrip()
|
|
||||||
metadata[current_key] = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
|
|
||||||
|
|
||||||
# Extract required fields
|
|
||||||
name = metadata.get("name")
|
name = metadata.get("name")
|
||||||
description = metadata.get("description")
|
description = metadata.get("description")
|
||||||
|
|
||||||
|
if not name or not isinstance(name, str):
|
||||||
|
return None
|
||||||
|
if not description or not isinstance(description, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalise: strip surrounding whitespace that YAML may preserve.
|
||||||
|
name = name.strip()
|
||||||
|
description = description.strip()
|
||||||
|
|
||||||
if not name or not description:
|
if not name or not description:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
license_text = metadata.get("license")
|
license_text = metadata.get("license")
|
||||||
|
if license_text is not None:
|
||||||
|
license_text = str(license_text).strip() or None
|
||||||
|
|
||||||
return Skill(
|
return Skill(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -117,9 +72,9 @@ def parse_skill_file(skill_file: Path, category: str, relative_path: Path | None
|
|||||||
skill_file=skill_file,
|
skill_file=skill_file,
|
||||||
relative_path=relative_path or Path(skill_file.parent.name),
|
relative_path=relative_path or Path(skill_file.parent.name),
|
||||||
category=category,
|
category=category,
|
||||||
enabled=True, # Default to enabled, actual state comes from config file
|
enabled=True, # Actual state comes from the extensions config file.
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("Error parsing skill file %s: %s", skill_file, e)
|
logger.exception("Unexpected error parsing skill file %s", skill_file)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ async def scan_skill_content(content: str, *, executable: bool = False, location
|
|||||||
[
|
[
|
||||||
{"role": "system", "content": rubric},
|
{"role": "system", "content": rubric},
|
||||||
{"role": "user", "content": prompt},
|
{"role": "user", "content": prompt},
|
||||||
]
|
],
|
||||||
|
config={"run_name": "security_agent"},
|
||||||
)
|
)
|
||||||
parsed = _extract_json_object(str(getattr(response, "content", "") or ""))
|
parsed = _extract_json_object(str(getattr(response, "content", "") or ""))
|
||||||
if parsed and parsed.get("decision") in {"allow", "warn", "block"}:
|
if parsed and parsed.get("decision") in {"allow", "warn", "block"}:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user