mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-20 15:11:09 +00:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c53b9ccb02 | |||
| e99cb01fe1 | |||
| 3e6a34297d | |||
| 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 |
@@ -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]
|
||||||
+12
-7
@@ -166,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
|
||||||
```
|
```
|
||||||
@@ -298,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
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ help:
|
|||||||
@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)"
|
||||||
@@ -73,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 "=========================================="
|
||||||
@@ -99,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..."; \
|
||||||
|
|||||||
@@ -264,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**:
|
||||||
@@ -658,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.
|
||||||
|
|||||||
+27
-12
@@ -130,7 +130,7 @@ from app.gateway.app import app
|
|||||||
from app.channels.service import start_channel_service
|
from app.channels.service import start_channel_service
|
||||||
|
|
||||||
# App → Harness (allowed)
|
# App → Harness (allowed)
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
# Harness → App (FORBIDDEN — enforced by test_harness_boundary.py)
|
# Harness → App (FORBIDDEN — enforced by test_harness_boundary.py)
|
||||||
# from app.gateway.routers.uploads import ... # ← will fail CI
|
# from app.gateway.routers.uploads import ... # ← will fail CI
|
||||||
@@ -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 under the user's isolation scope (`backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}`); resolves `user_id` via `get_effective_user_id()` (falls back to `"default"` in no-auth mode); Web UI thread deletion now follows LangGraph thread removal with Gateway cleanup of the local thread directory
|
1. **ThreadDataMiddleware** - Creates per-thread directories under the user's isolation scope (`backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}`); resolves `user_id` via `get_effective_user_id()` (falls back to `"default"` in no-auth mode); Web UI thread deletion now follows LangGraph thread removal with Gateway cleanup of the local thread 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
|
||||||
|
|
||||||
@@ -179,7 +185,16 @@ Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** direc
|
|||||||
|
|
||||||
**Config Versioning**: `config.example.yaml` has a `config_version` field. On startup, `AppConfig.from_file()` compares user version vs example version and emits a warning if outdated. Missing `config_version` = version 0. Run `make config-upgrade` to auto-merge missing fields. When changing the config schema, bump `config_version` in `config.example.yaml`.
|
**Config Versioning**: `config.example.yaml` has a `config_version` field. On startup, `AppConfig.from_file()` compares user version vs example version and emits a warning if outdated. Missing `config_version` = version 0. Run `make config-upgrade` to auto-merge missing fields. When changing the config schema, bump `config_version` in `config.example.yaml`.
|
||||||
|
|
||||||
**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path changes or the file's mtime increases. This keeps Gateway and LangGraph reads aligned with `config.yaml` edits without requiring a manual process restart.
|
**Config Lifecycle**: All config models are `frozen=True` (immutable after construction). `AppConfig.from_file()` is a pure function — no side effects, no process-global state. The resolved `AppConfig` is passed as an explicit parameter down every consumer lane:
|
||||||
|
|
||||||
|
- **Gateway**: `app.state.config` populated in lifespan; routers receive it via `Depends(get_config)` from `app/gateway/deps.py`.
|
||||||
|
- **Client**: `DeerFlowClient._app_config` captured in the constructor; every method reads `self._app_config`.
|
||||||
|
- **Agent run**: wrapped in `DeerFlowContext(app_config=…)` and injected via LangGraph `Runtime[DeerFlowContext].context`. Middleware and tools read `runtime.context.app_config` directly or via `resolve_context(runtime)`.
|
||||||
|
- **LangGraph Server bootstrap**: `make_lead_agent` (registered in `langgraph.json`) calls `AppConfig.from_file()` itself — the only place in production that loads from disk at agent-build time.
|
||||||
|
|
||||||
|
To update config at runtime (Gateway API mutations for MCP/Skills), write the new file and call `AppConfig.from_file()` to build a fresh snapshot, then swap `app.state.config`. No mtime detection, no auto-reload, no ambient ContextVar lookup (`AppConfig.current()` has been removed).
|
||||||
|
|
||||||
|
**DeerFlowContext**: Per-invocation typed context for the agent execution path, injected via LangGraph `Runtime[DeerFlowContext]`. Holds `app_config: AppConfig`, `thread_id: str`, `agent_name: str | None`. Gateway runtime and `DeerFlowClient` construct full `DeerFlowContext` at invoke time; the LangGraph Server boundary builds one inside `make_lead_agent`. Middleware and tools access context through `resolve_context(runtime)` which returns the typed `DeerFlowContext` — legacy dict/None shapes are rejected. Mutable runtime state (`sandbox_id`) flows through `ThreadState.sandbox`, not context.
|
||||||
|
|
||||||
Configuration priority:
|
Configuration priority:
|
||||||
1. Explicit `config_path` argument
|
1. Explicit `config_path` argument
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -375,7 +375,9 @@ class FeishuChannel(Channel):
|
|||||||
virtual_path = f"{VIRTUAL_PATH_PREFIX}/uploads/{resolved_target.name}"
|
virtual_path = f"{VIRTUAL_PATH_PREFIX}/uploads/{resolved_target.name}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sandbox_provider = get_sandbox_provider()
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
|
sandbox_provider = get_sandbox_provider(AppConfig.from_file())
|
||||||
sandbox_id = sandbox_provider.acquire(thread_id)
|
sandbox_id = sandbox_provider.acquire(thread_id)
|
||||||
if sandbox_id != "local":
|
if sandbox_id != "local":
|
||||||
sandbox = sandbox_provider.get(sandbox_id)
|
sandbox = sandbox_provider.get(sandbox_id)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ 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},
|
||||||
|
|||||||
@@ -4,17 +4,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from app.channels.base import Channel
|
from app.channels.base import Channel
|
||||||
from app.channels.manager import DEFAULT_GATEWAY_URL, DEFAULT_LANGGRAPH_URL, ChannelManager
|
from app.channels.manager import DEFAULT_GATEWAY_URL, DEFAULT_LANGGRAPH_URL, ChannelManager
|
||||||
from app.channels.message_bus import MessageBus
|
from app.channels.message_bus import MessageBus
|
||||||
from app.channels.store import ChannelStore
|
from app.channels.store import ChannelStore
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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",
|
||||||
@@ -22,6 +26,16 @@ _CHANNEL_REGISTRY: dict[str, str] = {
|
|||||||
"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"
|
||||||
|
|
||||||
@@ -64,14 +78,11 @@ class ChannelService:
|
|||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_app_config(cls) -> ChannelService:
|
def from_app_config(cls, app_config: AppConfig) -> ChannelService:
|
||||||
"""Create a ChannelService from the application config."""
|
"""Create a ChannelService from an explicit application config."""
|
||||||
from deerflow.config.app_config import get_app_config
|
|
||||||
|
|
||||||
config = get_app_config()
|
|
||||||
channels_config = {}
|
channels_config = {}
|
||||||
# extra fields are allowed by AppConfig (extra="allow")
|
# extra fields are allowed by AppConfig (extra="allow")
|
||||||
extra = config.model_extra or {}
|
extra = app_config.model_extra or {}
|
||||||
if "channels" in extra:
|
if "channels" in extra:
|
||||||
channels_config = extra["channels"]
|
channels_config = extra["channels"]
|
||||||
return cls(channels_config=channels_config)
|
return cls(channels_config=channels_config)
|
||||||
@@ -87,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)
|
||||||
@@ -181,12 +201,12 @@ def get_channel_service() -> ChannelService | None:
|
|||||||
return _channel_service
|
return _channel_service
|
||||||
|
|
||||||
|
|
||||||
async def start_channel_service() -> ChannelService:
|
async def start_channel_service(app_config: AppConfig) -> ChannelService:
|
||||||
"""Create and start the global ChannelService from app config."""
|
"""Create and start the global ChannelService from app config."""
|
||||||
global _channel_service
|
global _channel_service
|
||||||
if _channel_service is not None:
|
if _channel_service is not None:
|
||||||
return _channel_service
|
return _channel_service
|
||||||
_channel_service = ChannelService.from_app_config()
|
_channel_service = ChannelService.from_app_config(app_config)
|
||||||
await _channel_service.start()
|
await _channel_service.start()
|
||||||
return _channel_service
|
return _channel_service
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
@@ -27,7 +28,7 @@ from app.gateway.routers import (
|
|||||||
threads,
|
threads,
|
||||||
uploads,
|
uploads,
|
||||||
)
|
)
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -38,6 +39,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
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_admin_user(app: FastAPI) -> None:
|
async def _ensure_admin_user(app: FastAPI) -> None:
|
||||||
"""Startup hook: handle first boot and migrate orphan threads otherwise.
|
"""Startup hook: handle first boot and migrate orphan threads otherwise.
|
||||||
@@ -145,9 +151,11 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
|||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Application lifespan handler."""
|
"""Application lifespan handler."""
|
||||||
|
|
||||||
# Load config and check necessary environment variables at startup
|
|
||||||
try:
|
try:
|
||||||
get_app_config()
|
# ``app.state.config`` is the sole source of truth for
|
||||||
|
# ``Depends(get_config)``. Consumers that want AppConfig must receive
|
||||||
|
# it as an explicit parameter; there is no ambient singleton.
|
||||||
|
app.state.config = AppConfig.from_file()
|
||||||
logger.info("Configuration loaded successfully")
|
logger.info("Configuration loaded successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to load configuration during gateway startup: {e}"
|
error_msg = f"Failed to load configuration during gateway startup: {e}"
|
||||||
@@ -168,18 +176,26 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
try:
|
try:
|
||||||
from app.channels.service import start_channel_service
|
from app.channels.service import start_channel_service
|
||||||
|
|
||||||
channel_service = await start_channel_service()
|
channel_service = await start_channel_service(app.state.config)
|
||||||
logger.info("Channel service started: %s", channel_service.get_status())
|
logger.info("Channel service started: %s", channel_service.get_status())
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("No IM channels configured or channel service failed to start")
|
logger.exception("No IM channels configured or channel service failed to start")
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
|
|||||||
@@ -25,14 +25,15 @@ from deerflow.persistence.user.model import UserRow
|
|||||||
|
|
||||||
|
|
||||||
async def _run(email: str | None) -> int:
|
async def _run(email: str | None) -> int:
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config import AppConfig
|
||||||
from deerflow.persistence.engine import (
|
from deerflow.persistence.engine import (
|
||||||
close_engine,
|
close_engine,
|
||||||
get_session_factory,
|
get_session_factory,
|
||||||
init_engine_from_config,
|
init_engine_from_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
config = get_app_config()
|
# CLI entry: load config explicitly at the top, pass down through the closure.
|
||||||
|
config = AppConfig.from_file()
|
||||||
await init_engine_from_config(config.database)
|
await init_engine_from_config(config.database)
|
||||||
try:
|
try:
|
||||||
sf = get_session_factory()
|
sf = get_session_factory()
|
||||||
|
|||||||
+39
-26
@@ -8,17 +8,14 @@ Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from typing import TYPE_CHECKING, TypeVar, cast
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from langgraph.types import Checkpointer
|
|
||||||
|
|
||||||
from deerflow.persistence.feedback import FeedbackRepository
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.runtime import RunContext, RunManager, StreamBridge
|
from deerflow.runtime import RunContext, RunManager
|
||||||
from deerflow.runtime.events.store.base import RunEventStore
|
|
||||||
from deerflow.runtime.runs.store.base import RunStore
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.gateway.auth.local_provider import LocalAuthProvider
|
from app.gateway.auth.local_provider import LocalAuthProvider
|
||||||
@@ -26,7 +23,17 @@ if TYPE_CHECKING:
|
|||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
def get_config(request: Request) -> AppConfig:
|
||||||
|
"""FastAPI dependency returning the app-scoped ``AppConfig``.
|
||||||
|
|
||||||
|
Reads from ``request.app.state.config`` which is set at startup
|
||||||
|
(``app.py`` lifespan) and swapped on config reload (``routers/mcp.py``,
|
||||||
|
``routers/skills.py``).
|
||||||
|
"""
|
||||||
|
cfg = getattr(request.app.state, "config", None)
|
||||||
|
if cfg is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Configuration not available")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -38,22 +45,24 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
async with langgraph_runtime(app):
|
async with langgraph_runtime(app):
|
||||||
yield
|
yield
|
||||||
"""
|
"""
|
||||||
from deerflow.config import get_app_config
|
|
||||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
||||||
from deerflow.runtime import make_store, make_stream_bridge
|
from deerflow.runtime import make_store, make_stream_bridge
|
||||||
from deerflow.runtime.checkpointer.async_provider import make_checkpointer
|
from deerflow.runtime.checkpointer.async_provider import make_checkpointer
|
||||||
from deerflow.runtime.events.store import make_run_event_store
|
from deerflow.runtime.events.store import make_run_event_store
|
||||||
|
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge())
|
# app.state.config is populated earlier in lifespan(); thread it
|
||||||
|
# explicitly into every provider below.
|
||||||
|
config = app.state.config
|
||||||
|
|
||||||
|
app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge(config))
|
||||||
|
|
||||||
# Initialize persistence engine BEFORE checkpointer so that
|
# Initialize persistence engine BEFORE checkpointer so that
|
||||||
# auto-create-database logic runs first (postgres backend).
|
# auto-create-database logic runs first (postgres backend).
|
||||||
config = get_app_config()
|
|
||||||
await init_engine_from_config(config.database)
|
await init_engine_from_config(config.database)
|
||||||
|
|
||||||
app.state.checkpointer = await stack.enter_async_context(make_checkpointer())
|
app.state.checkpointer = await stack.enter_async_context(make_checkpointer(config))
|
||||||
app.state.store = await stack.enter_async_context(make_store())
|
app.state.store = await stack.enter_async_context(make_store(config))
|
||||||
|
|
||||||
# Initialize repositories — one get_session_factory() call for all.
|
# Initialize repositories — one get_session_factory() call for all.
|
||||||
sf = get_session_factory()
|
sf = get_session_factory()
|
||||||
@@ -91,25 +100,25 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _require(attr: str, label: str) -> Callable[[Request], T]:
|
def _require(attr: str, label: str):
|
||||||
"""Create a FastAPI dependency that returns ``app.state.<attr>`` or 503."""
|
"""Create a FastAPI dependency that returns ``app.state.<attr>`` or 503."""
|
||||||
|
|
||||||
def dep(request: Request) -> T:
|
def dep(request: Request):
|
||||||
val = getattr(request.app.state, attr, None)
|
val = getattr(request.app.state, attr, None)
|
||||||
if val is None:
|
if val is None:
|
||||||
raise HTTPException(status_code=503, detail=f"{label} not available")
|
raise HTTPException(status_code=503, detail=f"{label} not available")
|
||||||
return cast(T, val)
|
return val
|
||||||
|
|
||||||
dep.__name__ = dep.__qualname__ = f"get_{attr}"
|
dep.__name__ = dep.__qualname__ = f"get_{attr}"
|
||||||
return dep
|
return dep
|
||||||
|
|
||||||
|
|
||||||
get_stream_bridge: Callable[[Request], StreamBridge] = _require("stream_bridge", "Stream bridge")
|
get_stream_bridge = _require("stream_bridge", "Stream bridge")
|
||||||
get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run manager")
|
get_run_manager = _require("run_manager", "Run manager")
|
||||||
get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
|
get_checkpointer = _require("checkpointer", "Checkpointer")
|
||||||
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
|
get_run_event_store = _require("run_event_store", "Run event store")
|
||||||
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
|
get_feedback_repo = _require("feedback_repo", "Feedback")
|
||||||
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
|
get_run_store = _require("run_store", "Run store")
|
||||||
|
|
||||||
|
|
||||||
def get_store(request: Request):
|
def get_store(request: Request):
|
||||||
@@ -128,19 +137,23 @@ def get_thread_store(request: Request) -> ThreadMetaStore:
|
|||||||
def get_run_context(request: Request) -> RunContext:
|
def get_run_context(request: Request) -> RunContext:
|
||||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
||||||
|
|
||||||
Returns a *base* context with infrastructure dependencies.
|
Returns a *base* context with infrastructure dependencies. Callers that
|
||||||
|
need per-run fields (e.g. ``follow_up_to_run_id``) should use
|
||||||
|
``dataclasses.replace(ctx, follow_up_to_run_id=...)`` before passing it
|
||||||
|
to :func:`run_agent`.
|
||||||
"""
|
"""
|
||||||
from deerflow.config import get_app_config
|
config = get_config(request)
|
||||||
|
|
||||||
return RunContext(
|
return RunContext(
|
||||||
checkpointer=get_checkpointer(request),
|
checkpointer=get_checkpointer(request),
|
||||||
store=get_store(request),
|
store=get_store(request),
|
||||||
event_store=get_run_event_store(request),
|
event_store=get_run_event_store(request),
|
||||||
run_events_config=getattr(get_app_config(), "run_events", None),
|
run_events_config=getattr(config, "run_events", None),
|
||||||
thread_store=get_thread_store(request),
|
thread_store=get_thread_store(request),
|
||||||
|
app_config=config,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auth helpers (used by authz.py and auth middleware)
|
# Auth helpers (used by authz.py and auth middleware)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_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.app_config import AppConfig
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -24,6 +26,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 +43,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 +53,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 +78,15 @@ def _normalize_agent_name(name: str) -> str:
|
|||||||
return name.lower()
|
return name.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_agents_api_enabled(app_config: AppConfig) -> None:
|
||||||
|
"""Reject access unless the custom-agent management API is explicitly enabled."""
|
||||||
|
if not app_config.agents_api.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 +98,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,12 +109,14 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False
|
|||||||
summary="List Custom Agents",
|
summary="List Custom Agents",
|
||||||
description="List all custom agents available in the agents directory, including their soul content.",
|
description="List all custom agents available in the agents directory, including their soul content.",
|
||||||
)
|
)
|
||||||
async def list_agents() -> AgentsListResponse:
|
async def list_agents(app_config: AppConfig = Depends(get_config)) -> AgentsListResponse:
|
||||||
"""List all custom agents.
|
"""List all custom agents.
|
||||||
|
|
||||||
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(app_config)
|
||||||
|
|
||||||
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 +142,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(app_config)
|
||||||
_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()
|
||||||
@@ -137,7 +155,7 @@ async def check_agent_name(name: str) -> dict:
|
|||||||
summary="Get Custom Agent",
|
summary="Get Custom Agent",
|
||||||
description="Retrieve details and SOUL.md content for a specific custom agent.",
|
description="Retrieve details and SOUL.md content for a specific custom agent.",
|
||||||
)
|
)
|
||||||
async def get_agent(name: str) -> AgentResponse:
|
async def get_agent(name: str, app_config: AppConfig = Depends(get_config)) -> AgentResponse:
|
||||||
"""Get a specific custom agent by name.
|
"""Get a specific custom agent by name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -149,6 +167,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(app_config)
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
|
|
||||||
@@ -169,7 +188,7 @@ async def get_agent(name: str) -> AgentResponse:
|
|||||||
summary="Create Custom Agent",
|
summary="Create Custom Agent",
|
||||||
description="Create a new custom agent with its config and SOUL.md.",
|
description="Create a new custom agent with its config and SOUL.md.",
|
||||||
)
|
)
|
||||||
async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
async def create_agent_endpoint(request: AgentCreateRequest, app_config: AppConfig = Depends(get_config)) -> AgentResponse:
|
||||||
"""Create a new custom agent.
|
"""Create a new custom agent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -181,6 +200,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(app_config)
|
||||||
_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 +220,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:
|
||||||
@@ -230,7 +252,7 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
|||||||
summary="Update Custom Agent",
|
summary="Update Custom Agent",
|
||||||
description="Update an existing custom agent's config and/or SOUL.md.",
|
description="Update an existing custom agent's config and/or SOUL.md.",
|
||||||
)
|
)
|
||||||
async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
async def update_agent(name: str, request: AgentUpdateRequest, app_config: AppConfig = Depends(get_config)) -> AgentResponse:
|
||||||
"""Update an existing custom agent.
|
"""Update an existing custom agent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -243,6 +265,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(app_config)
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
|
|
||||||
@@ -255,21 +278,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)
|
||||||
@@ -309,12 +343,14 @@ class UserProfileUpdateRequest(BaseModel):
|
|||||||
summary="Get User Profile",
|
summary="Get User Profile",
|
||||||
description="Read the global USER.md file that is injected into all custom agents.",
|
description="Read the global USER.md file that is injected into all custom agents.",
|
||||||
)
|
)
|
||||||
async def get_user_profile() -> UserProfileResponse:
|
async def get_user_profile(app_config: AppConfig = Depends(get_config)) -> UserProfileResponse:
|
||||||
"""Return the current USER.md content.
|
"""Return the current USER.md content.
|
||||||
|
|
||||||
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(app_config)
|
||||||
|
|
||||||
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():
|
||||||
@@ -332,7 +368,7 @@ async def get_user_profile() -> UserProfileResponse:
|
|||||||
summary="Update User Profile",
|
summary="Update User Profile",
|
||||||
description="Write the global USER.md file that is injected into all custom agents.",
|
description="Write the global USER.md file that is injected into all custom agents.",
|
||||||
)
|
)
|
||||||
async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse:
|
async def update_user_profile(request: UserProfileUpdateRequest, app_config: AppConfig = Depends(get_config)) -> UserProfileResponse:
|
||||||
"""Create or overwrite the global USER.md.
|
"""Create or overwrite the global USER.md.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -341,6 +377,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(app_config)
|
||||||
|
|
||||||
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)
|
||||||
@@ -358,7 +396,7 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR
|
|||||||
summary="Delete Custom Agent",
|
summary="Delete Custom Agent",
|
||||||
description="Delete a custom agent and all its files (config, SOUL.md, memory).",
|
description="Delete a custom agent and all its files (config, SOUL.md, memory).",
|
||||||
)
|
)
|
||||||
async def delete_agent(name: str) -> None:
|
async def delete_agent(name: str, app_config: AppConfig = Depends(get_config)) -> None:
|
||||||
"""Delete a custom agent.
|
"""Delete a custom agent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -367,6 +405,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(app_config)
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
|
from app.gateway.deps import get_config
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.extensions_config import ExtensionsConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["mcp"])
|
router = APIRouter(prefix="/api", tags=["mcp"])
|
||||||
@@ -69,7 +71,7 @@ class McpConfigUpdateRequest(BaseModel):
|
|||||||
summary="Get MCP Configuration",
|
summary="Get MCP Configuration",
|
||||||
description="Retrieve the current Model Context Protocol (MCP) server configurations.",
|
description="Retrieve the current Model Context Protocol (MCP) server configurations.",
|
||||||
)
|
)
|
||||||
async def get_mcp_configuration() -> McpConfigResponse:
|
async def get_mcp_configuration(config: AppConfig = Depends(get_config)) -> McpConfigResponse:
|
||||||
"""Get the current MCP configuration.
|
"""Get the current MCP configuration.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -90,9 +92,9 @@ async def get_mcp_configuration() -> McpConfigResponse:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
config = get_extensions_config()
|
ext = config.extensions
|
||||||
|
|
||||||
return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in config.mcp_servers.items()})
|
return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in ext.mcp_servers.items()})
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -101,7 +103,11 @@ async def get_mcp_configuration() -> McpConfigResponse:
|
|||||||
summary="Update MCP Configuration",
|
summary="Update MCP Configuration",
|
||||||
description="Update Model Context Protocol (MCP) server configurations and save to file.",
|
description="Update Model Context Protocol (MCP) server configurations and save to file.",
|
||||||
)
|
)
|
||||||
async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfigResponse:
|
async def update_mcp_configuration(
|
||||||
|
request: McpConfigUpdateRequest,
|
||||||
|
http_request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
) -> McpConfigResponse:
|
||||||
"""Update the MCP configuration.
|
"""Update the MCP configuration.
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
@@ -142,13 +148,13 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfig
|
|||||||
config_path = Path.cwd().parent / "extensions_config.json"
|
config_path = Path.cwd().parent / "extensions_config.json"
|
||||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||||
|
|
||||||
# Load current config to preserve skills configuration
|
# Use injected config to preserve skills configuration
|
||||||
current_config = get_extensions_config()
|
current_ext = config.extensions
|
||||||
|
|
||||||
# Convert request to dict format for JSON serialization
|
# Convert request to dict format for JSON serialization
|
||||||
config_data = {
|
config_data = {
|
||||||
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()},
|
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()},
|
||||||
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
|
"skills": {name: {"enabled": skill.enabled} for name, skill in current_ext.skills.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Write the configuration to file
|
# Write the configuration to file
|
||||||
@@ -160,9 +166,11 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfig
|
|||||||
# NOTE: No need to reload/reset cache here - LangGraph Server (separate process)
|
# NOTE: No need to reload/reset cache here - LangGraph Server (separate process)
|
||||||
# will detect config file changes via mtime and reinitialize MCP tools automatically
|
# will detect config file changes via mtime and reinitialize MCP tools automatically
|
||||||
|
|
||||||
# Reload the configuration and update the global cache
|
# Reload the configuration and swap ``app.state.config`` so subsequent
|
||||||
reloaded_config = reload_extensions_config()
|
# ``Depends(get_config)`` calls see the refreshed value.
|
||||||
return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in reloaded_config.mcp_servers.items()})
|
reloaded = AppConfig.from_file()
|
||||||
|
http_request.app.state.config = reloaded
|
||||||
|
return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in reloaded.extensions.mcp_servers.items()})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update MCP configuration: {e}", exc_info=True)
|
logger.error(f"Failed to update MCP configuration: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Memory API router for retrieving and managing global memory data."""
|
"""Memory API router for retrieving and managing global memory data."""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_config
|
||||||
from deerflow.agents.memory.updater import (
|
from deerflow.agents.memory.updater import (
|
||||||
clear_memory_data,
|
clear_memory_data,
|
||||||
create_memory_fact,
|
create_memory_fact,
|
||||||
@@ -12,7 +13,7 @@ from deerflow.agents.memory.updater import (
|
|||||||
reload_memory_data,
|
reload_memory_data,
|
||||||
update_memory_fact,
|
update_memory_fact,
|
||||||
)
|
)
|
||||||
from deerflow.config.memory_config import get_memory_config
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["memory"])
|
router = APIRouter(prefix="/api", tags=["memory"])
|
||||||
@@ -114,7 +115,7 @@ class MemoryStatusResponse(BaseModel):
|
|||||||
summary="Get Memory Data",
|
summary="Get Memory Data",
|
||||||
description="Retrieve the current global memory data including user context, history, and facts.",
|
description="Retrieve the current global memory data including user context, history, and facts.",
|
||||||
)
|
)
|
||||||
async def get_memory() -> MemoryResponse:
|
async def get_memory(app_config: AppConfig = Depends(get_config)) -> MemoryResponse:
|
||||||
"""Get the current global memory data.
|
"""Get the current global memory data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -148,7 +149,7 @@ async def get_memory() -> MemoryResponse:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
memory_data = get_memory_data(user_id=get_effective_user_id())
|
memory_data = get_memory_data(app_config.memory, user_id=get_effective_user_id())
|
||||||
return MemoryResponse(**memory_data)
|
return MemoryResponse(**memory_data)
|
||||||
|
|
||||||
|
|
||||||
@@ -159,7 +160,7 @@ async def get_memory() -> MemoryResponse:
|
|||||||
summary="Reload Memory Data",
|
summary="Reload Memory Data",
|
||||||
description="Reload memory data from the storage file, refreshing the in-memory cache.",
|
description="Reload memory data from the storage file, refreshing the in-memory cache.",
|
||||||
)
|
)
|
||||||
async def reload_memory() -> MemoryResponse:
|
async def reload_memory(app_config: AppConfig = Depends(get_config)) -> MemoryResponse:
|
||||||
"""Reload memory data from file.
|
"""Reload memory data from file.
|
||||||
|
|
||||||
This forces a reload of the memory data from the storage file,
|
This forces a reload of the memory data from the storage file,
|
||||||
@@ -168,7 +169,7 @@ async def reload_memory() -> MemoryResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
The reloaded memory data.
|
The reloaded memory data.
|
||||||
"""
|
"""
|
||||||
memory_data = reload_memory_data(user_id=get_effective_user_id())
|
memory_data = reload_memory_data(app_config.memory, user_id=get_effective_user_id())
|
||||||
return MemoryResponse(**memory_data)
|
return MemoryResponse(**memory_data)
|
||||||
|
|
||||||
|
|
||||||
@@ -179,10 +180,10 @@ async def reload_memory() -> MemoryResponse:
|
|||||||
summary="Clear All Memory Data",
|
summary="Clear All Memory Data",
|
||||||
description="Delete all saved memory data and reset the memory structure to an empty state.",
|
description="Delete all saved memory data and reset the memory structure to an empty state.",
|
||||||
)
|
)
|
||||||
async def clear_memory() -> MemoryResponse:
|
async def clear_memory(app_config: AppConfig = Depends(get_config)) -> MemoryResponse:
|
||||||
"""Clear all persisted memory data."""
|
"""Clear all persisted memory data."""
|
||||||
try:
|
try:
|
||||||
memory_data = clear_memory_data(user_id=get_effective_user_id())
|
memory_data = clear_memory_data(app_config.memory, user_id=get_effective_user_id())
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(status_code=500, detail="Failed to clear memory data.") from exc
|
raise HTTPException(status_code=500, detail="Failed to clear memory data.") from exc
|
||||||
|
|
||||||
@@ -196,10 +197,11 @@ async def clear_memory() -> MemoryResponse:
|
|||||||
summary="Create Memory Fact",
|
summary="Create Memory Fact",
|
||||||
description="Create a single saved memory fact manually.",
|
description="Create a single saved memory fact manually.",
|
||||||
)
|
)
|
||||||
async def create_memory_fact_endpoint(request: FactCreateRequest) -> MemoryResponse:
|
async def create_memory_fact_endpoint(request: FactCreateRequest, app_config: AppConfig = Depends(get_config)) -> MemoryResponse:
|
||||||
"""Create a single fact manually."""
|
"""Create a single fact manually."""
|
||||||
try:
|
try:
|
||||||
memory_data = create_memory_fact(
|
memory_data = create_memory_fact(
|
||||||
|
app_config.memory,
|
||||||
content=request.content,
|
content=request.content,
|
||||||
category=request.category,
|
category=request.category,
|
||||||
confidence=request.confidence,
|
confidence=request.confidence,
|
||||||
@@ -220,10 +222,10 @@ async def create_memory_fact_endpoint(request: FactCreateRequest) -> MemoryRespo
|
|||||||
summary="Delete Memory Fact",
|
summary="Delete Memory Fact",
|
||||||
description="Delete a single saved memory fact by its fact id.",
|
description="Delete a single saved memory fact by its fact id.",
|
||||||
)
|
)
|
||||||
async def delete_memory_fact_endpoint(fact_id: str) -> MemoryResponse:
|
async def delete_memory_fact_endpoint(fact_id: str, app_config: AppConfig = Depends(get_config)) -> MemoryResponse:
|
||||||
"""Delete a single fact from memory by fact id."""
|
"""Delete a single fact from memory by fact id."""
|
||||||
try:
|
try:
|
||||||
memory_data = delete_memory_fact(fact_id, user_id=get_effective_user_id())
|
memory_data = delete_memory_fact(app_config.memory, fact_id, user_id=get_effective_user_id())
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail=f"Memory fact '{fact_id}' not found.") from exc
|
raise HTTPException(status_code=404, detail=f"Memory fact '{fact_id}' not found.") from exc
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -239,10 +241,11 @@ async def delete_memory_fact_endpoint(fact_id: str) -> MemoryResponse:
|
|||||||
summary="Patch Memory Fact",
|
summary="Patch Memory Fact",
|
||||||
description="Partially update a single saved memory fact by its fact id while preserving omitted fields.",
|
description="Partially update a single saved memory fact by its fact id while preserving omitted fields.",
|
||||||
)
|
)
|
||||||
async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) -> MemoryResponse:
|
async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest, app_config: AppConfig = Depends(get_config)) -> MemoryResponse:
|
||||||
"""Partially update a single fact manually."""
|
"""Partially update a single fact manually."""
|
||||||
try:
|
try:
|
||||||
memory_data = update_memory_fact(
|
memory_data = update_memory_fact(
|
||||||
|
app_config.memory,
|
||||||
fact_id=fact_id,
|
fact_id=fact_id,
|
||||||
content=request.content,
|
content=request.content,
|
||||||
category=request.category,
|
category=request.category,
|
||||||
@@ -266,9 +269,9 @@ async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) -
|
|||||||
summary="Export Memory Data",
|
summary="Export Memory Data",
|
||||||
description="Export the current global memory data as JSON for backup or transfer.",
|
description="Export the current global memory data as JSON for backup or transfer.",
|
||||||
)
|
)
|
||||||
async def export_memory() -> MemoryResponse:
|
async def export_memory(app_config: AppConfig = Depends(get_config)) -> MemoryResponse:
|
||||||
"""Export the current memory data."""
|
"""Export the current memory data."""
|
||||||
memory_data = get_memory_data(user_id=get_effective_user_id())
|
memory_data = get_memory_data(app_config.memory, user_id=get_effective_user_id())
|
||||||
return MemoryResponse(**memory_data)
|
return MemoryResponse(**memory_data)
|
||||||
|
|
||||||
|
|
||||||
@@ -279,10 +282,10 @@ async def export_memory() -> MemoryResponse:
|
|||||||
summary="Import Memory Data",
|
summary="Import Memory Data",
|
||||||
description="Import and overwrite the current global memory data from a JSON payload.",
|
description="Import and overwrite the current global memory data from a JSON payload.",
|
||||||
)
|
)
|
||||||
async def import_memory(request: MemoryResponse) -> MemoryResponse:
|
async def import_memory(request: MemoryResponse, app_config: AppConfig = Depends(get_config)) -> MemoryResponse:
|
||||||
"""Import and persist memory data."""
|
"""Import and persist memory data."""
|
||||||
try:
|
try:
|
||||||
memory_data = import_memory_data(request.model_dump(), user_id=get_effective_user_id())
|
memory_data = import_memory_data(app_config.memory, request.model_dump(), user_id=get_effective_user_id())
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(status_code=500, detail="Failed to import memory data.") from exc
|
raise HTTPException(status_code=500, detail="Failed to import memory data.") from exc
|
||||||
|
|
||||||
@@ -295,7 +298,9 @@ async def import_memory(request: MemoryResponse) -> MemoryResponse:
|
|||||||
summary="Get Memory Configuration",
|
summary="Get Memory Configuration",
|
||||||
description="Retrieve the current memory system configuration.",
|
description="Retrieve the current memory system configuration.",
|
||||||
)
|
)
|
||||||
async def get_memory_config_endpoint() -> MemoryConfigResponse:
|
async def get_memory_config_endpoint(
|
||||||
|
app_config: AppConfig = Depends(get_config),
|
||||||
|
) -> MemoryConfigResponse:
|
||||||
"""Get the memory system configuration.
|
"""Get the memory system configuration.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -314,7 +319,7 @@ async def get_memory_config_endpoint() -> MemoryConfigResponse:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
config = get_memory_config()
|
config = app_config.memory
|
||||||
return MemoryConfigResponse(
|
return MemoryConfigResponse(
|
||||||
enabled=config.enabled,
|
enabled=config.enabled,
|
||||||
storage_path=config.storage_path,
|
storage_path=config.storage_path,
|
||||||
@@ -333,14 +338,16 @@ async def get_memory_config_endpoint() -> MemoryConfigResponse:
|
|||||||
summary="Get Memory Status",
|
summary="Get Memory Status",
|
||||||
description="Retrieve both memory configuration and current data in a single request.",
|
description="Retrieve both memory configuration and current data in a single request.",
|
||||||
)
|
)
|
||||||
async def get_memory_status() -> MemoryStatusResponse:
|
async def get_memory_status(
|
||||||
|
app_config: AppConfig = Depends(get_config),
|
||||||
|
) -> MemoryStatusResponse:
|
||||||
"""Get the memory system status including configuration and data.
|
"""Get the memory system status including configuration and data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Combined memory configuration and current data.
|
Combined memory configuration and current data.
|
||||||
"""
|
"""
|
||||||
config = get_memory_config()
|
config = app_config.memory
|
||||||
memory_data = get_memory_data(user_id=get_effective_user_id())
|
memory_data = get_memory_data(config, user_id=get_effective_user_id())
|
||||||
|
|
||||||
return MemoryStatusResponse(
|
return MemoryStatusResponse(
|
||||||
config=MemoryConfigResponse(
|
config=MemoryConfigResponse(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
from app.gateway.deps import get_config
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["models"])
|
router = APIRouter(prefix="/api", tags=["models"])
|
||||||
|
|
||||||
@@ -17,10 +18,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(
|
||||||
@@ -29,14 +37,14 @@ class ModelsListResponse(BaseModel):
|
|||||||
summary="List All Models",
|
summary="List All Models",
|
||||||
description="Retrieve a list of all available AI models configured in the system.",
|
description="Retrieve a list of all available AI models configured in the system.",
|
||||||
)
|
)
|
||||||
async def list_models() -> ModelsListResponse:
|
async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResponse:
|
||||||
"""List all available models from configuration.
|
"""List all available models from configuration.
|
||||||
|
|
||||||
Returns model information suitable for frontend display,
|
Returns model information suitable for frontend display,
|
||||||
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,21 +52,27 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
config = get_app_config()
|
|
||||||
models = [
|
models = [
|
||||||
ModelResponse(
|
ModelResponse(
|
||||||
name=model.name,
|
name=model.name,
|
||||||
@@ -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(
|
||||||
@@ -79,7 +96,7 @@ async def list_models() -> ModelsListResponse:
|
|||||||
summary="Get Model Details",
|
summary="Get Model Details",
|
||||||
description="Retrieve detailed information about a specific AI model by its name.",
|
description="Retrieve detailed information about a specific AI model by its name.",
|
||||||
)
|
)
|
||||||
async def get_model(model_name: str) -> ModelResponse:
|
async def get_model(model_name: str, config: AppConfig = Depends(get_config)) -> ModelResponse:
|
||||||
"""Get a specific model by name.
|
"""Get a specific model by name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -101,7 +118,6 @@ async def get_model(model_name: str) -> ModelResponse:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
config = get_app_config()
|
|
||||||
model = config.get_model_config(model_name)
|
model = config.get_model_config(model_name)
|
||||||
if model is None:
|
if model is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
||||||
|
|||||||
@@ -123,8 +123,7 @@ async def run_messages(
|
|||||||
run = await _resolve_run(run_id, request)
|
run = await _resolve_run(run_id, request)
|
||||||
event_store = get_run_event_store(request)
|
event_store = get_run_event_store(request)
|
||||||
rows = await event_store.list_messages_by_run(
|
rows = await event_store.list_messages_by_run(
|
||||||
run["thread_id"],
|
run["thread_id"], run_id,
|
||||||
run_id,
|
|
||||||
limit=limit + 1,
|
limit=limit + 1,
|
||||||
before_seq=before_seq,
|
before_seq=before_seq,
|
||||||
after_seq=after_seq,
|
after_seq=after_seq,
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
import errno
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_config
|
||||||
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 refresh_skills_system_prompt_cache_async
|
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.app_config import AppConfig
|
||||||
|
from deerflow.config.extensions_config import ExtensionsConfig
|
||||||
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
|
||||||
from deerflow.skills.manager import (
|
from deerflow.skills.manager import (
|
||||||
@@ -100,9 +103,9 @@ def _skill_to_response(skill: Skill) -> SkillResponse:
|
|||||||
summary="List All Skills",
|
summary="List All Skills",
|
||||||
description="Retrieve a list of all available skills from both public and custom directories.",
|
description="Retrieve a list of all available skills from both public and custom directories.",
|
||||||
)
|
)
|
||||||
async def list_skills() -> SkillsListResponse:
|
async def list_skills(app_config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
||||||
try:
|
try:
|
||||||
skills = load_skills(enabled_only=False)
|
skills = load_skills(app_config, enabled_only=False)
|
||||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
||||||
@@ -115,11 +118,11 @@ async def list_skills() -> SkillsListResponse:
|
|||||||
summary="Install Skill",
|
summary="Install Skill",
|
||||||
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
||||||
)
|
)
|
||||||
async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse:
|
async def install_skill(request: SkillInstallRequest, app_config: AppConfig = Depends(get_config)) -> 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()
|
await refresh_skills_system_prompt_cache_async(app_config)
|
||||||
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))
|
||||||
@@ -135,9 +138,9 @@ async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
||||||
async def list_custom_skills() -> SkillsListResponse:
|
async def list_custom_skills(app_config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
||||||
try:
|
try:
|
||||||
skills = [skill for skill in load_skills(enabled_only=False) if skill.category == "custom"]
|
skills = [skill for skill in load_skills(app_config, enabled_only=False) if skill.category == "custom"]
|
||||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to list custom skills: %s", e, exc_info=True)
|
logger.error("Failed to list custom skills: %s", e, exc_info=True)
|
||||||
@@ -145,13 +148,13 @@ async def list_custom_skills() -> SkillsListResponse:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
|
@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
|
||||||
async def get_custom_skill(skill_name: str) -> CustomSkillContentResponse:
|
async def get_custom_skill(skill_name: str, app_config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
||||||
try:
|
try:
|
||||||
skills = load_skills(enabled_only=False)
|
skills = load_skills(app_config, enabled_only=False)
|
||||||
skill = next((s for s in skills if s.name == skill_name and s.category == "custom"), None)
|
skill = next((s for s in skills if s.name == skill_name and s.category == "custom"), None)
|
||||||
if skill is None:
|
if skill is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
||||||
return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=read_custom_skill_content(skill_name))
|
return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=read_custom_skill_content(skill_name, app_config))
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -160,14 +163,18 @@ async def get_custom_skill(skill_name: str) -> CustomSkillContentResponse:
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
|
@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
|
||||||
async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest) -> CustomSkillContentResponse:
|
async def update_custom_skill(
|
||||||
|
skill_name: str,
|
||||||
|
request: CustomSkillUpdateRequest,
|
||||||
|
app_config: AppConfig = Depends(get_config),
|
||||||
|
) -> CustomSkillContentResponse:
|
||||||
try:
|
try:
|
||||||
ensure_custom_skill_is_editable(skill_name)
|
ensure_custom_skill_is_editable(skill_name, app_config)
|
||||||
validate_skill_markdown_content(skill_name, request.content)
|
validate_skill_markdown_content(skill_name, request.content)
|
||||||
scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/SKILL.md")
|
scan = await scan_skill_content(app_config, request.content, executable=False, location=f"{skill_name}/SKILL.md")
|
||||||
if scan.decision == "block":
|
if scan.decision == "block":
|
||||||
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
|
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
|
||||||
skill_file = get_custom_skill_dir(skill_name) / "SKILL.md"
|
skill_file = get_custom_skill_dir(skill_name, app_config) / "SKILL.md"
|
||||||
prev_content = skill_file.read_text(encoding="utf-8")
|
prev_content = skill_file.read_text(encoding="utf-8")
|
||||||
atomic_write(skill_file, request.content)
|
atomic_write(skill_file, request.content)
|
||||||
append_history(
|
append_history(
|
||||||
@@ -181,9 +188,10 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
|||||||
"new_content": request.content,
|
"new_content": request.content,
|
||||||
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
||||||
},
|
},
|
||||||
|
app_config,
|
||||||
)
|
)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async(app_config)
|
||||||
return await get_custom_skill(skill_name)
|
return await get_custom_skill(skill_name, app_config)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@@ -196,25 +204,31 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
||||||
async def delete_custom_skill(skill_name: str) -> dict[str, bool]:
|
async def delete_custom_skill(skill_name: str, app_config: AppConfig = Depends(get_config)) -> dict[str, bool]:
|
||||||
try:
|
try:
|
||||||
ensure_custom_skill_is_editable(skill_name)
|
ensure_custom_skill_is_editable(skill_name, app_config)
|
||||||
skill_dir = get_custom_skill_dir(skill_name)
|
skill_dir = get_custom_skill_dir(skill_name, app_config)
|
||||||
prev_content = read_custom_skill_content(skill_name)
|
prev_content = read_custom_skill_content(skill_name, app_config)
|
||||||
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."},
|
||||||
)
|
},
|
||||||
|
app_config,
|
||||||
|
)
|
||||||
|
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)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async(app_config)
|
||||||
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))
|
||||||
@@ -226,11 +240,11 @@ async def delete_custom_skill(skill_name: str) -> dict[str, bool]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
||||||
async def get_custom_skill_history(skill_name: str) -> CustomSkillHistoryResponse:
|
async def get_custom_skill_history(skill_name: str, app_config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
||||||
try:
|
try:
|
||||||
if not custom_skill_exists(skill_name) and not get_skill_history_file(skill_name).exists():
|
if not custom_skill_exists(skill_name, app_config) and not get_skill_history_file(skill_name, app_config).exists():
|
||||||
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
||||||
return CustomSkillHistoryResponse(history=read_history(skill_name))
|
return CustomSkillHistoryResponse(history=read_history(skill_name, app_config))
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -239,11 +253,15 @@ async def get_custom_skill_history(skill_name: str) -> CustomSkillHistoryRespons
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
|
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
|
||||||
async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest) -> CustomSkillContentResponse:
|
async def rollback_custom_skill(
|
||||||
|
skill_name: str,
|
||||||
|
request: SkillRollbackRequest,
|
||||||
|
app_config: AppConfig = Depends(get_config),
|
||||||
|
) -> CustomSkillContentResponse:
|
||||||
try:
|
try:
|
||||||
if not custom_skill_exists(skill_name) and not get_skill_history_file(skill_name).exists():
|
if not custom_skill_exists(skill_name, app_config) and not get_skill_history_file(skill_name, app_config).exists():
|
||||||
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
||||||
history = read_history(skill_name)
|
history = read_history(skill_name, app_config)
|
||||||
if not history:
|
if not history:
|
||||||
raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
|
raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
|
||||||
record = history[request.history_index]
|
record = history[request.history_index]
|
||||||
@@ -251,8 +269,8 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest)
|
|||||||
if target_content is None:
|
if target_content is None:
|
||||||
raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
|
raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
|
||||||
validate_skill_markdown_content(skill_name, target_content)
|
validate_skill_markdown_content(skill_name, target_content)
|
||||||
scan = await scan_skill_content(target_content, executable=False, location=f"{skill_name}/SKILL.md")
|
scan = await scan_skill_content(app_config, target_content, executable=False, location=f"{skill_name}/SKILL.md")
|
||||||
skill_file = get_custom_skill_file(skill_name)
|
skill_file = get_custom_skill_file(skill_name, app_config)
|
||||||
current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None
|
current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None
|
||||||
history_entry = {
|
history_entry = {
|
||||||
"action": "rollback",
|
"action": "rollback",
|
||||||
@@ -265,12 +283,12 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest)
|
|||||||
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
||||||
}
|
}
|
||||||
if scan.decision == "block":
|
if scan.decision == "block":
|
||||||
append_history(skill_name, history_entry)
|
append_history(skill_name, history_entry, app_config)
|
||||||
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, app_config)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async(app_config)
|
||||||
return await get_custom_skill(skill_name)
|
return await get_custom_skill(skill_name, app_config)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except IndexError:
|
except IndexError:
|
||||||
@@ -290,9 +308,9 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest)
|
|||||||
summary="Get Skill Details",
|
summary="Get Skill Details",
|
||||||
description="Retrieve detailed information about a specific skill by its name.",
|
description="Retrieve detailed information about a specific skill by its name.",
|
||||||
)
|
)
|
||||||
async def get_skill(skill_name: str) -> SkillResponse:
|
async def get_skill(skill_name: str, app_config: AppConfig = Depends(get_config)) -> SkillResponse:
|
||||||
try:
|
try:
|
||||||
skills = load_skills(enabled_only=False)
|
skills = load_skills(app_config, enabled_only=False)
|
||||||
skill = next((s for s in skills if s.name == skill_name), None)
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
|
||||||
if skill is None:
|
if skill is None:
|
||||||
@@ -312,9 +330,14 @@ async def get_skill(skill_name: str) -> SkillResponse:
|
|||||||
summary="Update Skill",
|
summary="Update Skill",
|
||||||
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
||||||
)
|
)
|
||||||
async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillResponse:
|
async def update_skill(
|
||||||
|
skill_name: str,
|
||||||
|
request: SkillUpdateRequest,
|
||||||
|
http_request: Request,
|
||||||
|
app_config: AppConfig = Depends(get_config),
|
||||||
|
) -> SkillResponse:
|
||||||
try:
|
try:
|
||||||
skills = load_skills(enabled_only=False)
|
skills = load_skills(app_config, enabled_only=False)
|
||||||
skill = next((s for s in skills if s.name == skill_name), None)
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
|
||||||
if skill is None:
|
if skill is None:
|
||||||
@@ -325,22 +348,29 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillRes
|
|||||||
config_path = Path.cwd().parent / "extensions_config.json"
|
config_path = Path.cwd().parent / "extensions_config.json"
|
||||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||||
|
|
||||||
extensions_config = get_extensions_config()
|
# Do not mutate the frozen AppConfig in place. Compose the new skills
|
||||||
extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled)
|
# state in a fresh dict, write to disk, and reload AppConfig below so
|
||||||
|
# every subsequent Depends(get_config) sees the refreshed snapshot.
|
||||||
|
ext = app_config.extensions
|
||||||
|
updated_skills = {name: {"enabled": skill_config.enabled} for name, skill_config in ext.skills.items()}
|
||||||
|
updated_skills[skill_name] = {"enabled": request.enabled}
|
||||||
|
|
||||||
config_data = {
|
config_data = {
|
||||||
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
|
"mcpServers": {name: server.model_dump() for name, server in ext.mcp_servers.items()},
|
||||||
"skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()},
|
"skills": updated_skills,
|
||||||
}
|
}
|
||||||
|
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(config_data, f, indent=2)
|
json.dump(config_data, f, indent=2)
|
||||||
|
|
||||||
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 AppConfig and swap ``app.state.config`` so subsequent
|
||||||
await refresh_skills_system_prompt_cache_async()
|
# ``Depends(get_config)`` sees the refreshed value.
|
||||||
|
reloaded = AppConfig.from_file()
|
||||||
|
http_request.app.state.config = reloaded
|
||||||
|
await refresh_skills_system_prompt_cache_async(reloaded)
|
||||||
|
|
||||||
skills = load_skills(enabled_only=False)
|
skills = load_skills(reloaded, 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)
|
||||||
|
|
||||||
if updated_skill is None:
|
if updated_skill is None:
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from langchain_core.messages import HumanMessage, SystemMessage
|
from langchain_core.messages import HumanMessage, SystemMessage
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
|
from app.gateway.deps import get_config
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -100,7 +102,7 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str:
|
|||||||
description="Generate short follow-up questions a user might ask next, based on recent conversation context.",
|
description="Generate short follow-up questions a user might ask next, based on recent conversation context.",
|
||||||
)
|
)
|
||||||
@require_permission("threads", "read", owner_check=True)
|
@require_permission("threads", "read", owner_check=True)
|
||||||
async def generate_suggestions(thread_id: str, body: SuggestionsRequest, request: Request) -> SuggestionsResponse:
|
async def generate_suggestions(thread_id: str, body: SuggestionsRequest, request: Request, app_config: AppConfig = Depends(get_config)) -> SuggestionsResponse:
|
||||||
if not body.messages:
|
if not body.messages:
|
||||||
return SuggestionsResponse(suggestions=[])
|
return SuggestionsResponse(suggestions=[])
|
||||||
|
|
||||||
@@ -122,8 +124,8 @@ async def generate_suggestions(thread_id: str, body: SuggestionsRequest, request
|
|||||||
user_content = f"Conversation Context:\n{conversation}\n\nGenerate {n} follow-up questions"
|
user_content = f"Conversation Context:\n{conversation}\n\nGenerate {n} follow-up questions"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
model = create_chat_model(name=body.model_name, thinking_enabled=False)
|
model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=app_config)
|
||||||
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()]
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class RunCreateRequest(BaseModel):
|
|||||||
after_seconds: float | None = Field(default=None, description="Delayed execution")
|
after_seconds: float | None = Field(default=None, description="Delayed execution")
|
||||||
if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy")
|
if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy")
|
||||||
feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys")
|
feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys")
|
||||||
|
follow_up_to_run_id: str | None = Field(default=None, description="Run ID this message follows up on. Auto-detected from latest successful run if not provided.")
|
||||||
|
|
||||||
|
|
||||||
class RunResponse(BaseModel):
|
class RunResponse(BaseModel):
|
||||||
@@ -311,15 +312,11 @@ async def list_thread_messages(
|
|||||||
if i in last_ai_indices:
|
if i in last_ai_indices:
|
||||||
run_id = msg["run_id"]
|
run_id = msg["run_id"]
|
||||||
fb = feedback_map.get(run_id)
|
fb = feedback_map.get(run_id)
|
||||||
msg["feedback"] = (
|
msg["feedback"] = {
|
||||||
{
|
"feedback_id": fb["feedback_id"],
|
||||||
"feedback_id": fb["feedback_id"],
|
"rating": fb["rating"],
|
||||||
"rating": fb["rating"],
|
"comment": fb.get("comment"),
|
||||||
"comment": fb.get("comment"),
|
} if fb else None
|
||||||
}
|
|
||||||
if fb
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
msg["feedback"] = None
|
msg["feedback"] = None
|
||||||
|
|
||||||
@@ -342,8 +339,7 @@ async def list_run_messages(
|
|||||||
"""
|
"""
|
||||||
event_store = get_run_event_store(request)
|
event_store = get_run_event_store(request)
|
||||||
rows = await event_store.list_messages_by_run(
|
rows = await event_store.list_messages_by_run(
|
||||||
thread_id,
|
thread_id, run_id,
|
||||||
run_id,
|
|
||||||
limit=limit + 1,
|
limit=limit + 1,
|
||||||
before_seq=before_seq,
|
before_seq=before_seq,
|
||||||
after_seq=after_seq,
|
after_seq=after_seq,
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
|
|
||||||
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
|
from app.gateway.deps import get_config
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
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,
|
||||||
@@ -55,12 +57,40 @@ 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(app_config: AppConfig, key: str, default: object) -> object:
|
||||||
|
"""Read a value from the uploads config, supporting dict and attribute access."""
|
||||||
|
uploads_cfg = getattr(app_config, "uploads", None)
|
||||||
|
if isinstance(uploads_cfg, dict):
|
||||||
|
return uploads_cfg.get(key, default)
|
||||||
|
return getattr(uploads_cfg, key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_convert_documents_enabled(app_config: AppConfig) -> 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(app_config, "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)
|
||||||
@require_permission("threads", "write", owner_check=True, require_existing=False)
|
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
||||||
async def upload_files(
|
async def upload_files(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
files: list[UploadFile] = File(...),
|
files: list[UploadFile] = File(...),
|
||||||
|
app_config: AppConfig = Depends(get_config),
|
||||||
) -> UploadResponse:
|
) -> UploadResponse:
|
||||||
"""Upload multiple files to a thread's uploads directory."""
|
"""Upload multiple files to a thread's uploads directory."""
|
||||||
if not files:
|
if not files:
|
||||||
@@ -73,9 +103,13 @@ async def upload_files(
|
|||||||
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=get_effective_user_id())
|
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=get_effective_user_id())
|
||||||
uploaded_files = []
|
uploaded_files = []
|
||||||
|
|
||||||
sandbox_provider = get_sandbox_provider()
|
sandbox_provider = get_sandbox_provider(app_config)
|
||||||
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(app_config)
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
@@ -94,7 +128,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)
|
||||||
|
|
||||||
@@ -109,12 +143,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,8 @@ import dataclasses
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
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
|
||||||
@@ -102,9 +104,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
|
||||||
|
|
||||||
@@ -121,10 +124,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
|
||||||
@@ -143,7 +148,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", {}))
|
||||||
@@ -155,13 +167,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
|
||||||
@@ -195,6 +213,21 @@ async def start_run(
|
|||||||
|
|
||||||
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
|
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
|
||||||
|
|
||||||
|
# Resolve follow_up_to_run_id: explicit from request, or auto-detect from latest successful run
|
||||||
|
follow_up_to_run_id = getattr(body, "follow_up_to_run_id", None)
|
||||||
|
if follow_up_to_run_id is None:
|
||||||
|
run_store = get_run_store(request)
|
||||||
|
try:
|
||||||
|
recent_runs = await run_store.list_by_thread(thread_id, limit=1)
|
||||||
|
if recent_runs and recent_runs[0].get("status") == "success":
|
||||||
|
follow_up_to_run_id = recent_runs[0]["run_id"]
|
||||||
|
except Exception:
|
||||||
|
pass # Don't block run creation
|
||||||
|
|
||||||
|
# Enrich base context with per-run field
|
||||||
|
if follow_up_to_run_id:
|
||||||
|
run_ctx = dataclasses.replace(run_ctx, follow_up_to_run_id=follow_up_to_run_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
record = await run_mgr.create_or_reject(
|
record = await run_mgr.create_or_reject(
|
||||||
thread_id,
|
thread_id,
|
||||||
@@ -203,6 +236,7 @@ async def start_run(
|
|||||||
metadata=body.metadata or {},
|
metadata=body.metadata or {},
|
||||||
kwargs={"input": body.input, "config": body.config},
|
kwargs={"input": body.input, "config": body.config},
|
||||||
multitask_strategy=body.multitask_strategy,
|
multitask_strategy=body.multitask_strategy,
|
||||||
|
follow_up_to_run_id=follow_up_to_run_id,
|
||||||
)
|
)
|
||||||
except ConflictError as exc:
|
except ConflictError as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
@@ -243,6 +277,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}")
|
||||||
|
|||||||
@@ -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 │
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -3,30 +3,40 @@ import logging
|
|||||||
from langchain.agents import create_agent
|
from langchain.agents import create_agent
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
from langchain_core.runnables import RunnableConfig
|
from langchain_core.runnables import RunnableConfig
|
||||||
|
from langgraph.graph.state import CompiledStateGraph
|
||||||
|
|
||||||
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 SummarizationMiddleware
|
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 AppConfig
|
||||||
from deerflow.config.summarization_config import get_summarization_config
|
from deerflow.config.deer_flow_context import DeerFlowContext
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_model_name(requested_model_name: str | None = None) -> str:
|
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(app_config: AppConfig, 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()
|
|
||||||
default_model_name = app_config.models[0].name if app_config.models else None
|
default_model_name = app_config.models[0].name if app_config.models else None
|
||||||
if default_model_name is None:
|
if default_model_name is None:
|
||||||
raise ValueError("No chat models are configured. Please configure at least one model in config.yaml.")
|
raise ValueError("No chat models are configured. Please configure at least one model in config.yaml.")
|
||||||
@@ -39,9 +49,9 @@ 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(app_config: AppConfig) -> DeerFlowSummarizationMiddleware | None:
|
||||||
"""Create and configure the summarization middleware from config."""
|
"""Create and configure the summarization middleware from config."""
|
||||||
config = get_summarization_config()
|
config = app_config.summarization
|
||||||
|
|
||||||
if not config.enabled:
|
if not config.enabled:
|
||||||
return None
|
return None
|
||||||
@@ -62,9 +72,9 @@ def _create_summarization_middleware() -> SummarizationMiddleware | None:
|
|||||||
# as middleware rather than lead_agent (SummarizationMiddleware is a
|
# as middleware rather than lead_agent (SummarizationMiddleware is a
|
||||||
# LangChain built-in, so we tag the model at creation time).
|
# LangChain built-in, so we tag the model at creation time).
|
||||||
if config.model_name:
|
if config.model_name:
|
||||||
model = create_chat_model(name=config.model_name, thinking_enabled=False)
|
model = create_chat_model(name=config.model_name, thinking_enabled=False, app_config=app_config)
|
||||||
else:
|
else:
|
||||||
model = create_chat_model(thinking_enabled=False)
|
model = create_chat_model(thinking_enabled=False, app_config=app_config)
|
||||||
model = model.with_config(tags=["middleware:summarize"])
|
model = model.with_config(tags=["middleware:summarize"])
|
||||||
|
|
||||||
# Prepare kwargs
|
# Prepare kwargs
|
||||||
@@ -80,7 +90,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 app_config.memory.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 = 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:
|
||||||
@@ -208,10 +239,18 @@ Being proactive with task management demonstrates thoroughness and ensures all r
|
|||||||
# ViewImageMiddleware should be before ClarificationMiddleware to inject image details before LLM
|
# ViewImageMiddleware should be before ClarificationMiddleware to inject image details before LLM
|
||||||
# ToolErrorHandlingMiddleware should be before ClarificationMiddleware to convert tool exceptions to ToolMessages
|
# ToolErrorHandlingMiddleware should be before ClarificationMiddleware to convert tool exceptions to ToolMessages
|
||||||
# ClarificationMiddleware should be last to intercept clarification requests after model calls
|
# ClarificationMiddleware should be last to intercept clarification requests after model calls
|
||||||
def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_name: str | None = None, custom_middlewares: list[AgentMiddleware] | None = None):
|
def _build_middlewares(
|
||||||
|
app_config: AppConfig,
|
||||||
|
config: RunnableConfig,
|
||||||
|
*,
|
||||||
|
model_name: str | None,
|
||||||
|
agent_name: str | None = None,
|
||||||
|
custom_middlewares: list[AgentMiddleware] | None = None,
|
||||||
|
):
|
||||||
"""Build middleware chain based on runtime configuration.
|
"""Build middleware chain based on runtime configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
app_config: Resolved application config.
|
||||||
config: Runtime configuration containing configurable options like is_plan_mode.
|
config: Runtime configuration containing configurable options like is_plan_mode.
|
||||||
agent_name: If provided, MemoryMiddleware will use per-agent memory storage.
|
agent_name: If provided, MemoryMiddleware will use per-agent memory storage.
|
||||||
custom_middlewares: Optional list of custom middlewares to inject into the chain.
|
custom_middlewares: Optional list of custom middlewares to inject into the chain.
|
||||||
@@ -219,21 +258,22 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam
|
|||||||
Returns:
|
Returns:
|
||||||
List of middleware instances.
|
List of middleware instances.
|
||||||
"""
|
"""
|
||||||
middlewares = build_lead_runtime_middlewares(lazy_init=True)
|
middlewares = build_lead_runtime_middlewares(app_config=app_config, lazy_init=True)
|
||||||
|
|
||||||
# Add summarization middleware if enabled
|
# Add summarization middleware if enabled
|
||||||
summarization_middleware = _create_summarization_middleware()
|
summarization_middleware = _create_summarization_middleware(app_config)
|
||||||
if summarization_middleware is not None:
|
if summarization_middleware is not None:
|
||||||
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)
|
||||||
|
|
||||||
# Add TokenUsageMiddleware when token_usage tracking is enabled
|
# Add TokenUsageMiddleware when token_usage tracking is enabled
|
||||||
if get_app_config().token_usage.enabled:
|
if app_config.token_usage.enabled:
|
||||||
middlewares.append(TokenUsageMiddleware())
|
middlewares.append(TokenUsageMiddleware())
|
||||||
|
|
||||||
# Add TitleMiddleware
|
# Add TitleMiddleware
|
||||||
@@ -244,7 +284,6 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam
|
|||||||
|
|
||||||
# Add ViewImageMiddleware only if the current model supports vision.
|
# Add ViewImageMiddleware only if the current model supports vision.
|
||||||
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
|
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
|
||||||
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_name else None
|
||||||
if model_config is not None and model_config.supports_vision:
|
if model_config is not None and model_config.supports_vision:
|
||||||
middlewares.append(ViewImageMiddleware())
|
middlewares.append(ViewImageMiddleware())
|
||||||
@@ -256,9 +295,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
|
||||||
@@ -273,12 +312,33 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam
|
|||||||
return middlewares
|
return middlewares
|
||||||
|
|
||||||
|
|
||||||
def make_lead_agent(config: RunnableConfig):
|
def make_lead_agent(
|
||||||
|
config: RunnableConfig,
|
||||||
|
app_config: AppConfig | None = None,
|
||||||
|
) -> CompiledStateGraph:
|
||||||
|
"""Build the lead agent from runtime config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: LangGraph ``RunnableConfig`` carrying per-invocation options
|
||||||
|
(``thinking_enabled``, ``model_name``, ``is_plan_mode``, etc.).
|
||||||
|
app_config: Resolved application config. Required for in-process
|
||||||
|
entry points (DeerFlowClient, Gateway Worker). When omitted we
|
||||||
|
are being called via ``langgraph.json`` registration and reload
|
||||||
|
from disk — the LangGraph Server bootstrap path has no other
|
||||||
|
way to thread the value.
|
||||||
|
"""
|
||||||
# Lazy import to avoid circular dependency
|
# Lazy import to avoid circular dependency
|
||||||
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", {})
|
if app_config is None:
|
||||||
|
# LangGraph Server registers ``make_lead_agent`` via ``langgraph.json``
|
||||||
|
# and hands us only a ``RunnableConfig``. Reload config from disk
|
||||||
|
# here — it's a pure function, equivalent to the process-global the
|
||||||
|
# old code path would have read.
|
||||||
|
app_config = AppConfig.from_file()
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -287,16 +347,15 @@ 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 from agent config (if any), or None to let _resolve_model_name pick the default
|
# 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 None
|
agent_model_name = agent_config.model if agent_config and agent_config.model else None
|
||||||
|
|
||||||
# Final model name resolution: request → agent config → global default, with fallback for unknown names
|
# Final model name resolution: request → agent config → global default, with fallback for unknown names
|
||||||
model_name = _resolve_model_name(requested_model_name or agent_model_name)
|
model_name = _resolve_model_name(app_config, requested_model_name or agent_model_name)
|
||||||
|
|
||||||
app_config = get_app_config()
|
|
||||||
model_config = app_config.get_model_config(model_name)
|
model_config = app_config.get_model_config(model_name)
|
||||||
|
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
@@ -328,26 +387,30 @@ 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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_bootstrap:
|
if is_bootstrap:
|
||||||
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
||||||
return create_agent(
|
return create_agent(
|
||||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled),
|
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=app_config),
|
||||||
tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled) + [setup_agent],
|
tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=app_config) + [setup_agent],
|
||||||
middleware=_build_middlewares(config, model_name=model_name),
|
middleware=_build_middlewares(app_config, config, model_name=model_name),
|
||||||
system_prompt=apply_prompt_template(subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, available_skills=set(["bootstrap"])),
|
system_prompt=apply_prompt_template(app_config, subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, available_skills=set(["bootstrap"])),
|
||||||
state_schema=ThreadState,
|
state_schema=ThreadState,
|
||||||
|
context_schema=DeerFlowContext,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default lead agent (unchanged behavior)
|
# Default lead agent (unchanged behavior)
|
||||||
return create_agent(
|
return create_agent(
|
||||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort),
|
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=app_config),
|
||||||
tools=get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled),
|
tools=get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled, app_config=app_config),
|
||||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name),
|
middleware=_build_middlewares(app_config, config, model_name=model_name, agent_name=agent_name),
|
||||||
system_prompt=apply_prompt_template(
|
system_prompt=apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_name=agent_name, available_skills=set(agent_config.skills) if agent_config and agent_config.skills is not None else None
|
app_config, subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_name=agent_name, available_skills=set(agent_config.skills) if agent_config and agent_config.skills is not None else None
|
||||||
),
|
),
|
||||||
state_schema=ThreadState,
|
state_schema=ThreadState,
|
||||||
|
context_schema=DeerFlowContext,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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.config.app_config import AppConfig
|
||||||
from deerflow.skills import load_skills
|
from deerflow.skills import load_skills
|
||||||
from deerflow.skills.types import Skill
|
from deerflow.skills.types import Skill
|
||||||
from deerflow.subagents import get_available_subagent_names
|
from deerflow.subagents import get_available_subagent_names
|
||||||
@@ -19,19 +20,20 @@ _enabled_skills_refresh_version = 0
|
|||||||
_enabled_skills_refresh_event = threading.Event()
|
_enabled_skills_refresh_event = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
def _load_enabled_skills_sync() -> list[Skill]:
|
def _load_enabled_skills_sync(app_config: AppConfig | None) -> list[Skill]:
|
||||||
return list(load_skills(enabled_only=True))
|
return list(load_skills(app_config, enabled_only=True))
|
||||||
|
|
||||||
|
|
||||||
def _start_enabled_skills_refresh_thread() -> None:
|
def _start_enabled_skills_refresh_thread(app_config: AppConfig | None) -> None:
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=_refresh_enabled_skills_cache_worker,
|
target=_refresh_enabled_skills_cache_worker,
|
||||||
|
args=(app_config,),
|
||||||
name="deerflow-enabled-skills-loader",
|
name="deerflow-enabled-skills-loader",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
|
|
||||||
def _refresh_enabled_skills_cache_worker() -> None:
|
def _refresh_enabled_skills_cache_worker(app_config: AppConfig | None) -> None:
|
||||||
global _enabled_skills_cache, _enabled_skills_refresh_active
|
global _enabled_skills_cache, _enabled_skills_refresh_active
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -39,8 +41,8 @@ def _refresh_enabled_skills_cache_worker() -> None:
|
|||||||
target_version = _enabled_skills_refresh_version
|
target_version = _enabled_skills_refresh_version
|
||||||
|
|
||||||
try:
|
try:
|
||||||
skills = _load_enabled_skills_sync()
|
skills = _load_enabled_skills_sync(app_config)
|
||||||
except Exception:
|
except (OSError, ImportError):
|
||||||
logger.exception("Failed to load enabled skills for prompt injection")
|
logger.exception("Failed to load enabled skills for prompt injection")
|
||||||
skills = []
|
skills = []
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ def _refresh_enabled_skills_cache_worker() -> None:
|
|||||||
_enabled_skills_cache = None
|
_enabled_skills_cache = None
|
||||||
|
|
||||||
|
|
||||||
def _ensure_enabled_skills_cache() -> threading.Event:
|
def _ensure_enabled_skills_cache(app_config: AppConfig | None) -> threading.Event:
|
||||||
global _enabled_skills_refresh_active
|
global _enabled_skills_refresh_active
|
||||||
|
|
||||||
with _enabled_skills_lock:
|
with _enabled_skills_lock:
|
||||||
@@ -68,11 +70,11 @@ def _ensure_enabled_skills_cache() -> threading.Event:
|
|||||||
_enabled_skills_refresh_active = True
|
_enabled_skills_refresh_active = True
|
||||||
_enabled_skills_refresh_event.clear()
|
_enabled_skills_refresh_event.clear()
|
||||||
|
|
||||||
_start_enabled_skills_refresh_thread()
|
_start_enabled_skills_refresh_thread(app_config)
|
||||||
return _enabled_skills_refresh_event
|
return _enabled_skills_refresh_event
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_enabled_skills_cache() -> threading.Event:
|
def _invalidate_enabled_skills_cache(app_config: AppConfig | None) -> threading.Event:
|
||||||
global _enabled_skills_cache, _enabled_skills_refresh_active, _enabled_skills_refresh_version
|
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()
|
||||||
@@ -84,30 +86,30 @@ def _invalidate_enabled_skills_cache() -> threading.Event:
|
|||||||
return _enabled_skills_refresh_event
|
return _enabled_skills_refresh_event
|
||||||
_enabled_skills_refresh_active = True
|
_enabled_skills_refresh_active = True
|
||||||
|
|
||||||
_start_enabled_skills_refresh_thread()
|
_start_enabled_skills_refresh_thread(app_config)
|
||||||
return _enabled_skills_refresh_event
|
return _enabled_skills_refresh_event
|
||||||
|
|
||||||
|
|
||||||
def prime_enabled_skills_cache() -> None:
|
def prime_enabled_skills_cache(app_config: AppConfig | None = None) -> None:
|
||||||
_ensure_enabled_skills_cache()
|
_ensure_enabled_skills_cache(app_config)
|
||||||
|
|
||||||
|
|
||||||
def warm_enabled_skills_cache(timeout_seconds: float = _ENABLED_SKILLS_REFRESH_WAIT_TIMEOUT_SECONDS) -> bool:
|
def warm_enabled_skills_cache(app_config: AppConfig | None = None, timeout_seconds: float = _ENABLED_SKILLS_REFRESH_WAIT_TIMEOUT_SECONDS) -> bool:
|
||||||
if _ensure_enabled_skills_cache().wait(timeout=timeout_seconds):
|
if _ensure_enabled_skills_cache(app_config).wait(timeout=timeout_seconds):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
logger.warning("Timed out waiting %.1fs for enabled skills cache warm-up", timeout_seconds)
|
logger.warning("Timed out waiting %.1fs for enabled skills cache warm-up", timeout_seconds)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_enabled_skills():
|
def _get_enabled_skills(app_config: AppConfig | None = None):
|
||||||
with _enabled_skills_lock:
|
with _enabled_skills_lock:
|
||||||
cached = _enabled_skills_cache
|
cached = _enabled_skills_cache
|
||||||
|
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return list(cached)
|
return list(cached)
|
||||||
|
|
||||||
_ensure_enabled_skills_cache()
|
_ensure_enabled_skills_cache(app_config)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -115,12 +117,12 @@ def _skill_mutability_label(category: str) -> str:
|
|||||||
return "[custom, editable]" if category == "custom" else "[built-in]"
|
return "[custom, editable]" if category == "custom" else "[built-in]"
|
||||||
|
|
||||||
|
|
||||||
def clear_skills_system_prompt_cache() -> None:
|
def clear_skills_system_prompt_cache(app_config: AppConfig | None = None) -> None:
|
||||||
_invalidate_enabled_skills_cache()
|
_invalidate_enabled_skills_cache(app_config)
|
||||||
|
|
||||||
|
|
||||||
async def refresh_skills_system_prompt_cache_async() -> None:
|
async def refresh_skills_system_prompt_cache_async(app_config: AppConfig | None = None) -> None:
|
||||||
await asyncio.to_thread(_invalidate_enabled_skills_cache().wait)
|
await asyncio.to_thread(_invalidate_enabled_skills_cache(app_config).wait)
|
||||||
|
|
||||||
|
|
||||||
def _reset_skills_system_prompt_cache_state() -> None:
|
def _reset_skills_system_prompt_cache_state() -> None:
|
||||||
@@ -134,10 +136,10 @@ def _reset_skills_system_prompt_cache_state() -> None:
|
|||||||
_enabled_skills_refresh_event.clear()
|
_enabled_skills_refresh_event.clear()
|
||||||
|
|
||||||
|
|
||||||
def _refresh_enabled_skills_cache() -> None:
|
def _refresh_enabled_skills_cache(app_config: AppConfig | None = None) -> None:
|
||||||
"""Backward-compatible test helper for direct synchronous reload."""
|
"""Backward-compatible test helper for direct synchronous reload."""
|
||||||
try:
|
try:
|
||||||
skills = _load_enabled_skills_sync()
|
skills = _load_enabled_skills_sync(app_config)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to load enabled skills for prompt injection")
|
logger.exception("Failed to load enabled skills for prompt injection")
|
||||||
skills = []
|
skills = []
|
||||||
@@ -164,23 +166,53 @@ Skip simple one-off tasks.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _build_subagent_section(max_concurrent: int) -> str:
|
def _build_available_subagents_description(available_names: list[str], bash_available: bool, app_config: AppConfig) -> 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, app_config)
|
||||||
|
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, app_config: AppConfig) -> str:
|
||||||
"""Build the subagent system prompt section with dynamic concurrency limit.
|
"""Build the subagent system prompt section with dynamic concurrency limit.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
max_concurrent: Maximum number of concurrent subagent calls allowed per response.
|
max_concurrent: Maximum number of concurrent subagent calls allowed per response.
|
||||||
|
app_config: Application config used to gate bash availability.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
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(app_config)
|
||||||
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, app_config)
|
||||||
)
|
|
||||||
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()'
|
||||||
@@ -420,7 +452,7 @@ You: "Deploying to staging..." [proceed]
|
|||||||
- Treat `/mnt/user-data/workspace` as your default current working directory for coding and file-editing tasks
|
- 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`
|
- 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
|
- 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_file` tool
|
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_files` tool
|
||||||
{acp_section}
|
{acp_section}
|
||||||
</working_directory>
|
</working_directory>
|
||||||
|
|
||||||
@@ -507,37 +539,34 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _get_memory_context(agent_name: str | None = None) -> str:
|
def _get_memory_context(app_config: AppConfig, agent_name: str | None = None) -> str:
|
||||||
"""Get memory context for injection into system prompt.
|
"""Get memory context for injection into system prompt.
|
||||||
|
|
||||||
Args:
|
Returns an empty string when memory is disabled or the stored memory file
|
||||||
agent_name: If provided, loads per-agent memory. If None, loads global memory.
|
cannot be read/parsed. A corrupt memory.json degrades the prompt to
|
||||||
|
no-memory; it never kills the agent.
|
||||||
Returns:
|
|
||||||
Formatted memory context string wrapped in XML tags, or empty string if disabled.
|
|
||||||
"""
|
"""
|
||||||
|
from deerflow.agents.memory import format_memory_for_injection, get_memory_data
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
|
memory_config = app_config.memory
|
||||||
|
if not memory_config.enabled or not memory_config.injection_enabled:
|
||||||
|
return ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from deerflow.agents.memory import format_memory_for_injection, get_memory_data
|
memory_data = get_memory_data(memory_config, agent_name, user_id=get_effective_user_id())
|
||||||
from deerflow.config.memory_config import get_memory_config
|
except (OSError, ValueError, UnicodeDecodeError):
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
logger.exception("Failed to load memory data for prompt injection")
|
||||||
|
return ""
|
||||||
|
|
||||||
config = get_memory_config()
|
memory_content = format_memory_for_injection(memory_data, max_tokens=memory_config.max_injection_tokens)
|
||||||
if not config.enabled or not config.injection_enabled:
|
if not memory_content.strip():
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
memory_data = get_memory_data(agent_name, user_id=get_effective_user_id())
|
return f"""<memory>
|
||||||
memory_content = format_memory_for_injection(memory_data, max_tokens=config.max_injection_tokens)
|
|
||||||
|
|
||||||
if not memory_content.strip():
|
|
||||||
return ""
|
|
||||||
|
|
||||||
return f"""<memory>
|
|
||||||
{memory_content}
|
{memory_content}
|
||||||
</memory>
|
</memory>
|
||||||
"""
|
"""
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to load memory context: %s", e)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
@lru_cache(maxsize=32)
|
||||||
@@ -572,19 +601,12 @@ You have access to skills that provide optimized workflows for specific tasks. E
|
|||||||
</skill_system>"""
|
</skill_system>"""
|
||||||
|
|
||||||
|
|
||||||
def get_skills_prompt_section(available_skills: set[str] | None = None) -> str:
|
def get_skills_prompt_section(app_config: AppConfig, available_skills: set[str] | None = None) -> str:
|
||||||
"""Generate the skills prompt section with available skills list."""
|
"""Generate the skills prompt section with available skills list."""
|
||||||
skills = _get_enabled_skills()
|
skills = _get_enabled_skills(app_config)
|
||||||
|
|
||||||
try:
|
container_base_path = app_config.skills.container_path
|
||||||
from deerflow.config import get_app_config
|
skill_evolution_enabled = app_config.skill_evolution.enabled
|
||||||
|
|
||||||
config = get_app_config()
|
|
||||||
container_base_path = config.skills.container_path
|
|
||||||
skill_evolution_enabled = config.skill_evolution.enabled
|
|
||||||
except Exception:
|
|
||||||
container_base_path = "/mnt/skills"
|
|
||||||
skill_evolution_enabled = False
|
|
||||||
|
|
||||||
if not skills and not skill_evolution_enabled:
|
if not skills and not skill_evolution_enabled:
|
||||||
return ""
|
return ""
|
||||||
@@ -608,7 +630,7 @@ def get_agent_soul(agent_name: str | None) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def get_deferred_tools_prompt_section() -> str:
|
def get_deferred_tools_prompt_section(app_config: AppConfig) -> str:
|
||||||
"""Generate <available-deferred-tools> block for the system prompt.
|
"""Generate <available-deferred-tools> block for the system prompt.
|
||||||
|
|
||||||
Lists only deferred tool names so the agent knows what exists
|
Lists only deferred tool names so the agent knows what exists
|
||||||
@@ -617,12 +639,7 @@ def get_deferred_tools_prompt_section() -> str:
|
|||||||
"""
|
"""
|
||||||
from deerflow.tools.builtins.tool_search import get_deferred_registry
|
from deerflow.tools.builtins.tool_search import get_deferred_registry
|
||||||
|
|
||||||
try:
|
if not app_config.tool_search.enabled:
|
||||||
from deerflow.config import get_app_config
|
|
||||||
|
|
||||||
if not get_app_config().tool_search.enabled:
|
|
||||||
return ""
|
|
||||||
except Exception:
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
registry = get_deferred_registry()
|
registry = get_deferred_registry()
|
||||||
@@ -633,15 +650,9 @@ def get_deferred_tools_prompt_section() -> str:
|
|||||||
return f"<available-deferred-tools>\n{names}\n</available-deferred-tools>"
|
return f"<available-deferred-tools>\n{names}\n</available-deferred-tools>"
|
||||||
|
|
||||||
|
|
||||||
def _build_acp_section() -> str:
|
def _build_acp_section(app_config: AppConfig) -> str:
|
||||||
"""Build the ACP agent prompt section, only if ACP agents are configured."""
|
"""Build the ACP agent prompt section, only if ACP agents are configured."""
|
||||||
try:
|
if not app_config.acp_agents:
|
||||||
from deerflow.config.acp_config import get_acp_agents
|
|
||||||
|
|
||||||
agents = get_acp_agents()
|
|
||||||
if not agents:
|
|
||||||
return ""
|
|
||||||
except Exception:
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -649,19 +660,13 @@ 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`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_custom_mounts_section() -> str:
|
def _build_custom_mounts_section(app_config: AppConfig) -> str:
|
||||||
"""Build a prompt section for explicitly configured sandbox mounts."""
|
"""Build a prompt section for explicitly configured sandbox mounts."""
|
||||||
try:
|
mounts = app_config.sandbox.mounts or []
|
||||||
from deerflow.config import get_app_config
|
|
||||||
|
|
||||||
mounts = get_app_config().sandbox.mounts or []
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to load configured sandbox mounts for the lead-agent prompt")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if not mounts:
|
if not mounts:
|
||||||
return ""
|
return ""
|
||||||
@@ -675,13 +680,20 @@ def _build_custom_mounts_section() -> str:
|
|||||||
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
|
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
|
||||||
|
|
||||||
|
|
||||||
def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagents: int = 3, *, agent_name: str | None = None, available_skills: set[str] | None = None) -> str:
|
def apply_prompt_template(
|
||||||
|
app_config: AppConfig,
|
||||||
|
subagent_enabled: bool = False,
|
||||||
|
max_concurrent_subagents: int = 3,
|
||||||
|
*,
|
||||||
|
agent_name: str | None = None,
|
||||||
|
available_skills: set[str] | None = None,
|
||||||
|
) -> str:
|
||||||
# Get memory context
|
# Get memory context
|
||||||
memory_context = _get_memory_context(agent_name)
|
memory_context = _get_memory_context(app_config, agent_name)
|
||||||
|
|
||||||
# Include subagent section only if enabled (from runtime parameter)
|
# Include subagent section only if enabled (from runtime parameter)
|
||||||
n = max_concurrent_subagents
|
n = max_concurrent_subagents
|
||||||
subagent_section = _build_subagent_section(n) if subagent_enabled else ""
|
subagent_section = _build_subagent_section(n, app_config) if subagent_enabled else ""
|
||||||
|
|
||||||
# Add subagent reminder to critical_reminders if enabled
|
# Add subagent reminder to critical_reminders if enabled
|
||||||
subagent_reminder = (
|
subagent_reminder = (
|
||||||
@@ -702,14 +714,14 @@ def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagen
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get skills section
|
# Get skills section
|
||||||
skills_section = get_skills_prompt_section(available_skills)
|
skills_section = get_skills_prompt_section(app_config, available_skills)
|
||||||
|
|
||||||
# Get deferred tools section (tool_search)
|
# Get deferred tools section (tool_search)
|
||||||
deferred_tools_section = get_deferred_tools_prompt_section()
|
deferred_tools_section = get_deferred_tools_prompt_section(app_config)
|
||||||
|
|
||||||
# Build ACP agent section only if ACP agents are configured
|
# Build ACP agent section only if ACP agents are configured
|
||||||
acp_section = _build_acp_section()
|
acp_section = _build_acp_section(app_config)
|
||||||
custom_mounts_section = _build_custom_mounts_section()
|
custom_mounts_section = _build_custom_mounts_section(app_config)
|
||||||
acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section)
|
acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section)
|
||||||
|
|
||||||
# Format the prompt with dynamic skills and memory
|
# Format the prompt with dynamic skills and memory
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -7,11 +7,17 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import UTC, 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.app_config import AppConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level config pointer set by the middleware that owns the queue.
|
||||||
|
# The queue runs on a background Timer thread where ``Runtime`` and FastAPI
|
||||||
|
# request context are not accessible; the enqueuer (which does have runtime
|
||||||
|
# context) is responsible for plumbing ``AppConfig`` through ``add()``.
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ConversationContext:
|
class ConversationContext:
|
||||||
"""Context for a conversation to be processed for memory update."""
|
"""Context for a conversation to be processed for memory update."""
|
||||||
@@ -31,10 +37,21 @@ class MemoryUpdateQueue:
|
|||||||
This queue collects conversation contexts and processes them after
|
This queue collects conversation contexts and processes them after
|
||||||
a configurable debounce period. Multiple conversations received within
|
a configurable debounce period. Multiple conversations received within
|
||||||
the debounce window are batched together.
|
the debounce window are batched together.
|
||||||
|
|
||||||
|
The queue captures an ``AppConfig`` reference at construction time and
|
||||||
|
reuses it for the MemoryUpdater it spawns. Callers must construct a
|
||||||
|
fresh queue when the config changes rather than reaching into a global.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, app_config: AppConfig):
|
||||||
"""Initialize the memory update queue."""
|
"""Initialize the memory update queue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_config: Application config. The queue reads its own
|
||||||
|
``memory`` section for debounce timing and hands the full
|
||||||
|
config to :class:`MemoryUpdater`.
|
||||||
|
"""
|
||||||
|
self._app_config = app_config
|
||||||
self._queue: list[ConversationContext] = []
|
self._queue: list[ConversationContext] = []
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._timer: threading.Timer | None = None
|
self._timer: threading.Timer | None = None
|
||||||
@@ -49,66 +66,99 @@ class MemoryUpdateQueue:
|
|||||||
correction_detected: bool = False,
|
correction_detected: bool = False,
|
||||||
reinforcement_detected: bool = False,
|
reinforcement_detected: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a conversation to the update queue.
|
"""Add a conversation to the update queue."""
|
||||||
|
config = self._app_config.memory
|
||||||
Args:
|
|
||||||
thread_id: The thread ID.
|
|
||||||
messages: The conversation messages.
|
|
||||||
agent_name: If provided, memory is stored per-agent. If None, uses global memory.
|
|
||||||
user_id: The user ID captured at enqueue time. Stored in ConversationContext so it
|
|
||||||
survives the threading.Timer boundary (ContextVar does not propagate across
|
|
||||||
raw threads).
|
|
||||||
correction_detected: Whether recent turns include an explicit correction signal.
|
|
||||||
reinforcement_detected: Whether recent turns include a positive reinforcement signal.
|
|
||||||
"""
|
|
||||||
config = get_memory_config()
|
|
||||||
if not config.enabled:
|
if not config.enabled:
|
||||||
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,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
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,
|
||||||
|
user_id: str | None = None,
|
||||||
|
correction_detected: bool = False,
|
||||||
|
reinforcement_detected: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Add a conversation and start processing immediately in the background."""
|
||||||
|
config = self._app_config.memory
|
||||||
|
if not config.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._enqueue_locked(
|
||||||
|
thread_id=thread_id,
|
||||||
|
messages=messages,
|
||||||
|
agent_name=agent_name,
|
||||||
|
user_id=user_id,
|
||||||
|
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,
|
||||||
|
user_id: str | None = 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,
|
||||||
|
user_id=user_id,
|
||||||
|
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 = self._app_config.memory
|
||||||
|
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
|
||||||
@@ -116,8 +166,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:
|
||||||
@@ -131,7 +181,7 @@ class MemoryUpdateQueue:
|
|||||||
logger.info("Processing %d queued memory updates", len(contexts_to_process))
|
logger.info("Processing %d queued memory updates", len(contexts_to_process))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updater = MemoryUpdater()
|
updater = MemoryUpdater(self._app_config)
|
||||||
|
|
||||||
for context in contexts_to_process:
|
for context in contexts_to_process:
|
||||||
try:
|
try:
|
||||||
@@ -171,6 +221,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.
|
||||||
|
|
||||||
@@ -196,31 +253,35 @@ class MemoryUpdateQueue:
|
|||||||
return self._processing
|
return self._processing
|
||||||
|
|
||||||
|
|
||||||
# Global singleton instance
|
# Queues keyed by ``id(AppConfig)`` so tests and multi-client setups with
|
||||||
_memory_queue: MemoryUpdateQueue | None = None
|
# distinct configs do not share a debounce queue.
|
||||||
|
_memory_queues: dict[int, MemoryUpdateQueue] = {}
|
||||||
_queue_lock = threading.Lock()
|
_queue_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_memory_queue() -> MemoryUpdateQueue:
|
def get_memory_queue(app_config: AppConfig) -> MemoryUpdateQueue:
|
||||||
"""Get the global memory update queue singleton.
|
"""Get or create the memory update queue for the given app config."""
|
||||||
|
key = id(app_config)
|
||||||
Returns:
|
|
||||||
The memory update queue instance.
|
|
||||||
"""
|
|
||||||
global _memory_queue
|
|
||||||
with _queue_lock:
|
with _queue_lock:
|
||||||
if _memory_queue is None:
|
queue = _memory_queues.get(key)
|
||||||
_memory_queue = MemoryUpdateQueue()
|
if queue is None:
|
||||||
return _memory_queue
|
queue = MemoryUpdateQueue(app_config)
|
||||||
|
_memory_queues[key] = queue
|
||||||
|
return queue
|
||||||
|
|
||||||
|
|
||||||
def reset_memory_queue() -> None:
|
def reset_memory_queue(app_config: AppConfig | None = None) -> None:
|
||||||
"""Reset the global memory queue.
|
"""Reset memory queue(s).
|
||||||
|
|
||||||
This is useful for testing.
|
Pass an ``app_config`` to reset only its queue, or omit to reset all
|
||||||
|
(useful at test teardown).
|
||||||
"""
|
"""
|
||||||
global _memory_queue
|
|
||||||
with _queue_lock:
|
with _queue_lock:
|
||||||
if _memory_queue is not None:
|
if app_config is not None:
|
||||||
_memory_queue.clear()
|
queue = _memory_queues.pop(id(app_config), None)
|
||||||
_memory_queue = None
|
if queue is not None:
|
||||||
|
queue.clear()
|
||||||
|
return
|
||||||
|
for queue in _memory_queues.values():
|
||||||
|
queue.clear()
|
||||||
|
_memory_queues.clear()
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import abc
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
||||||
from deerflow.config.memory_config import get_memory_config
|
from deerflow.config.memory_config import MemoryConfig
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -61,11 +62,20 @@ class MemoryStorage(abc.ABC):
|
|||||||
class FileMemoryStorage(MemoryStorage):
|
class FileMemoryStorage(MemoryStorage):
|
||||||
"""File-based memory storage provider."""
|
"""File-based memory storage provider."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, memory_config: MemoryConfig):
|
||||||
"""Initialize the file memory storage."""
|
"""Initialize the file memory storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
memory_config: Memory configuration (storage_path etc.). Stored on
|
||||||
|
the instance so per-request lookups don't need to reach for
|
||||||
|
ambient state.
|
||||||
|
"""
|
||||||
|
self._memory_config = memory_config
|
||||||
# Per-user/agent memory cache: keyed by (user_id, agent_name) tuple (None = global)
|
# Per-user/agent memory cache: keyed by (user_id, agent_name) tuple (None = global)
|
||||||
# Value: (memory_data, file_mtime)
|
# Value: (memory_data, file_mtime)
|
||||||
self._memory_cache: dict[tuple[str | None, str | None], tuple[dict[str, Any], float | None]] = {}
|
self._memory_cache: dict[tuple[str | None, 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.
|
||||||
@@ -80,11 +90,11 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
|
|
||||||
def _get_memory_file_path(self, agent_name: str | None = None, *, user_id: str | None = None) -> Path:
|
def _get_memory_file_path(self, agent_name: str | None = None, *, user_id: str | None = None) -> Path:
|
||||||
"""Get the path to the memory file."""
|
"""Get the path to the memory file."""
|
||||||
|
config = self._memory_config
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
if agent_name is not None:
|
if agent_name is not None:
|
||||||
self._validate_agent_name(agent_name)
|
self._validate_agent_name(agent_name)
|
||||||
return get_paths().user_agent_memory_file(user_id, agent_name)
|
return get_paths().user_agent_memory_file(user_id, agent_name)
|
||||||
config = get_memory_config()
|
|
||||||
if config.storage_path and Path(config.storage_path).is_absolute():
|
if config.storage_path and Path(config.storage_path).is_absolute():
|
||||||
return Path(config.storage_path)
|
return Path(config.storage_path)
|
||||||
return get_paths().user_memory_file(user_id)
|
return get_paths().user_memory_file(user_id)
|
||||||
@@ -92,7 +102,6 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
if agent_name is not None:
|
if agent_name is not None:
|
||||||
self._validate_agent_name(agent_name)
|
self._validate_agent_name(agent_name)
|
||||||
return get_paths().agent_memory_file(agent_name)
|
return get_paths().agent_memory_file(agent_name)
|
||||||
config = get_memory_config()
|
|
||||||
if config.storage_path:
|
if config.storage_path:
|
||||||
p = Path(config.storage_path)
|
p = Path(config.storage_path)
|
||||||
return p if p.is_absolute() else get_paths().base_dir / p
|
return p if p.is_absolute() else get_paths().base_dir / p
|
||||||
@@ -123,14 +132,17 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
current_mtime = None
|
current_mtime = None
|
||||||
|
|
||||||
cache_key = (user_id, agent_name)
|
cache_key = (user_id, agent_name)
|
||||||
cached = self._memory_cache.get(cache_key)
|
with self._cache_lock:
|
||||||
|
cached = self._memory_cache.get(cache_key)
|
||||||
|
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, user_id=user_id)
|
||||||
memory_data = self._load_memory_from_file(agent_name, user_id=user_id)
|
|
||||||
|
with self._cache_lock:
|
||||||
self._memory_cache[cache_key] = (memory_data, current_mtime)
|
self._memory_cache[cache_key] = (memory_data, current_mtime)
|
||||||
return memory_data
|
|
||||||
|
|
||||||
return cached[0]
|
return memory_data
|
||||||
|
|
||||||
def reload(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
def reload(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||||
"""Reload memory data from file, forcing cache invalidation."""
|
"""Reload memory data from file, forcing cache invalidation."""
|
||||||
@@ -143,7 +155,8 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
mtime = None
|
mtime = None
|
||||||
|
|
||||||
cache_key = (user_id, agent_name)
|
cache_key = (user_id, agent_name)
|
||||||
self._memory_cache[cache_key] = (memory_data, mtime)
|
with self._cache_lock:
|
||||||
|
self._memory_cache[cache_key] = (memory_data, mtime)
|
||||||
return memory_data
|
return memory_data
|
||||||
|
|
||||||
def save(self, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool:
|
def save(self, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool:
|
||||||
@@ -152,9 +165,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"] = utc_now_iso_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)
|
||||||
|
|
||||||
@@ -166,7 +182,8 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
mtime = None
|
mtime = None
|
||||||
|
|
||||||
cache_key = (user_id, agent_name)
|
cache_key = (user_id, agent_name)
|
||||||
self._memory_cache[cache_key] = (memory_data, mtime)
|
with self._cache_lock:
|
||||||
|
self._memory_cache[cache_key] = (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:
|
||||||
@@ -174,23 +191,31 @@ class FileMemoryStorage(MemoryStorage):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
_storage_instance: MemoryStorage | None = None
|
# Instances keyed by (storage_class_path, id(memory_config)) so tests can
|
||||||
|
# construct isolated storages and multi-client setups with different configs
|
||||||
|
# don't collide on a single process-wide singleton.
|
||||||
|
_storage_instances: dict[tuple[str, int], MemoryStorage] = {}
|
||||||
_storage_lock = threading.Lock()
|
_storage_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_memory_storage() -> MemoryStorage:
|
def get_memory_storage(memory_config: MemoryConfig) -> MemoryStorage:
|
||||||
"""Get the configured memory storage instance."""
|
"""Get the configured memory storage instance.
|
||||||
global _storage_instance
|
|
||||||
if _storage_instance is not None:
|
Caches one instance per ``(storage_class, memory_config)`` pair. In
|
||||||
return _storage_instance
|
single-config deployments this collapses to one instance; in multi-client
|
||||||
|
or test scenarios each config gets its own storage.
|
||||||
|
"""
|
||||||
|
key = (memory_config.storage_class, id(memory_config))
|
||||||
|
existing = _storage_instances.get(key)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
with _storage_lock:
|
with _storage_lock:
|
||||||
if _storage_instance is not None:
|
existing = _storage_instances.get(key)
|
||||||
return _storage_instance
|
if existing is not None:
|
||||||
|
return existing
|
||||||
config = get_memory_config()
|
|
||||||
storage_class_path = config.storage_class
|
|
||||||
|
|
||||||
|
storage_class_path = memory_config.storage_class
|
||||||
try:
|
try:
|
||||||
module_path, class_name = storage_class_path.rsplit(".", 1)
|
module_path, class_name = storage_class_path.rsplit(".", 1)
|
||||||
import importlib
|
import importlib
|
||||||
@@ -204,13 +229,14 @@ def get_memory_storage() -> MemoryStorage:
|
|||||||
if not issubclass(storage_class, MemoryStorage):
|
if not issubclass(storage_class, MemoryStorage):
|
||||||
raise TypeError(f"Configured memory storage '{storage_class_path}' is not a subclass of MemoryStorage")
|
raise TypeError(f"Configured memory storage '{storage_class_path}' is not a subclass of MemoryStorage")
|
||||||
|
|
||||||
_storage_instance = storage_class()
|
instance = storage_class(memory_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to load memory storage %s, falling back to FileMemoryStorage: %s",
|
"Failed to load memory storage %s, falling back to FileMemoryStorage: %s",
|
||||||
storage_class_path,
|
storage_class_path,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
_storage_instance = FileMemoryStorage()
|
instance = FileMemoryStorage(memory_config)
|
||||||
|
|
||||||
return _storage_instance
|
_storage_instances[key] = instance
|
||||||
|
return instance
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""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.app_config import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
def memory_flush_hook(event: SummarizationEvent) -> None:
|
||||||
|
"""Flush messages about to be summarized into the memory queue.
|
||||||
|
|
||||||
|
Reads ``AppConfig`` from disk on every invocation. This hook is fired by
|
||||||
|
``SummarizationMiddleware`` which has no ergonomic way to thread an
|
||||||
|
explicit ``app_config`` through; ``AppConfig.from_file()`` is a pure load
|
||||||
|
so the cost is acceptable for this rare pre-summarization callback.
|
||||||
|
"""
|
||||||
|
app_config = AppConfig.from_file()
|
||||||
|
if not app_config.memory.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(app_config)
|
||||||
|
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,10 +1,15 @@
|
|||||||
"""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 collections.abc import Awaitable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deerflow.agents.memory.prompt import (
|
from deerflow.agents.memory.prompt import (
|
||||||
@@ -16,56 +21,51 @@ from deerflow.agents.memory.storage import (
|
|||||||
get_memory_storage,
|
get_memory_storage,
|
||||||
utc_now_iso_z,
|
utc_now_iso_z,
|
||||||
)
|
)
|
||||||
from deerflow.config.memory_config import get_memory_config
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.memory_config import MemoryConfig
|
||||||
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."""
|
||||||
return create_empty_memory()
|
return create_empty_memory()
|
||||||
|
|
||||||
|
|
||||||
def _save_memory_to_file(memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool:
|
def _save_memory_to_file(memory_config: MemoryConfig, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> bool:
|
||||||
"""Backward-compatible wrapper around the configured memory storage save path."""
|
"""Save via the configured memory storage."""
|
||||||
return get_memory_storage().save(memory_data, agent_name, user_id=user_id)
|
return get_memory_storage(memory_config).save(memory_data, agent_name, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
def get_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
def get_memory_data(memory_config: MemoryConfig, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||||
"""Get the current memory data via storage provider."""
|
"""Get the current memory data via storage provider."""
|
||||||
return get_memory_storage().load(agent_name, user_id=user_id)
|
return get_memory_storage(memory_config).load(agent_name, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
def reload_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
def reload_memory_data(memory_config: MemoryConfig, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||||
"""Reload memory data via storage provider."""
|
"""Reload memory data via storage provider."""
|
||||||
return get_memory_storage().reload(agent_name, user_id=user_id)
|
return get_memory_storage(memory_config).reload(agent_name, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
def import_memory_data(memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
def import_memory_data(memory_config: MemoryConfig, memory_data: dict[str, Any], agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||||
"""Persist imported memory data via storage provider.
|
"""Persist imported memory data via storage provider."""
|
||||||
|
storage = get_memory_storage(memory_config)
|
||||||
Args:
|
|
||||||
memory_data: Full memory payload to persist.
|
|
||||||
agent_name: If provided, imports into per-agent memory.
|
|
||||||
user_id: If provided, scopes memory to a specific user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The saved memory data after storage normalization.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
OSError: If persisting the imported memory fails.
|
|
||||||
"""
|
|
||||||
storage = get_memory_storage()
|
|
||||||
if not storage.save(memory_data, agent_name, user_id=user_id):
|
if not storage.save(memory_data, agent_name, user_id=user_id):
|
||||||
raise OSError("Failed to save imported memory data")
|
raise OSError("Failed to save imported memory data")
|
||||||
return storage.load(agent_name, user_id=user_id)
|
return storage.load(agent_name, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
def clear_memory_data(agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
def clear_memory_data(memory_config: MemoryConfig, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||||
"""Clear all stored memory data and persist an empty structure."""
|
"""Clear all stored memory data and persist an empty structure."""
|
||||||
cleared_memory = create_empty_memory()
|
cleared_memory = create_empty_memory()
|
||||||
if not _save_memory_to_file(cleared_memory, agent_name, user_id=user_id):
|
if not _save_memory_to_file(memory_config, cleared_memory, agent_name, user_id=user_id):
|
||||||
raise OSError("Failed to save cleared memory data")
|
raise OSError("Failed to save cleared memory data")
|
||||||
return cleared_memory
|
return cleared_memory
|
||||||
|
|
||||||
@@ -78,6 +78,7 @@ def _validate_confidence(confidence: float) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def create_memory_fact(
|
def create_memory_fact(
|
||||||
|
memory_config: MemoryConfig,
|
||||||
content: str,
|
content: str,
|
||||||
category: str = "context",
|
category: str = "context",
|
||||||
confidence: float = 0.5,
|
confidence: float = 0.5,
|
||||||
@@ -93,7 +94,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 = utc_now_iso_z()
|
now = utc_now_iso_z()
|
||||||
memory_data = get_memory_data(agent_name, user_id=user_id)
|
memory_data = get_memory_data(memory_config, agent_name, user_id=user_id)
|
||||||
updated_memory = dict(memory_data)
|
updated_memory = dict(memory_data)
|
||||||
facts = list(memory_data.get("facts", []))
|
facts = list(memory_data.get("facts", []))
|
||||||
facts.append(
|
facts.append(
|
||||||
@@ -108,15 +109,15 @@ def create_memory_fact(
|
|||||||
)
|
)
|
||||||
updated_memory["facts"] = facts
|
updated_memory["facts"] = facts
|
||||||
|
|
||||||
if not _save_memory_to_file(updated_memory, agent_name, user_id=user_id):
|
if not _save_memory_to_file(memory_config, updated_memory, agent_name, user_id=user_id):
|
||||||
raise OSError("Failed to save memory data after creating fact")
|
raise OSError("Failed to save memory data after creating fact")
|
||||||
|
|
||||||
return updated_memory
|
return updated_memory
|
||||||
|
|
||||||
|
|
||||||
def delete_memory_fact(fact_id: str, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
def delete_memory_fact(memory_config: MemoryConfig, fact_id: str, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, Any]:
|
||||||
"""Delete a fact by its id and persist the updated memory data."""
|
"""Delete a fact by its id and persist the updated memory data."""
|
||||||
memory_data = get_memory_data(agent_name, user_id=user_id)
|
memory_data = get_memory_data(memory_config, agent_name, user_id=user_id)
|
||||||
facts = memory_data.get("facts", [])
|
facts = memory_data.get("facts", [])
|
||||||
updated_facts = [fact for fact in facts if fact.get("id") != fact_id]
|
updated_facts = [fact for fact in facts if fact.get("id") != fact_id]
|
||||||
if len(updated_facts) == len(facts):
|
if len(updated_facts) == len(facts):
|
||||||
@@ -125,13 +126,14 @@ def delete_memory_fact(fact_id: str, agent_name: str | None = None, *, user_id:
|
|||||||
updated_memory = dict(memory_data)
|
updated_memory = dict(memory_data)
|
||||||
updated_memory["facts"] = updated_facts
|
updated_memory["facts"] = updated_facts
|
||||||
|
|
||||||
if not _save_memory_to_file(updated_memory, agent_name, user_id=user_id):
|
if not _save_memory_to_file(memory_config, updated_memory, agent_name, user_id=user_id):
|
||||||
raise OSError(f"Failed to save memory data after deleting fact '{fact_id}'")
|
raise OSError(f"Failed to save memory data after deleting fact '{fact_id}'")
|
||||||
|
|
||||||
return updated_memory
|
return updated_memory
|
||||||
|
|
||||||
|
|
||||||
def update_memory_fact(
|
def update_memory_fact(
|
||||||
|
memory_config: MemoryConfig,
|
||||||
fact_id: str,
|
fact_id: str,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
@@ -141,7 +143,7 @@ def update_memory_fact(
|
|||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Update an existing fact and persist the updated memory data."""
|
"""Update an existing fact and persist the updated memory data."""
|
||||||
memory_data = get_memory_data(agent_name, user_id=user_id)
|
memory_data = get_memory_data(memory_config, agent_name, user_id=user_id)
|
||||||
updated_memory = dict(memory_data)
|
updated_memory = dict(memory_data)
|
||||||
updated_facts: list[dict[str, Any]] = []
|
updated_facts: list[dict[str, Any]] = []
|
||||||
found = False
|
found = False
|
||||||
@@ -168,7 +170,7 @@ def update_memory_fact(
|
|||||||
|
|
||||||
updated_memory["facts"] = updated_facts
|
updated_memory["facts"] = updated_facts
|
||||||
|
|
||||||
if not _save_memory_to_file(updated_memory, agent_name, user_id=user_id):
|
if not _save_memory_to_file(memory_config, updated_memory, agent_name, user_id=user_id):
|
||||||
raise OSError(f"Failed to save memory data after updating fact '{fact_id}'")
|
raise OSError(f"Failed to save memory data after updating fact '{fact_id}'")
|
||||||
|
|
||||||
return updated_memory
|
return updated_memory
|
||||||
@@ -211,6 +213,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".
|
||||||
@@ -260,19 +295,141 @@ def _fact_content_key(content: Any) -> str | None:
|
|||||||
class MemoryUpdater:
|
class MemoryUpdater:
|
||||||
"""Updates memory using LLM based on conversation context."""
|
"""Updates memory using LLM based on conversation context."""
|
||||||
|
|
||||||
def __init__(self, model_name: str | None = None):
|
def __init__(self, app_config: AppConfig, model_name: str | None = None):
|
||||||
"""Initialize the memory updater.
|
"""Initialize the memory updater.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
app_config: Application config (the updater needs both ``memory``
|
||||||
|
section for behavior and the full config for ``create_chat_model``).
|
||||||
model_name: Optional model name to use. If None, uses config or default.
|
model_name: Optional model name to use. If None, uses config or default.
|
||||||
"""
|
"""
|
||||||
|
self._app_config = app_config
|
||||||
self._model_name = model_name
|
self._model_name = model_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _memory_config(self) -> MemoryConfig:
|
||||||
|
return self._app_config.memory
|
||||||
|
|
||||||
def _get_model(self):
|
def _get_model(self):
|
||||||
"""Get the model for memory updates."""
|
"""Get the model for memory updates."""
|
||||||
config = get_memory_config()
|
model_name = self._model_name or self._memory_config.model_name
|
||||||
model_name = self._model_name or config.model_name
|
return create_chat_model(name=model_name, thinking_enabled=False, app_config=self._app_config)
|
||||||
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,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> tuple[dict[str, Any], str] | None:
|
||||||
|
"""Load memory and build the update prompt for a conversation."""
|
||||||
|
config = self._memory_config
|
||||||
|
if not config.enabled or not messages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_memory = get_memory_data(config, agent_name, user_id=user_id)
|
||||||
|
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,
|
||||||
|
user_id: str | None = 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(self._memory_config).save(updated_memory, agent_name, user_id=user_id)
|
||||||
|
|
||||||
|
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,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> 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,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
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,
|
||||||
@@ -283,7 +440,7 @@ class MemoryUpdater:
|
|||||||
reinforcement_detected: bool = False,
|
reinforcement_detected: bool = False,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
) -> 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.
|
||||||
@@ -296,7 +453,7 @@ class MemoryUpdater:
|
|||||||
Returns:
|
Returns:
|
||||||
True if update was successful, False otherwise.
|
True if update was successful, False otherwise.
|
||||||
"""
|
"""
|
||||||
config = get_memory_config()
|
config = self._memory_config
|
||||||
if not config.enabled:
|
if not config.enabled:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -305,7 +462,7 @@ class MemoryUpdater:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Get current memory
|
# Get current memory
|
||||||
current_memory = get_memory_data(agent_name, user_id=user_id)
|
current_memory = get_memory_data(config, agent_name, user_id=user_id)
|
||||||
|
|
||||||
# Format conversation for prompt
|
# Format conversation for prompt
|
||||||
conversation_text = format_conversation_for_update(messages)
|
conversation_text = format_conversation_for_update(messages)
|
||||||
@@ -360,7 +517,7 @@ class MemoryUpdater:
|
|||||||
updated_memory = _strip_upload_mentions_from_memory(updated_memory)
|
updated_memory = _strip_upload_mentions_from_memory(updated_memory)
|
||||||
|
|
||||||
# Save
|
# Save
|
||||||
return get_memory_storage().save(updated_memory, agent_name, user_id=user_id)
|
return get_memory_storage(config).save(updated_memory, agent_name, user_id=user_id)
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.warning("Failed to parse LLM response for memory update: %s", e)
|
logger.warning("Failed to parse LLM response for memory update: %s", e)
|
||||||
@@ -385,7 +542,7 @@ class MemoryUpdater:
|
|||||||
Returns:
|
Returns:
|
||||||
Updated memory data.
|
Updated memory data.
|
||||||
"""
|
"""
|
||||||
config = get_memory_config()
|
config = self._memory_config
|
||||||
now = utc_now_iso_z()
|
now = utc_now_iso_z()
|
||||||
|
|
||||||
# Update user sections
|
# Update user sections
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import json
|
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
|
||||||
@@ -36,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.
|
||||||
|
|
||||||
@@ -131,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.app_config import AppConfig
|
||||||
|
|
||||||
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 = AppConfig.from_file()
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.config.deer_flow_context import DeerFlowContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Defaults — can be overridden via constructor
|
# Defaults — can be overridden via constructor
|
||||||
@@ -31,6 +34,8 @@ _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]:
|
def _normalize_tool_call_args(raw_args: object) -> tuple[dict, str | None]:
|
||||||
@@ -125,8 +130,14 @@ def _hash_tool_calls(tool_calls: list[dict]) -> str:
|
|||||||
|
|
||||||
_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.
|
||||||
@@ -140,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__(
|
||||||
@@ -148,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[DeerFlowContext]) -> 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 runtime.context.thread_id 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.
|
||||||
@@ -174,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)
|
||||||
"""
|
"""
|
||||||
@@ -213,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",
|
||||||
@@ -239,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
|
||||||
|
|
||||||
@@ -261,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)
|
||||||
|
|
||||||
@@ -268,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:
|
||||||
@@ -283,16 +361,16 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
|
|||||||
# the conversation; injecting one mid-conversation crashes
|
# the conversation; injecting one mid-conversation crashes
|
||||||
# langchain_anthropic's _format_messages(). HumanMessage works
|
# langchain_anthropic's _format_messages(). HumanMessage works
|
||||||
# with all providers. See #1299.
|
# with all providers. See #1299.
|
||||||
return {"messages": [HumanMessage(content=warning, name="loop_warning")]}
|
return {"messages": [HumanMessage(content=warning)]}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
def after_model(self, state: AgentState, runtime: Runtime[DeerFlowContext]) -> dict | None:
|
||||||
return self._apply(state, runtime)
|
return self._apply(state, runtime)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
async def aafter_model(self, state: AgentState, runtime: Runtime[DeerFlowContext]) -> dict | None:
|
||||||
return self._apply(state, runtime)
|
return self._apply(state, runtime)
|
||||||
|
|
||||||
def reset(self, thread_id: str | None = None) -> None:
|
def reset(self, thread_id: str | None = None) -> None:
|
||||||
@@ -301,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,51 +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.deer_flow_context import DeerFlowContext
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_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."""
|
||||||
@@ -53,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.
|
||||||
|
|
||||||
@@ -194,7 +43,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
|||||||
self._agent_name = agent_name
|
self._agent_name = agent_name
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def after_agent(self, state: MemoryMiddlewareState, runtime: Runtime) -> dict | None:
|
def after_agent(self, state: MemoryMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None:
|
||||||
"""Queue conversation for memory update after agent completes.
|
"""Queue conversation for memory update after agent completes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -204,15 +53,11 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
|||||||
Returns:
|
Returns:
|
||||||
None (no state changes needed from this middleware).
|
None (no state changes needed from this middleware).
|
||||||
"""
|
"""
|
||||||
config = get_memory_config()
|
memory_config = runtime.context.app_config.memory
|
||||||
if not config.enabled:
|
if not memory_config.enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get thread ID from runtime context first, then fall back to LangGraph's configurable metadata
|
thread_id = runtime.context.thread_id
|
||||||
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
|
||||||
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 in context, skipping memory update")
|
||||||
return None
|
return None
|
||||||
@@ -224,7 +69,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
|
||||||
@@ -241,7 +86,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
|||||||
# threading.Timer fires on a different thread where ContextVar values are not
|
# threading.Timer fires on a different thread where ContextVar values are not
|
||||||
# propagated, so we must store user_id explicitly in ConversationContext.
|
# propagated, so we must store user_id explicitly in ConversationContext.
|
||||||
user_id = get_effective_user_id()
|
user_id = get_effective_user_id()
|
||||||
queue = get_memory_queue()
|
queue = get_memory_queue(runtime.context.app_config)
|
||||||
queue.add(
|
queue.add(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
messages=filtered_messages,
|
messages=filtered_messages,
|
||||||
|
|||||||
@@ -1,13 +1,347 @@
|
|||||||
from typing import override
|
"""Summarization middleware extensions for DeerFlow."""
|
||||||
|
|
||||||
from langchain.agents.middleware import SummarizationMiddleware as BaseSummarizationMiddleware
|
from __future__ import annotations
|
||||||
from langchain_core.messages.human import HumanMessage
|
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SummarizationMiddleware(BaseSummarizationMiddleware):
|
@dataclass(frozen=True)
|
||||||
@override
|
class SummarizationEvent:
|
||||||
def _build_new_messages(self, summary: str) -> list[HumanMessage]:
|
"""Context emitted before conversation history is summarized away."""
|
||||||
"""Override the base implementation to let the human message with the special name 'summary'.
|
|
||||||
And this message will be ignored to display in the frontend, but still can be used as context for the model.
|
messages_to_summarize: tuple[AnyMessage, ...]
|
||||||
"""
|
preserved_messages: tuple[AnyMessage, ...]
|
||||||
return [HumanMessage(content=f"Here is a summary of the conversation to date:\n\n{summary}", name="summary")]
|
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_thread_id(runtime: Runtime) -> str | None:
|
||||||
|
"""Resolve the current thread ID from runtime context or LangGraph config."""
|
||||||
|
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
||||||
|
if thread_id is None:
|
||||||
|
try:
|
||||||
|
config_data = get_config()
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
thread_id = config_data.get("configurable", {}).get("thread_id")
|
||||||
|
return thread_id
|
||||||
|
|
||||||
|
|
||||||
|
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=_resolve_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)
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import NotRequired, override
|
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 langchain_core.messages import HumanMessage
|
|
||||||
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.deer_flow_context import DeerFlowContext
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
@@ -79,14 +77,10 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
|||||||
return self._get_thread_paths(thread_id, user_id=user_id)
|
return self._get_thread_paths(thread_id, user_id=user_id)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None:
|
def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None:
|
||||||
context = runtime.context or {}
|
thread_id = runtime.context.thread_id
|
||||||
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 not thread_id:
|
||||||
raise ValueError("Thread ID is required in runtime context or config.configurable")
|
raise ValueError("Thread ID is required in runtime context or config.configurable")
|
||||||
|
|
||||||
user_id = get_effective_user_id()
|
user_id = get_effective_user_id()
|
||||||
@@ -99,20 +93,8 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
|||||||
paths = self._create_thread_directories(thread_id, user_id=user_id)
|
paths = self._create_thread_directories(thread_id, user_id=user_id)
|
||||||
logger.debug("Created thread data directories for thread %s", thread_id)
|
logger.debug("Created thread data directories for thread %s", thread_id)
|
||||||
|
|
||||||
messages = list(state.get("messages", []))
|
|
||||||
last_message = messages[-1] if messages else None
|
|
||||||
|
|
||||||
if last_message and isinstance(last_message, HumanMessage):
|
|
||||||
messages[-1] = HumanMessage(
|
|
||||||
content=last_message.content,
|
|
||||||
id=last_message.id,
|
|
||||||
name=last_message.name or "user-input",
|
|
||||||
additional_kwargs={**last_message.additional_kwargs, "run_id": runtime.context.get("run_id"), "timestamp": datetime.now(UTC).isoformat()},
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"thread_data": {
|
"thread_data": {
|
||||||
**paths,
|
**paths,
|
||||||
},
|
}
|
||||||
"messages": messages,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Any, NotRequired, override
|
from typing import Any, NotRequired, override
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
@@ -8,7 +9,9 @@ from langchain.agents.middleware import AgentMiddleware
|
|||||||
from langgraph.config import get_config
|
from langgraph.config import get_config
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
from deerflow.config.title_config import get_title_config
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.deer_flow_context import DeerFlowContext
|
||||||
|
from deerflow.config.title_config import TitleConfig
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -44,10 +47,9 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _should_generate_title(self, state: TitleMiddlewareState) -> bool:
|
def _should_generate_title(self, state: TitleMiddlewareState, title_config: TitleConfig) -> bool:
|
||||||
"""Check if we should generate a title for this thread."""
|
"""Check if we should generate a title for this thread."""
|
||||||
config = get_title_config()
|
if not title_config.enabled:
|
||||||
if not config.enabled:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if thread already has a title in state
|
# Check if thread already has a title in state
|
||||||
@@ -66,37 +68,39 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
# Generate title after first complete exchange
|
# Generate title after first complete exchange
|
||||||
return len(user_messages) == 1 and len(assistant_messages) >= 1
|
return len(user_messages) == 1 and len(assistant_messages) >= 1
|
||||||
|
|
||||||
def _build_title_prompt(self, state: TitleMiddlewareState) -> tuple[str, str]:
|
def _build_title_prompt(self, state: TitleMiddlewareState, title_config: TitleConfig) -> tuple[str, str]:
|
||||||
"""Extract user/assistant messages and build the title prompt.
|
"""Extract user/assistant messages and build the title prompt.
|
||||||
|
|
||||||
Returns (prompt_string, user_msg) so callers can use user_msg as fallback.
|
Returns (prompt_string, user_msg) so callers can use user_msg as fallback.
|
||||||
"""
|
"""
|
||||||
config = get_title_config()
|
|
||||||
messages = state.get("messages", [])
|
messages = state.get("messages", [])
|
||||||
|
|
||||||
user_msg_content = next((m.content for m in messages if m.type == "human"), "")
|
user_msg_content = next((m.content for m in messages if m.type == "human"), "")
|
||||||
assistant_msg_content = next((m.content for m in messages if m.type == "ai"), "")
|
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 = title_config.prompt_template.format(
|
||||||
max_words=config.max_words,
|
max_words=title_config.max_words,
|
||||||
user_msg=user_msg[:500],
|
user_msg=user_msg[:500],
|
||||||
assistant_msg=assistant_msg[:500],
|
assistant_msg=assistant_msg[:500],
|
||||||
)
|
)
|
||||||
return prompt, user_msg
|
return prompt, user_msg
|
||||||
|
|
||||||
def _parse_title(self, content: object) -> str:
|
def _strip_think_tags(self, text: str) -> str:
|
||||||
"""Normalize model output into a clean title string."""
|
"""Remove <think>...</think> blocks emitted by reasoning models (e.g. minimax, DeepSeek-R1)."""
|
||||||
config = get_title_config()
|
return re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE).strip()
|
||||||
title_content = self._normalize_content(content)
|
|
||||||
title = title_content.strip().strip('"').strip("'")
|
|
||||||
return title[: config.max_chars] if len(title) > config.max_chars else title
|
|
||||||
|
|
||||||
def _fallback_title(self, user_msg: str) -> str:
|
def _parse_title(self, content: object, title_config: TitleConfig) -> str:
|
||||||
config = get_title_config()
|
"""Normalize model output into a clean title string."""
|
||||||
fallback_chars = min(config.max_chars, 50)
|
title_content = self._normalize_content(content)
|
||||||
|
title_content = self._strip_think_tags(title_content)
|
||||||
|
title = title_content.strip().strip('"').strip("'")
|
||||||
|
return title[: title_config.max_chars] if len(title) > title_config.max_chars else title
|
||||||
|
|
||||||
|
def _fallback_title(self, user_msg: str, title_config: TitleConfig) -> str:
|
||||||
|
fallback_chars = min(title_config.max_chars, 50)
|
||||||
if len(user_msg) > fallback_chars:
|
if len(user_msg) > fallback_chars:
|
||||||
return user_msg[:fallback_chars].rstrip() + "..."
|
return user_msg[:fallback_chars].rstrip() + "..."
|
||||||
return user_msg if user_msg else "New Conversation"
|
return user_msg if user_msg else "New Conversation"
|
||||||
@@ -115,39 +119,39 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
config["tags"] = [*(config.get("tags") or []), "middleware:title"]
|
config["tags"] = [*(config.get("tags") or []), "middleware:title"]
|
||||||
return config
|
return config
|
||||||
|
|
||||||
def _generate_title_result(self, state: TitleMiddlewareState) -> dict | None:
|
def _generate_title_result(self, state: TitleMiddlewareState, title_config: TitleConfig) -> dict | None:
|
||||||
"""Generate a local fallback title without blocking on an LLM call."""
|
"""Generate a local fallback title without blocking on an LLM call."""
|
||||||
if not self._should_generate_title(state):
|
if not self._should_generate_title(state, title_config):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_, user_msg = self._build_title_prompt(state)
|
_, user_msg = self._build_title_prompt(state, title_config)
|
||||||
return {"title": self._fallback_title(user_msg)}
|
return {"title": self._fallback_title(user_msg, title_config)}
|
||||||
|
|
||||||
async def _agenerate_title_result(self, state: TitleMiddlewareState) -> dict | None:
|
async def _agenerate_title_result(self, state: TitleMiddlewareState, app_config: AppConfig) -> dict | None:
|
||||||
"""Generate a title asynchronously and fall back locally on failure."""
|
"""Generate a title asynchronously and fall back locally on failure."""
|
||||||
if not self._should_generate_title(state):
|
title_config = app_config.title
|
||||||
|
if not self._should_generate_title(state, title_config):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
config = get_title_config()
|
prompt, user_msg = self._build_title_prompt(state, title_config)
|
||||||
prompt, user_msg = self._build_title_prompt(state)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if config.model_name:
|
if title_config.model_name:
|
||||||
model = create_chat_model(name=config.model_name, thinking_enabled=False)
|
model = create_chat_model(name=title_config.model_name, thinking_enabled=False, app_config=app_config)
|
||||||
else:
|
else:
|
||||||
model = create_chat_model(thinking_enabled=False)
|
model = create_chat_model(thinking_enabled=False, app_config=app_config)
|
||||||
response = await model.ainvoke(prompt, config=self._get_runnable_config())
|
response = await model.ainvoke(prompt, config=self._get_runnable_config())
|
||||||
title = self._parse_title(response.content)
|
title = self._parse_title(response.content, title_config)
|
||||||
if title:
|
if title:
|
||||||
return {"title": title}
|
return {"title": title}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to generate async title; falling back to local title", exc_info=True)
|
logger.debug("Failed to generate async title; falling back to local title", exc_info=True)
|
||||||
return {"title": self._fallback_title(user_msg)}
|
return {"title": self._fallback_title(user_msg, title_config)}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
|
def after_model(self, state: TitleMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None:
|
||||||
return self._generate_title_result(state)
|
return self._generate_title_result(state, runtime.context.app_config.title)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def aafter_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
|
async def aafter_model(self, state: TitleMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None:
|
||||||
return await self._agenerate_title_result(state)
|
return await self._agenerate_title_result(state, runtime.context.app_config)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+10
-5
@@ -1,8 +1,10 @@
|
|||||||
"""Tool error handling middleware and shared runtime middleware builders."""
|
"""Tool error handling middleware and shared runtime middleware builders."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import override
|
from typing import TYPE_CHECKING, override
|
||||||
|
|
||||||
from langchain.agents import AgentState
|
from langchain.agents import AgentState
|
||||||
from langchain.agents.middleware import AgentMiddleware
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
@@ -11,6 +13,9 @@ from langgraph.errors import GraphBubbleUp
|
|||||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||||
from langgraph.types import Command
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_MISSING_TOOL_CALL_ID = "missing_tool_call_id"
|
_MISSING_TOOL_CALL_ID = "missing_tool_call_id"
|
||||||
@@ -67,6 +72,7 @@ class ToolErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
|||||||
|
|
||||||
def _build_runtime_middlewares(
|
def _build_runtime_middlewares(
|
||||||
*,
|
*,
|
||||||
|
app_config: "AppConfig",
|
||||||
include_uploads: bool,
|
include_uploads: bool,
|
||||||
include_dangling_tool_call_patch: bool,
|
include_dangling_tool_call_patch: bool,
|
||||||
lazy_init: bool = True,
|
lazy_init: bool = True,
|
||||||
@@ -94,9 +100,7 @@ def _build_runtime_middlewares(
|
|||||||
middlewares.append(LLMErrorHandlingMiddleware())
|
middlewares.append(LLMErrorHandlingMiddleware())
|
||||||
|
|
||||||
# Guardrail middleware (if configured)
|
# Guardrail middleware (if configured)
|
||||||
from deerflow.config.guardrails_config import get_guardrails_config
|
guardrails_config = app_config.guardrails
|
||||||
|
|
||||||
guardrails_config = get_guardrails_config()
|
|
||||||
if guardrails_config.enabled and guardrails_config.provider:
|
if guardrails_config.enabled and guardrails_config.provider:
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
@@ -125,9 +129,10 @@ def _build_runtime_middlewares(
|
|||||||
return middlewares
|
return middlewares
|
||||||
|
|
||||||
|
|
||||||
def build_lead_runtime_middlewares(*, lazy_init: bool = True) -> list[AgentMiddleware]:
|
def build_lead_runtime_middlewares(*, app_config: "AppConfig", lazy_init: bool = True) -> list[AgentMiddleware]:
|
||||||
"""Middlewares shared by lead agent runtime before lead-only middlewares."""
|
"""Middlewares shared by lead agent runtime before lead-only middlewares."""
|
||||||
return _build_runtime_middlewares(
|
return _build_runtime_middlewares(
|
||||||
|
app_config=app_config,
|
||||||
include_uploads=True,
|
include_uploads=True,
|
||||||
include_dangling_tool_call_patch=True,
|
include_dangling_tool_call_patch=True,
|
||||||
lazy_init=lazy_init,
|
lazy_init=lazy_init,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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.config.deer_flow_context import DeerFlowContext
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
from deerflow.utils.file_conversion import extract_outline
|
from deerflow.utils.file_conversion import extract_outline
|
||||||
@@ -185,7 +186,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
|||||||
return files if files else None
|
return files if files else None
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def before_agent(self, state: UploadsMiddlewareState, runtime: Runtime) -> dict | None:
|
def before_agent(self, state: UploadsMiddlewareState, runtime: Runtime[DeerFlowContext]) -> dict | None:
|
||||||
"""Inject uploaded files information before agent execution.
|
"""Inject uploaded files information before agent execution.
|
||||||
|
|
||||||
New files come from the current message's additional_kwargs.files.
|
New files come from the current message's additional_kwargs.files.
|
||||||
@@ -214,14 +215,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 = runtime.context.thread_id
|
||||||
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, user_id=get_effective_user_id()) if thread_id else None
|
uploads_dir = self._paths.sandbox_uploads_dir(thread_id, user_id=get_effective_user_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
|
||||||
@@ -263,23 +257,26 @@ 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,
|
||||||
name=last_message.name,
|
|
||||||
additional_kwargs=last_message.additional_kwargs,
|
additional_kwargs=last_message.additional_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ from deerflow.agents.lead_agent.agent import _build_middlewares
|
|||||||
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
||||||
from deerflow.config.app_config import get_app_config, reload_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
from deerflow.config.deer_flow_context import DeerFlowContext
|
||||||
|
from deerflow.config.extensions_config import ExtensionsConfig
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
@@ -116,6 +117,7 @@ class DeerFlowClient:
|
|||||||
config_path: str | None = None,
|
config_path: str | None = None,
|
||||||
checkpointer=None,
|
checkpointer=None,
|
||||||
*,
|
*,
|
||||||
|
config: AppConfig | None = None,
|
||||||
model_name: str | None = None,
|
model_name: str | None = None,
|
||||||
thinking_enabled: bool = True,
|
thinking_enabled: bool = True,
|
||||||
subagent_enabled: bool = False,
|
subagent_enabled: bool = False,
|
||||||
@@ -130,9 +132,14 @@ class DeerFlowClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_path: Path to config.yaml. Uses default resolution if None.
|
config_path: Path to config.yaml. Uses default resolution if None.
|
||||||
|
Ignored when ``config`` is provided.
|
||||||
checkpointer: LangGraph checkpointer instance for state persistence.
|
checkpointer: LangGraph checkpointer instance for state persistence.
|
||||||
Required for multi-turn conversations on the same thread_id.
|
Required for multi-turn conversations on the same thread_id.
|
||||||
Without a checkpointer, each call is stateless.
|
Without a checkpointer, each call is stateless.
|
||||||
|
config: Optional pre-constructed AppConfig. When provided, it takes
|
||||||
|
precedence over ``config_path`` and no file is read. Enables
|
||||||
|
multi-client isolation: two clients with different configs can
|
||||||
|
coexist in the same process without touching process-global state.
|
||||||
model_name: Override the default model name from config.
|
model_name: Override the default model name from config.
|
||||||
thinking_enabled: Enable model's extended thinking.
|
thinking_enabled: Enable model's extended thinking.
|
||||||
subagent_enabled: Enable subagent delegation.
|
subagent_enabled: Enable subagent delegation.
|
||||||
@@ -141,9 +148,18 @@ class DeerFlowClient:
|
|||||||
available_skills: Optional set of skill names to make available. If None (default), all scanned skills are available.
|
available_skills: Optional set of skill names to make available. If None (default), all scanned skills are available.
|
||||||
middlewares: Optional list of custom middlewares to inject into the agent.
|
middlewares: Optional list of custom middlewares to inject into the agent.
|
||||||
"""
|
"""
|
||||||
if config_path is not None:
|
# Constructor-captured config: the client owns its AppConfig for its lifetime.
|
||||||
reload_app_config(config_path)
|
# Multiple clients with different configs do not contend.
|
||||||
self._app_config = get_app_config()
|
#
|
||||||
|
# Priority: explicit ``config=`` > explicit ``config_path=`` > ``AppConfig.from_file()``
|
||||||
|
# with default path resolution. There is no ambient global fallback; if
|
||||||
|
# config.yaml cannot be located, ``from_file`` raises loudly.
|
||||||
|
if config is not None:
|
||||||
|
self._app_config = config
|
||||||
|
elif config_path is not None:
|
||||||
|
self._app_config = AppConfig.from_file(config_path)
|
||||||
|
else:
|
||||||
|
self._app_config = AppConfig.from_file()
|
||||||
|
|
||||||
if agent_name is not None and not AGENT_NAME_PATTERN.match(agent_name):
|
if agent_name is not None and not AGENT_NAME_PATTERN.match(agent_name):
|
||||||
raise ValueError(f"Invalid agent name '{agent_name}'. Must match pattern: {AGENT_NAME_PATTERN.pattern}")
|
raise ValueError(f"Invalid agent name '{agent_name}'. Must match pattern: {AGENT_NAME_PATTERN.pattern}")
|
||||||
@@ -171,6 +187,15 @@ class DeerFlowClient:
|
|||||||
self._agent = None
|
self._agent = None
|
||||||
self._agent_config_key = None
|
self._agent_config_key = None
|
||||||
|
|
||||||
|
def _reload_config(self) -> None:
|
||||||
|
"""Reload config from file and refresh the cached reference.
|
||||||
|
|
||||||
|
Only the client's own ``_app_config`` is rebuilt. Other clients
|
||||||
|
and the process-global are untouched, so multi-client coexistence
|
||||||
|
survives reload.
|
||||||
|
"""
|
||||||
|
self._app_config = AppConfig.from_file()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -228,10 +253,11 @@ class DeerFlowClient:
|
|||||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||||
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": create_chat_model(name=model_name, thinking_enabled=thinking_enabled),
|
"model": create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=self._app_config),
|
||||||
"tools": self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled),
|
"tools": self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled),
|
||||||
"middleware": _build_middlewares(config, model_name=model_name, agent_name=self._agent_name, custom_middlewares=self._middlewares),
|
"middleware": _build_middlewares(self._app_config, config, model_name=model_name, agent_name=self._agent_name, custom_middlewares=self._middlewares),
|
||||||
"system_prompt": apply_prompt_template(
|
"system_prompt": apply_prompt_template(
|
||||||
|
self._app_config,
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
max_concurrent_subagents=max_concurrent_subagents,
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
agent_name=self._agent_name,
|
agent_name=self._agent_name,
|
||||||
@@ -243,7 +269,7 @@ class DeerFlowClient:
|
|||||||
if checkpointer is None:
|
if checkpointer is None:
|
||||||
from deerflow.runtime.checkpointer import get_checkpointer
|
from deerflow.runtime.checkpointer import get_checkpointer
|
||||||
|
|
||||||
checkpointer = get_checkpointer()
|
checkpointer = get_checkpointer(self._app_config)
|
||||||
if checkpointer is not None:
|
if checkpointer is not None:
|
||||||
kwargs["checkpointer"] = checkpointer
|
kwargs["checkpointer"] = checkpointer
|
||||||
|
|
||||||
@@ -251,12 +277,11 @@ class DeerFlowClient:
|
|||||||
self._agent_config_key = key
|
self._agent_config_key = key
|
||||||
logger.info("Agent created: agent_name=%s, model=%s, thinking=%s", self._agent_name, model_name, thinking_enabled)
|
logger.info("Agent created: agent_name=%s, model=%s, thinking=%s", self._agent_name, model_name, thinking_enabled)
|
||||||
|
|
||||||
@staticmethod
|
def _get_tools(self, *, model_name: str | None, subagent_enabled: bool):
|
||||||
def _get_tools(*, model_name: str | None, subagent_enabled: bool):
|
|
||||||
"""Lazy import to avoid circular dependency at module level."""
|
"""Lazy import to avoid circular dependency at module level."""
|
||||||
from deerflow.tools import get_available_tools
|
from deerflow.tools import get_available_tools
|
||||||
|
|
||||||
return get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled)
|
return get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=self._app_config)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_tool_calls(tool_calls) -> list[dict]:
|
def _serialize_tool_calls(tool_calls) -> list[dict]:
|
||||||
@@ -377,7 +402,7 @@ class DeerFlowClient:
|
|||||||
if checkpointer is None:
|
if checkpointer is None:
|
||||||
from deerflow.runtime.checkpointer.provider import get_checkpointer
|
from deerflow.runtime.checkpointer.provider import get_checkpointer
|
||||||
|
|
||||||
checkpointer = get_checkpointer()
|
checkpointer = get_checkpointer(self._app_config)
|
||||||
|
|
||||||
thread_info_map = {}
|
thread_info_map = {}
|
||||||
|
|
||||||
@@ -432,7 +457,7 @@ class DeerFlowClient:
|
|||||||
if checkpointer is None:
|
if checkpointer is None:
|
||||||
from deerflow.runtime.checkpointer.provider import get_checkpointer
|
from deerflow.runtime.checkpointer.provider import get_checkpointer
|
||||||
|
|
||||||
checkpointer = get_checkpointer()
|
checkpointer = get_checkpointer(self._app_config)
|
||||||
|
|
||||||
config = {"configurable": {"thread_id": thread_id}}
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
checkpoints = []
|
checkpoints = []
|
||||||
@@ -552,9 +577,7 @@ class DeerFlowClient:
|
|||||||
self._ensure_agent(config)
|
self._ensure_agent(config)
|
||||||
|
|
||||||
state: dict[str, Any] = {"messages": [HumanMessage(content=message)]}
|
state: dict[str, Any] = {"messages": [HumanMessage(content=message)]}
|
||||||
context = {"thread_id": thread_id}
|
context = DeerFlowContext(app_config=self._app_config, thread_id=thread_id, agent_name=self._agent_name)
|
||||||
if 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``
|
# Cross-mode handoff: ids already streamed via LangGraph ``messages``
|
||||||
@@ -723,6 +746,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": [
|
||||||
{
|
{
|
||||||
@@ -734,7 +761,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:
|
||||||
@@ -758,7 +786,7 @@ class DeerFlowClient:
|
|||||||
"category": s.category,
|
"category": s.category,
|
||||||
"enabled": s.enabled,
|
"enabled": s.enabled,
|
||||||
}
|
}
|
||||||
for s in load_skills(enabled_only=enabled_only)
|
for s in load_skills(self._app_config, enabled_only=enabled_only)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,19 +798,19 @@ class DeerFlowClient:
|
|||||||
"""
|
"""
|
||||||
from deerflow.agents.memory.updater import get_memory_data
|
from deerflow.agents.memory.updater import get_memory_data
|
||||||
|
|
||||||
return get_memory_data(user_id=get_effective_user_id())
|
return get_memory_data(self._app_config.memory, user_id=get_effective_user_id())
|
||||||
|
|
||||||
def export_memory(self) -> dict:
|
def export_memory(self) -> dict:
|
||||||
"""Export current memory data for backup or transfer."""
|
"""Export current memory data for backup or transfer."""
|
||||||
from deerflow.agents.memory.updater import get_memory_data
|
from deerflow.agents.memory.updater import get_memory_data
|
||||||
|
|
||||||
return get_memory_data(user_id=get_effective_user_id())
|
return get_memory_data(self._app_config.memory, user_id=get_effective_user_id())
|
||||||
|
|
||||||
def import_memory(self, memory_data: dict) -> dict:
|
def import_memory(self, memory_data: dict) -> dict:
|
||||||
"""Import and persist full memory data."""
|
"""Import and persist full memory data."""
|
||||||
from deerflow.agents.memory.updater import import_memory_data
|
from deerflow.agents.memory.updater import import_memory_data
|
||||||
|
|
||||||
return import_memory_data(memory_data, user_id=get_effective_user_id())
|
return import_memory_data(self._app_config.memory, memory_data, user_id=get_effective_user_id())
|
||||||
|
|
||||||
def get_model(self, name: str) -> dict | None:
|
def get_model(self, name: str) -> dict | None:
|
||||||
"""Get a specific model's configuration by name.
|
"""Get a specific model's configuration by name.
|
||||||
@@ -817,8 +845,8 @@ class DeerFlowClient:
|
|||||||
Dict with "mcp_servers" key mapping server name to config,
|
Dict with "mcp_servers" key mapping server name to config,
|
||||||
matching the Gateway API ``McpConfigResponse`` schema.
|
matching the Gateway API ``McpConfigResponse`` schema.
|
||||||
"""
|
"""
|
||||||
config = get_extensions_config()
|
ext = self._app_config.extensions
|
||||||
return {"mcp_servers": {name: server.model_dump() for name, server in config.mcp_servers.items()}}
|
return {"mcp_servers": {name: server.model_dump() for name, server in ext.mcp_servers.items()}}
|
||||||
|
|
||||||
def update_mcp_config(self, mcp_servers: dict[str, dict]) -> dict:
|
def update_mcp_config(self, mcp_servers: dict[str, dict]) -> dict:
|
||||||
"""Update MCP server configurations.
|
"""Update MCP server configurations.
|
||||||
@@ -840,18 +868,19 @@ class DeerFlowClient:
|
|||||||
if config_path is None:
|
if config_path is None:
|
||||||
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.")
|
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.")
|
||||||
|
|
||||||
current_config = get_extensions_config()
|
current_ext = self._app_config.extensions
|
||||||
|
|
||||||
config_data = {
|
config_data = {
|
||||||
"mcpServers": mcp_servers,
|
"mcpServers": mcp_servers,
|
||||||
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
|
"skills": {name: {"enabled": skill.enabled} for name, skill in current_ext.skills.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
self._atomic_write_json(config_path, config_data)
|
self._atomic_write_json(config_path, config_data)
|
||||||
|
|
||||||
self._agent = None
|
self._agent = None
|
||||||
self._agent_config_key = None
|
self._agent_config_key = None
|
||||||
reloaded = reload_extensions_config()
|
self._reload_config()
|
||||||
|
reloaded = self._app_config.extensions
|
||||||
return {"mcp_servers": {name: server.model_dump() for name, server in reloaded.mcp_servers.items()}}
|
return {"mcp_servers": {name: server.model_dump() for name, server in reloaded.mcp_servers.items()}}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -869,7 +898,7 @@ class DeerFlowClient:
|
|||||||
"""
|
"""
|
||||||
from deerflow.skills.loader import load_skills
|
from deerflow.skills.loader import load_skills
|
||||||
|
|
||||||
skill = next((s for s in load_skills(enabled_only=False) if s.name == name), None)
|
skill = next((s for s in load_skills(self._app_config, enabled_only=False) if s.name == name), None)
|
||||||
if skill is None:
|
if skill is None:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -896,7 +925,7 @@ class DeerFlowClient:
|
|||||||
"""
|
"""
|
||||||
from deerflow.skills.loader import load_skills
|
from deerflow.skills.loader import load_skills
|
||||||
|
|
||||||
skills = load_skills(enabled_only=False)
|
skills = load_skills(self._app_config, enabled_only=False)
|
||||||
skill = next((s for s in skills if s.name == name), None)
|
skill = next((s for s in skills if s.name == name), None)
|
||||||
if skill is None:
|
if skill is None:
|
||||||
raise ValueError(f"Skill '{name}' not found")
|
raise ValueError(f"Skill '{name}' not found")
|
||||||
@@ -905,21 +934,25 @@ class DeerFlowClient:
|
|||||||
if config_path is None:
|
if config_path is None:
|
||||||
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.")
|
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.")
|
||||||
|
|
||||||
extensions_config = get_extensions_config()
|
# Do not mutate self._app_config (frozen value). Compose the new
|
||||||
extensions_config.skills[name] = SkillStateConfig(enabled=enabled)
|
# skills state in a fresh dict, write it to disk, and let _reload_config()
|
||||||
|
# below rebuild AppConfig from the updated file.
|
||||||
|
ext = self._app_config.extensions
|
||||||
|
new_skills = {n: {"enabled": sc.enabled} for n, sc in ext.skills.items()}
|
||||||
|
new_skills[name] = {"enabled": enabled}
|
||||||
|
|
||||||
config_data = {
|
config_data = {
|
||||||
"mcpServers": {n: s.model_dump() for n, s in extensions_config.mcp_servers.items()},
|
"mcpServers": {n: s.model_dump() for n, s in ext.mcp_servers.items()},
|
||||||
"skills": {n: {"enabled": sc.enabled} for n, sc in extensions_config.skills.items()},
|
"skills": new_skills,
|
||||||
}
|
}
|
||||||
|
|
||||||
self._atomic_write_json(config_path, config_data)
|
self._atomic_write_json(config_path, config_data)
|
||||||
|
|
||||||
self._agent = None
|
self._agent = None
|
||||||
self._agent_config_key = None
|
self._agent_config_key = None
|
||||||
reload_extensions_config()
|
self._reload_config()
|
||||||
|
|
||||||
updated = next((s for s in load_skills(enabled_only=False) if s.name == name), None)
|
updated = next((s for s in load_skills(self._app_config, enabled_only=False) if s.name == name), None)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
raise RuntimeError(f"Skill '{name}' disappeared after update")
|
raise RuntimeError(f"Skill '{name}' disappeared after update")
|
||||||
return {
|
return {
|
||||||
@@ -957,25 +990,25 @@ class DeerFlowClient:
|
|||||||
"""
|
"""
|
||||||
from deerflow.agents.memory.updater import reload_memory_data
|
from deerflow.agents.memory.updater import reload_memory_data
|
||||||
|
|
||||||
return reload_memory_data(user_id=get_effective_user_id())
|
return reload_memory_data(self._app_config.memory, user_id=get_effective_user_id())
|
||||||
|
|
||||||
def clear_memory(self) -> dict:
|
def clear_memory(self) -> dict:
|
||||||
"""Clear all persisted memory data."""
|
"""Clear all persisted memory data."""
|
||||||
from deerflow.agents.memory.updater import clear_memory_data
|
from deerflow.agents.memory.updater import clear_memory_data
|
||||||
|
|
||||||
return clear_memory_data(user_id=get_effective_user_id())
|
return clear_memory_data(self._app_config.memory, user_id=get_effective_user_id())
|
||||||
|
|
||||||
def create_memory_fact(self, content: str, category: str = "context", confidence: float = 0.5) -> dict:
|
def create_memory_fact(self, content: str, category: str = "context", confidence: float = 0.5) -> dict:
|
||||||
"""Create a single fact manually."""
|
"""Create a single fact manually."""
|
||||||
from deerflow.agents.memory.updater import create_memory_fact
|
from deerflow.agents.memory.updater import create_memory_fact
|
||||||
|
|
||||||
return create_memory_fact(content=content, category=category, confidence=confidence)
|
return create_memory_fact(self._app_config.memory, content=content, category=category, confidence=confidence)
|
||||||
|
|
||||||
def delete_memory_fact(self, fact_id: str) -> dict:
|
def delete_memory_fact(self, fact_id: str) -> dict:
|
||||||
"""Delete a single fact from memory by fact id."""
|
"""Delete a single fact from memory by fact id."""
|
||||||
from deerflow.agents.memory.updater import delete_memory_fact
|
from deerflow.agents.memory.updater import delete_memory_fact
|
||||||
|
|
||||||
return delete_memory_fact(fact_id)
|
return delete_memory_fact(self._app_config.memory, fact_id)
|
||||||
|
|
||||||
def update_memory_fact(
|
def update_memory_fact(
|
||||||
self,
|
self,
|
||||||
@@ -988,6 +1021,7 @@ class DeerFlowClient:
|
|||||||
from deerflow.agents.memory.updater import update_memory_fact
|
from deerflow.agents.memory.updater import update_memory_fact
|
||||||
|
|
||||||
return update_memory_fact(
|
return update_memory_fact(
|
||||||
|
self._app_config.memory,
|
||||||
fact_id=fact_id,
|
fact_id=fact_id,
|
||||||
content=content,
|
content=content,
|
||||||
category=category,
|
category=category,
|
||||||
@@ -1000,9 +1034,7 @@ class DeerFlowClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Memory config dict.
|
Memory config dict.
|
||||||
"""
|
"""
|
||||||
from deerflow.config.memory_config import get_memory_config
|
config = self._app_config.memory
|
||||||
|
|
||||||
config = get_memory_config()
|
|
||||||
return {
|
return {
|
||||||
"enabled": config.enabled,
|
"enabled": config.enabled,
|
||||||
"storage_path": config.storage_path,
|
"storage_path": config.storage_path,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ except ImportError: # pragma: no cover - Windows fallback
|
|||||||
fcntl = None # type: ignore[assignment]
|
fcntl = None # type: ignore[assignment]
|
||||||
import msvcrt
|
import msvcrt
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
from deerflow.sandbox.sandbox import Sandbox
|
from deerflow.sandbox.sandbox import Sandbox
|
||||||
@@ -90,7 +90,8 @@ class AioSandboxProvider(SandboxProvider):
|
|||||||
API_KEY: $MY_API_KEY
|
API_KEY: $MY_API_KEY
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, app_config: "AppConfig"):
|
||||||
|
self._app_config = app_config
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._sandboxes: dict[str, AioSandbox] = {} # sandbox_id -> AioSandbox instance
|
self._sandboxes: dict[str, AioSandbox] = {} # sandbox_id -> AioSandbox instance
|
||||||
self._sandbox_infos: dict[str, SandboxInfo] = {} # sandbox_id -> SandboxInfo (for destroy)
|
self._sandbox_infos: dict[str, SandboxInfo] = {} # sandbox_id -> SandboxInfo (for destroy)
|
||||||
@@ -120,6 +121,16 @@ class AioSandboxProvider(SandboxProvider):
|
|||||||
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:
|
||||||
@@ -149,8 +160,7 @@ class AioSandboxProvider(SandboxProvider):
|
|||||||
|
|
||||||
def _load_config(self) -> dict:
|
def _load_config(self) -> dict:
|
||||||
"""Load sandbox configuration from app config."""
|
"""Load sandbox configuration from app config."""
|
||||||
config = get_app_config()
|
sandbox_config = self._app_config.sandbox
|
||||||
sandbox_config = config.sandbox
|
|
||||||
|
|
||||||
idle_timeout = getattr(sandbox_config, "idle_timeout", None)
|
idle_timeout = getattr(sandbox_config, "idle_timeout", None)
|
||||||
replicas = getattr(sandbox_config, "replicas", None)
|
replicas = getattr(sandbox_config, "replicas", None)
|
||||||
@@ -273,17 +283,15 @@ class AioSandboxProvider(SandboxProvider):
|
|||||||
(paths.host_acp_workspace_dir(thread_id, user_id=user_id), "/mnt/acp-workspace", True),
|
(paths.host_acp_workspace_dir(thread_id, user_id=user_id), "/mnt/acp-workspace", True),
|
||||||
]
|
]
|
||||||
|
|
||||||
@staticmethod
|
def _get_skills_mount(self) -> tuple[str, str, bool] | None:
|
||||||
def _get_skills_mount() -> tuple[str, str, bool] | None:
|
|
||||||
"""Get the skills directory mount configuration.
|
"""Get the skills directory mount configuration.
|
||||||
|
|
||||||
Mount source uses DEER_FLOW_HOST_SKILLS_PATH when running inside Docker (DooD)
|
Mount source uses DEER_FLOW_HOST_SKILLS_PATH when running inside Docker (DooD)
|
||||||
so the host Docker daemon can resolve the path.
|
so the host Docker daemon can resolve the path.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
config = get_app_config()
|
skills_path = self._app_config.skills.get_skills_path()
|
||||||
skills_path = config.skills.get_skills_path()
|
container_path = self._app_config.skills.container_path
|
||||||
container_path = config.skills.container_path
|
|
||||||
|
|
||||||
if skills_path.exists():
|
if skills_path.exists():
|
||||||
# When running inside Docker with DooD, use host-side skills path.
|
# When running inside Docker with DooD, use host-side skills path.
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ Web Search Tool - Search the web using DuckDuckGo (no API key required).
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from langchain.tools import tool
|
from langchain.tools import ToolRuntime, tool
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config.deer_flow_context import resolve_context
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ def _search_text(
|
|||||||
@tool("web_search", parse_docstring=True)
|
@tool("web_search", parse_docstring=True)
|
||||||
def web_search_tool(
|
def web_search_tool(
|
||||||
query: str,
|
query: str,
|
||||||
|
runtime: ToolRuntime,
|
||||||
max_results: int = 5,
|
max_results: int = 5,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Search the web for information. Use this tool to find current information, news, articles, and facts from the internet.
|
"""Search the web for information. Use this tool to find current information, news, articles, and facts from the internet.
|
||||||
@@ -63,11 +64,11 @@ def web_search_tool(
|
|||||||
query: Search keywords describing what you want to find. Be specific for better results.
|
query: Search keywords describing what you want to find. Be specific for better results.
|
||||||
max_results: Maximum number of results to return. Default is 5.
|
max_results: Maximum number of results to return. Default is 5.
|
||||||
"""
|
"""
|
||||||
config = get_app_config().get_tool_config("web_search")
|
tool_config = resolve_context(runtime).app_config.get_tool_config("web_search")
|
||||||
|
|
||||||
# Override max_results from config if set
|
# Override max_results from config if set
|
||||||
if config is not None and "max_results" in config.model_extra:
|
if tool_config is not None and "max_results" in tool_config.model_extra:
|
||||||
max_results = config.model_extra.get("max_results", max_results)
|
max_results = tool_config.model_extra.get("max_results", max_results)
|
||||||
|
|
||||||
results = _search_text(
|
results = _search_text(
|
||||||
query=query,
|
query=query,
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from exa_py import Exa
|
from exa_py import Exa
|
||||||
from langchain.tools import tool
|
from langchain.tools import ToolRuntime, tool
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.deer_flow_context import resolve_context
|
||||||
|
|
||||||
|
|
||||||
def _get_exa_client(tool_name: str = "web_search") -> Exa:
|
def _get_exa_client(app_config: AppConfig, tool_name: str = "web_search") -> Exa:
|
||||||
config = get_app_config().get_tool_config(tool_name)
|
tool_config = app_config.get_tool_config(tool_name)
|
||||||
api_key = None
|
api_key = None
|
||||||
if config is not None and "api_key" in config.model_extra:
|
if tool_config is not None and "api_key" in tool_config.model_extra:
|
||||||
api_key = config.model_extra.get("api_key")
|
api_key = tool_config.model_extra.get("api_key")
|
||||||
return Exa(api_key=api_key)
|
return Exa(api_key=api_key)
|
||||||
|
|
||||||
|
|
||||||
@tool("web_search", parse_docstring=True)
|
@tool("web_search", parse_docstring=True)
|
||||||
def web_search_tool(query: str) -> str:
|
def web_search_tool(query: str, runtime: ToolRuntime) -> str:
|
||||||
"""Search the web.
|
"""Search the web.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: The query to search for.
|
query: The query to search for.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
config = get_app_config().get_tool_config("web_search")
|
app_config = resolve_context(runtime).app_config
|
||||||
|
tool_config = app_config.get_tool_config("web_search")
|
||||||
max_results = 5
|
max_results = 5
|
||||||
search_type = "auto"
|
search_type = "auto"
|
||||||
contents_max_characters = 1000
|
contents_max_characters = 1000
|
||||||
if config is not None:
|
if tool_config is not None:
|
||||||
max_results = config.model_extra.get("max_results", max_results)
|
max_results = tool_config.model_extra.get("max_results", max_results)
|
||||||
search_type = config.model_extra.get("search_type", search_type)
|
search_type = tool_config.model_extra.get("search_type", search_type)
|
||||||
contents_max_characters = config.model_extra.get("contents_max_characters", contents_max_characters)
|
contents_max_characters = tool_config.model_extra.get("contents_max_characters", contents_max_characters)
|
||||||
|
|
||||||
client = _get_exa_client()
|
client = _get_exa_client(app_config)
|
||||||
res = client.search(
|
res = client.search(
|
||||||
query,
|
query,
|
||||||
type=search_type,
|
type=search_type,
|
||||||
@@ -54,7 +56,7 @@ def web_search_tool(query: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@tool("web_fetch", parse_docstring=True)
|
@tool("web_fetch", parse_docstring=True)
|
||||||
def web_fetch_tool(url: str) -> str:
|
def web_fetch_tool(url: str, runtime: ToolRuntime) -> str:
|
||||||
"""Fetch the contents of a web page at a given URL.
|
"""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.
|
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.
|
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
|
||||||
@@ -65,7 +67,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_exa_client("web_fetch")
|
client = _get_exa_client(resolve_context(runtime).app_config, "web_fetch")
|
||||||
res = client.get_contents([url], text={"max_characters": 4096})
|
res = client.get_contents([url], text={"max_characters": 4096})
|
||||||
|
|
||||||
if res.results:
|
if res.results:
|
||||||
|
|||||||
@@ -1,33 +1,35 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from firecrawl import FirecrawlApp
|
from firecrawl import FirecrawlApp
|
||||||
from langchain.tools import tool
|
from langchain.tools import ToolRuntime, tool
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.deer_flow_context import resolve_context
|
||||||
|
|
||||||
|
|
||||||
def _get_firecrawl_client(tool_name: str = "web_search") -> FirecrawlApp:
|
def _get_firecrawl_client(app_config: AppConfig, tool_name: str = "web_search") -> FirecrawlApp:
|
||||||
config = get_app_config().get_tool_config(tool_name)
|
tool_config = app_config.get_tool_config(tool_name)
|
||||||
api_key = None
|
api_key = None
|
||||||
if config is not None and "api_key" in config.model_extra:
|
if tool_config is not None and "api_key" in tool_config.model_extra:
|
||||||
api_key = config.model_extra.get("api_key")
|
api_key = tool_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]
|
||||||
|
|
||||||
|
|
||||||
@tool("web_search", parse_docstring=True)
|
@tool("web_search", parse_docstring=True)
|
||||||
def web_search_tool(query: str) -> str:
|
def web_search_tool(query: str, runtime: ToolRuntime) -> str:
|
||||||
"""Search the web.
|
"""Search the web.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: The query to search for.
|
query: The query to search for.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
config = get_app_config().get_tool_config("web_search")
|
app_config = resolve_context(runtime).app_config
|
||||||
|
tool_config = app_config.get_tool_config("web_search")
|
||||||
max_results = 5
|
max_results = 5
|
||||||
if config is not None:
|
if tool_config is not None:
|
||||||
max_results = config.model_extra.get("max_results", max_results)
|
max_results = tool_config.model_extra.get("max_results", max_results)
|
||||||
|
|
||||||
client = _get_firecrawl_client("web_search")
|
client = _get_firecrawl_client(app_config, "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
|
||||||
@@ -47,7 +49,7 @@ def web_search_tool(query: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@tool("web_fetch", parse_docstring=True)
|
@tool("web_fetch", parse_docstring=True)
|
||||||
def web_fetch_tool(url: str) -> str:
|
def web_fetch_tool(url: str, runtime: ToolRuntime) -> str:
|
||||||
"""Fetch the contents of a web page at a given URL.
|
"""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.
|
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.
|
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
|
||||||
@@ -58,7 +60,8 @@ 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("web_fetch")
|
app_config = resolve_context(runtime).app_config
|
||||||
|
client = _get_firecrawl_client(app_config, "web_fetch")
|
||||||
result = client.scrape(url, formats=["markdown"])
|
result = client.scrape(url, formats=["markdown"])
|
||||||
|
|
||||||
markdown_content = result.markdown or ""
|
markdown_content = result.markdown or ""
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ Image Search Tool - Search images using DuckDuckGo for reference in image genera
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from langchain.tools import tool
|
from langchain.tools import ToolRuntime, tool
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config.deer_flow_context import resolve_context
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -77,6 +77,7 @@ def _search_images(
|
|||||||
@tool("image_search", parse_docstring=True)
|
@tool("image_search", parse_docstring=True)
|
||||||
def image_search_tool(
|
def image_search_tool(
|
||||||
query: str,
|
query: str,
|
||||||
|
runtime: ToolRuntime,
|
||||||
max_results: int = 5,
|
max_results: int = 5,
|
||||||
size: str | None = None,
|
size: str | None = None,
|
||||||
type_image: str | None = None,
|
type_image: str | None = None,
|
||||||
@@ -99,11 +100,11 @@ def image_search_tool(
|
|||||||
type_image: Image type filter. Options: "photo", "clipart", "gif", "transparent", "line". Use "photo" for realistic references.
|
type_image: Image type filter. Options: "photo", "clipart", "gif", "transparent", "line". Use "photo" for realistic references.
|
||||||
layout: Layout filter. Options: "Square", "Tall", "Wide". Choose based on your generation needs.
|
layout: Layout filter. Options: "Square", "Tall", "Wide". Choose based on your generation needs.
|
||||||
"""
|
"""
|
||||||
config = get_app_config().get_tool_config("image_search")
|
tool_config = resolve_context(runtime).app_config.get_tool_config("image_search")
|
||||||
|
|
||||||
# Override max_results from config if set
|
# Override max_results from config if set
|
||||||
if config is not None and "max_results" in config.model_extra:
|
if tool_config is not None and "max_results" in tool_config.model_extra:
|
||||||
max_results = config.model_extra.get("max_results", max_results)
|
max_results = tool_config.model_extra.get("max_results", max_results)
|
||||||
|
|
||||||
results = _search_images(
|
results = _search_images(
|
||||||
query=query,
|
query=query,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from langchain.tools import tool
|
from langchain.tools import ToolRuntime, tool
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.deer_flow_context import resolve_context
|
||||||
from deerflow.utils.readability import ReadabilityExtractor
|
from deerflow.utils.readability import ReadabilityExtractor
|
||||||
|
|
||||||
from .infoquest_client import InfoQuestClient
|
from .infoquest_client import InfoQuestClient
|
||||||
@@ -8,13 +9,13 @@ from .infoquest_client import InfoQuestClient
|
|||||||
readability_extractor = ReadabilityExtractor()
|
readability_extractor = ReadabilityExtractor()
|
||||||
|
|
||||||
|
|
||||||
def _get_infoquest_client() -> InfoQuestClient:
|
def _get_infoquest_client(app_config: AppConfig) -> InfoQuestClient:
|
||||||
search_config = get_app_config().get_tool_config("web_search")
|
search_config = app_config.get_tool_config("web_search")
|
||||||
search_time_range = -1
|
search_time_range = -1
|
||||||
if search_config is not None and "search_time_range" in search_config.model_extra:
|
if search_config is not None and "search_time_range" in search_config.model_extra:
|
||||||
search_time_range = search_config.model_extra.get("search_time_range")
|
search_time_range = search_config.model_extra.get("search_time_range")
|
||||||
|
|
||||||
fetch_config = get_app_config().get_tool_config("web_fetch")
|
fetch_config = app_config.get_tool_config("web_fetch")
|
||||||
fetch_time = -1
|
fetch_time = -1
|
||||||
if fetch_config is not None and "fetch_time" in fetch_config.model_extra:
|
if fetch_config is not None and "fetch_time" in fetch_config.model_extra:
|
||||||
fetch_time = fetch_config.model_extra.get("fetch_time")
|
fetch_time = fetch_config.model_extra.get("fetch_time")
|
||||||
@@ -25,7 +26,7 @@ def _get_infoquest_client() -> InfoQuestClient:
|
|||||||
if fetch_config is not None and "navigation_timeout" in fetch_config.model_extra:
|
if fetch_config is not None and "navigation_timeout" in fetch_config.model_extra:
|
||||||
navigation_timeout = fetch_config.model_extra.get("navigation_timeout")
|
navigation_timeout = fetch_config.model_extra.get("navigation_timeout")
|
||||||
|
|
||||||
image_search_config = get_app_config().get_tool_config("image_search")
|
image_search_config = app_config.get_tool_config("image_search")
|
||||||
image_search_time_range = -1
|
image_search_time_range = -1
|
||||||
if image_search_config is not None and "image_search_time_range" in image_search_config.model_extra:
|
if image_search_config is not None and "image_search_time_range" in image_search_config.model_extra:
|
||||||
image_search_time_range = image_search_config.model_extra.get("image_search_time_range")
|
image_search_time_range = image_search_config.model_extra.get("image_search_time_range")
|
||||||
@@ -44,19 +45,18 @@ def _get_infoquest_client() -> InfoQuestClient:
|
|||||||
|
|
||||||
|
|
||||||
@tool("web_search", parse_docstring=True)
|
@tool("web_search", parse_docstring=True)
|
||||||
def web_search_tool(query: str) -> str:
|
def web_search_tool(query: str, runtime: ToolRuntime) -> str:
|
||||||
"""Search the web.
|
"""Search the web.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: The query to search for.
|
query: The query to search for.
|
||||||
"""
|
"""
|
||||||
|
client = _get_infoquest_client(resolve_context(runtime).app_config)
|
||||||
client = _get_infoquest_client()
|
|
||||||
return client.web_search(query)
|
return client.web_search(query)
|
||||||
|
|
||||||
|
|
||||||
@tool("web_fetch", parse_docstring=True)
|
@tool("web_fetch", parse_docstring=True)
|
||||||
def web_fetch_tool(url: str) -> str:
|
def web_fetch_tool(url: str, runtime: ToolRuntime) -> str:
|
||||||
"""Fetch the contents of a web page at a given URL.
|
"""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.
|
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.
|
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
|
||||||
@@ -66,7 +66,7 @@ def web_fetch_tool(url: str) -> str:
|
|||||||
Args:
|
Args:
|
||||||
url: The URL to fetch the contents of.
|
url: The URL to fetch the contents of.
|
||||||
"""
|
"""
|
||||||
client = _get_infoquest_client()
|
client = _get_infoquest_client(resolve_context(runtime).app_config)
|
||||||
result = client.fetch(url)
|
result = client.fetch(url)
|
||||||
if result.startswith("Error: "):
|
if result.startswith("Error: "):
|
||||||
return result
|
return result
|
||||||
@@ -75,7 +75,7 @@ def web_fetch_tool(url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@tool("image_search", parse_docstring=True)
|
@tool("image_search", parse_docstring=True)
|
||||||
def image_search_tool(query: str) -> str:
|
def image_search_tool(query: str, runtime: ToolRuntime) -> str:
|
||||||
"""Search for images online. Use this tool BEFORE image generation to find reference images for characters, portraits, objects, scenes, or any content requiring visual accuracy.
|
"""Search for images online. Use this tool BEFORE image generation to find reference images for characters, portraits, objects, scenes, or any content requiring visual accuracy.
|
||||||
|
|
||||||
**When to use:**
|
**When to use:**
|
||||||
@@ -89,5 +89,5 @@ def image_search_tool(query: str) -> str:
|
|||||||
Args:
|
Args:
|
||||||
query: The query to search for images.
|
query: The query to search for images.
|
||||||
"""
|
"""
|
||||||
client = _get_infoquest_client()
|
client = _get_infoquest_client(resolve_context(runtime).app_config)
|
||||||
return client.image_search(query)
|
return client.image_search(query)
|
||||||
|
|||||||
@@ -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,14 +1,16 @@
|
|||||||
from langchain.tools import tool
|
import asyncio
|
||||||
|
|
||||||
|
from langchain.tools import ToolRuntime, tool
|
||||||
|
|
||||||
from deerflow.community.jina_ai.jina_client import JinaClient
|
from deerflow.community.jina_ai.jina_client import JinaClient
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config.deer_flow_context import resolve_context
|
||||||
from deerflow.utils.readability import ReadabilityExtractor
|
from deerflow.utils.readability import ReadabilityExtractor
|
||||||
|
|
||||||
readability_extractor = ReadabilityExtractor()
|
readability_extractor = ReadabilityExtractor()
|
||||||
|
|
||||||
|
|
||||||
@tool("web_fetch", parse_docstring=True)
|
@tool("web_fetch", parse_docstring=True)
|
||||||
async def web_fetch_tool(url: str) -> str:
|
async def web_fetch_tool(url: str, runtime: ToolRuntime) -> str:
|
||||||
"""Fetch the contents of a web page at a given URL.
|
"""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.
|
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.
|
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
|
||||||
@@ -20,11 +22,11 @@ async def web_fetch_tool(url: str) -> str:
|
|||||||
"""
|
"""
|
||||||
jina_client = JinaClient()
|
jina_client = JinaClient()
|
||||||
timeout = 10
|
timeout = 10
|
||||||
config = get_app_config().get_tool_config("web_fetch")
|
tool_config = resolve_context(runtime).app_config.get_tool_config("web_fetch")
|
||||||
if config is not None and "timeout" in config.model_extra:
|
if tool_config is not None and "timeout" in tool_config.model_extra:
|
||||||
timeout = config.model_extra.get("timeout")
|
timeout = tool_config.model_extra.get("timeout")
|
||||||
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]
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from langchain.tools import tool
|
from langchain.tools import ToolRuntime, tool
|
||||||
from tavily import TavilyClient
|
from tavily import TavilyClient
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.deer_flow_context import resolve_context
|
||||||
|
|
||||||
|
|
||||||
def _get_tavily_client() -> TavilyClient:
|
def _get_tavily_client(app_config: AppConfig) -> TavilyClient:
|
||||||
config = get_app_config().get_tool_config("web_search")
|
tool_config = app_config.get_tool_config("web_search")
|
||||||
api_key = None
|
api_key = None
|
||||||
if config is not None and "api_key" in config.model_extra:
|
if tool_config is not None and "api_key" in tool_config.model_extra:
|
||||||
api_key = config.model_extra.get("api_key")
|
api_key = tool_config.model_extra.get("api_key")
|
||||||
return TavilyClient(api_key=api_key)
|
return TavilyClient(api_key=api_key)
|
||||||
|
|
||||||
|
|
||||||
@tool("web_search", parse_docstring=True)
|
@tool("web_search", parse_docstring=True)
|
||||||
def web_search_tool(query: str) -> str:
|
def web_search_tool(query: str, runtime: ToolRuntime) -> str:
|
||||||
"""Search the web.
|
"""Search the web.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: The query to search for.
|
query: The query to search for.
|
||||||
"""
|
"""
|
||||||
config = get_app_config().get_tool_config("web_search")
|
app_config = resolve_context(runtime).app_config
|
||||||
|
tool_config = app_config.get_tool_config("web_search")
|
||||||
max_results = 5
|
max_results = 5
|
||||||
if config is not None and "max_results" in config.model_extra:
|
if tool_config is not None and "max_results" in tool_config.model_extra:
|
||||||
max_results = config.model_extra.get("max_results")
|
max_results = tool_config.model_extra.get("max_results")
|
||||||
|
|
||||||
client = _get_tavily_client()
|
client = _get_tavily_client(app_config)
|
||||||
res = client.search(query, max_results=max_results)
|
res = client.search(query, max_results=max_results)
|
||||||
normalized_results = [
|
normalized_results = [
|
||||||
{
|
{
|
||||||
@@ -41,7 +43,7 @@ def web_search_tool(query: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@tool("web_fetch", parse_docstring=True)
|
@tool("web_fetch", parse_docstring=True)
|
||||||
def web_fetch_tool(url: str) -> str:
|
def web_fetch_tool(url: str, runtime: ToolRuntime) -> str:
|
||||||
"""Fetch the contents of a web page at a given URL.
|
"""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.
|
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.
|
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
|
||||||
@@ -51,7 +53,8 @@ def web_fetch_tool(url: str) -> str:
|
|||||||
Args:
|
Args:
|
||||||
url: The URL to fetch the contents of.
|
url: The URL to fetch the contents of.
|
||||||
"""
|
"""
|
||||||
client = _get_tavily_client()
|
app_config = resolve_context(runtime).app_config
|
||||||
|
client = _get_tavily_client(app_config)
|
||||||
res = client.extract([url])
|
res = client.extract([url])
|
||||||
if "failed_results" in res and len(res["failed_results"]) > 0:
|
if "failed_results" in res and len(res["failed_results"]) > 0:
|
||||||
return f"Error: {res['failed_results'][0]['error']}"
|
return f"Error: {res['failed_results'][0]['error']}"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from .app_config import get_app_config
|
from .app_config import AppConfig
|
||||||
from .extensions_config import ExtensionsConfig, get_extensions_config
|
from .extensions_config import ExtensionsConfig
|
||||||
from .memory_config import MemoryConfig, get_memory_config
|
from .memory_config import MemoryConfig
|
||||||
from .paths import Paths, get_paths
|
from .paths import Paths, get_paths
|
||||||
from .skill_evolution_config import SkillEvolutionConfig
|
from .skill_evolution_config import SkillEvolutionConfig
|
||||||
from .skills_config import SkillsConfig
|
from .skills_config import SkillsConfig
|
||||||
@@ -13,18 +13,16 @@ from .tracing_config import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_app_config",
|
"AppConfig",
|
||||||
"SkillEvolutionConfig",
|
|
||||||
"Paths",
|
|
||||||
"get_paths",
|
|
||||||
"SkillsConfig",
|
|
||||||
"ExtensionsConfig",
|
"ExtensionsConfig",
|
||||||
"get_extensions_config",
|
|
||||||
"MemoryConfig",
|
"MemoryConfig",
|
||||||
"get_memory_config",
|
"Paths",
|
||||||
"get_tracing_config",
|
"SkillEvolutionConfig",
|
||||||
"get_explicitly_enabled_tracing_providers",
|
"SkillsConfig",
|
||||||
"get_enabled_tracing_providers",
|
"get_enabled_tracing_providers",
|
||||||
|
"get_explicitly_enabled_tracing_providers",
|
||||||
|
"get_paths",
|
||||||
|
"get_tracing_config",
|
||||||
"is_tracing_enabled",
|
"is_tracing_enabled",
|
||||||
"validate_enabled_tracing_providers",
|
"validate_enabled_tracing_providers",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
"""ACP (Agent Client Protocol) agent configuration loaded from config.yaml."""
|
"""ACP (Agent Client Protocol) agent configuration loaded from config.yaml."""
|
||||||
|
|
||||||
import logging
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from collections.abc import Mapping
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ACPAgentConfig(BaseModel):
|
class ACPAgentConfig(BaseModel):
|
||||||
"""Configuration for a single ACP-compatible agent."""
|
"""Configuration for a single ACP-compatible agent."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
command: str = Field(description="Command to launch the ACP agent subprocess")
|
command: str = Field(description="Command to launch the ACP agent subprocess")
|
||||||
args: list[str] = Field(default_factory=list, description="Additional command arguments")
|
args: list[str] = Field(default_factory=list, description="Additional command arguments")
|
||||||
env: dict[str, str] = Field(default_factory=dict, description="Environment variables to inject into the agent subprocess. Values starting with $ are resolved from host environment variables.")
|
env: dict[str, str] = Field(default_factory=dict, description="Environment variables to inject into the agent subprocess. Values starting with $ are resolved from host environment variables.")
|
||||||
@@ -24,28 +21,3 @@ class ACPAgentConfig(BaseModel):
|
|||||||
"are denied — the agent must be configured to operate without requesting permissions."
|
"are denied — the agent must be configured to operate without requesting permissions."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_acp_agents: dict[str, ACPAgentConfig] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def get_acp_agents() -> dict[str, ACPAgentConfig]:
|
|
||||||
"""Get the currently configured ACP agents.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Mapping of agent name -> ACPAgentConfig. Empty dict if no ACP agents are configured.
|
|
||||||
"""
|
|
||||||
return _acp_agents
|
|
||||||
|
|
||||||
|
|
||||||
def load_acp_config_from_dict(config_dict: Mapping[str, Mapping[str, object]] | None) -> None:
|
|
||||||
"""Load ACP agent configuration from a dictionary (typically from config.yaml).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_dict: Mapping of agent name -> config fields.
|
|
||||||
"""
|
|
||||||
global _acp_agents
|
|
||||||
if config_dict is None:
|
|
||||||
config_dict = {}
|
|
||||||
_acp_agents = {name: ACPAgentConfig(**cfg) for name, cfg in config_dict.items()}
|
|
||||||
logger.info("ACP config loaded: %d agent(s): %s", len(_acp_agents), list(_acp_agents.keys()))
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Configuration for the custom agents management API."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AgentsApiConfig(BaseModel):
|
||||||
|
"""Configuration for custom-agent and user-profile management routes."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
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."),
|
||||||
|
)
|
||||||
@@ -5,7 +5,7 @@ import re
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
|
||||||
@@ -15,9 +15,22 @@ 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."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
@@ -46,8 +59,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"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextvars import ContextVar
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Self
|
from typing import Any, Self
|
||||||
|
|
||||||
@@ -8,30 +9,38 @@ import yaml
|
|||||||
from dotenv import load_dotenv
|
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 ACPAgentConfig
|
||||||
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
|
from deerflow.config.agents_api_config import AgentsApiConfig
|
||||||
|
from deerflow.config.checkpointer_config import CheckpointerConfig
|
||||||
from deerflow.config.database_config import DatabaseConfig
|
from deerflow.config.database_config import DatabaseConfig
|
||||||
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
|
||||||
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
|
from deerflow.config.memory_config import MemoryConfig
|
||||||
from deerflow.config.model_config import ModelConfig
|
from deerflow.config.model_config import ModelConfig
|
||||||
from deerflow.config.run_events_config import RunEventsConfig
|
from deerflow.config.run_events_config import RunEventsConfig
|
||||||
from deerflow.config.sandbox_config import SandboxConfig
|
from deerflow.config.sandbox_config import SandboxConfig
|
||||||
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
||||||
from deerflow.config.skills_config import SkillsConfig
|
from deerflow.config.skills_config import SkillsConfig
|
||||||
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
|
from deerflow.config.stream_bridge_config import StreamBridgeConfig
|
||||||
from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict
|
from deerflow.config.subagents_config import SubagentsAppConfig
|
||||||
from deerflow.config.summarization_config import SummarizationConfig, load_summarization_config_from_dict
|
from deerflow.config.summarization_config import SummarizationConfig
|
||||||
from deerflow.config.title_config import TitleConfig, load_title_config_from_dict
|
from deerflow.config.title_config import TitleConfig
|
||||||
from deerflow.config.token_usage_config import TokenUsageConfig
|
from deerflow.config.token_usage_config import TokenUsageConfig
|
||||||
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
|
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
|
||||||
from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict
|
from deerflow.config.tool_search_config import ToolSearchConfig
|
||||||
|
|
||||||
load_dotenv()
|
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]
|
||||||
@@ -55,13 +64,16 @@ 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")
|
||||||
model_config = ConfigDict(extra="allow", frozen=False)
|
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
|
||||||
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
|
database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration")
|
||||||
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
|
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
|
||||||
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
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")
|
||||||
|
acp_agents: dict[str, ACPAgentConfig] = Field(default_factory=dict, description="ACP agent configurations keyed by agent name")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def resolve_config_path(cls, config_path: str | None = None) -> Path:
|
def resolve_config_path(cls, config_path: str | None = None) -> Path:
|
||||||
@@ -109,41 +121,6 @@ class AppConfig(BaseModel):
|
|||||||
|
|
||||||
config_data = cls.resolve_env_variables(config_data)
|
config_data = cls.resolve_env_variables(config_data)
|
||||||
|
|
||||||
# Load title config if present
|
|
||||||
if "title" in config_data:
|
|
||||||
load_title_config_from_dict(config_data["title"])
|
|
||||||
|
|
||||||
# Load summarization config if present
|
|
||||||
if "summarization" in config_data:
|
|
||||||
load_summarization_config_from_dict(config_data["summarization"])
|
|
||||||
|
|
||||||
# Load memory config if present
|
|
||||||
if "memory" in config_data:
|
|
||||||
load_memory_config_from_dict(config_data["memory"])
|
|
||||||
|
|
||||||
# Load subagents config if present
|
|
||||||
if "subagents" in config_data:
|
|
||||||
load_subagents_config_from_dict(config_data["subagents"])
|
|
||||||
|
|
||||||
# Load tool_search config if present
|
|
||||||
if "tool_search" in config_data:
|
|
||||||
load_tool_search_config_from_dict(config_data["tool_search"])
|
|
||||||
|
|
||||||
# Load guardrails config if present
|
|
||||||
if "guardrails" in config_data:
|
|
||||||
load_guardrails_config_from_dict(config_data["guardrails"])
|
|
||||||
|
|
||||||
# Load checkpointer config if present
|
|
||||||
if "checkpointer" in config_data:
|
|
||||||
load_checkpointer_config_from_dict(config_data["checkpointer"])
|
|
||||||
|
|
||||||
# Load stream bridge config if present
|
|
||||||
if "stream_bridge" in config_data:
|
|
||||||
load_stream_bridge_config_from_dict(config_data["stream_bridge"])
|
|
||||||
|
|
||||||
# Always refresh ACP agent config so removed entries do not linger across reloads.
|
|
||||||
load_acp_config_from_dict(config_data.get("acp_agents", {}))
|
|
||||||
|
|
||||||
# Load extensions config separately (it's in a different file)
|
# Load extensions config separately (it's in a different file)
|
||||||
extensions_config = ExtensionsConfig.from_file()
|
extensions_config = ExtensionsConfig.from_file()
|
||||||
config_data["extensions"] = extensions_config.model_dump()
|
config_data["extensions"] = extensions_config.model_dump()
|
||||||
@@ -254,130 +231,8 @@ class AppConfig(BaseModel):
|
|||||||
"""
|
"""
|
||||||
return next((group for group in self.tool_groups if group.name == name), None)
|
return next((group for group in self.tool_groups if group.name == name), None)
|
||||||
|
|
||||||
|
# AppConfig is a pure value object: construct with ``from_file()``, pass around.
|
||||||
_app_config: AppConfig | None = None
|
# Composition roots that hold the resolved instance:
|
||||||
_app_config_path: Path | None = None
|
# - Gateway: ``app.state.config`` via ``Depends(get_config)``
|
||||||
_app_config_mtime: float | None = None
|
# - Client: ``DeerFlowClient._app_config``
|
||||||
_app_config_is_custom = False
|
# - Agent run: ``Runtime[DeerFlowContext].context.app_config``
|
||||||
_current_app_config: ContextVar[AppConfig | None] = ContextVar("deerflow_current_app_config", default=None)
|
|
||||||
_current_app_config_stack: ContextVar[tuple[AppConfig | None, ...]] = ContextVar("deerflow_current_app_config_stack", default=())
|
|
||||||
|
|
||||||
|
|
||||||
def _get_config_mtime(config_path: Path) -> float | None:
|
|
||||||
"""Get the modification time of a config file if it exists."""
|
|
||||||
try:
|
|
||||||
return config_path.stat().st_mtime
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
|
|
||||||
"""Load config from disk and refresh cache metadata."""
|
|
||||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
|
||||||
|
|
||||||
resolved_path = AppConfig.resolve_config_path(config_path)
|
|
||||||
_app_config = AppConfig.from_file(str(resolved_path))
|
|
||||||
_app_config_path = resolved_path
|
|
||||||
_app_config_mtime = _get_config_mtime(resolved_path)
|
|
||||||
_app_config_is_custom = False
|
|
||||||
return _app_config
|
|
||||||
|
|
||||||
|
|
||||||
def get_app_config() -> AppConfig:
|
|
||||||
"""Get the DeerFlow config instance.
|
|
||||||
|
|
||||||
Returns a cached singleton instance and automatically reloads it when the
|
|
||||||
underlying config file path or modification time changes. Use
|
|
||||||
`reload_app_config()` to force a reload, or `reset_app_config()` to clear
|
|
||||||
the cache.
|
|
||||||
"""
|
|
||||||
global _app_config, _app_config_path, _app_config_mtime
|
|
||||||
|
|
||||||
runtime_override = _current_app_config.get()
|
|
||||||
if runtime_override is not None:
|
|
||||||
return runtime_override
|
|
||||||
|
|
||||||
if _app_config is not None and _app_config_is_custom:
|
|
||||||
return _app_config
|
|
||||||
|
|
||||||
resolved_path = AppConfig.resolve_config_path()
|
|
||||||
current_mtime = _get_config_mtime(resolved_path)
|
|
||||||
|
|
||||||
should_reload = _app_config is None or _app_config_path != resolved_path or _app_config_mtime != current_mtime
|
|
||||||
if should_reload:
|
|
||||||
if _app_config_path == resolved_path and _app_config_mtime is not None and current_mtime is not None and _app_config_mtime != current_mtime:
|
|
||||||
logger.info(
|
|
||||||
"Config file has been modified (mtime: %s -> %s), reloading AppConfig",
|
|
||||||
_app_config_mtime,
|
|
||||||
current_mtime,
|
|
||||||
)
|
|
||||||
_load_and_cache_app_config(str(resolved_path))
|
|
||||||
return _app_config
|
|
||||||
|
|
||||||
|
|
||||||
def reload_app_config(config_path: str | None = None) -> AppConfig:
|
|
||||||
"""Reload the config from file and update the cached instance.
|
|
||||||
|
|
||||||
This is useful when the config file has been modified and you want
|
|
||||||
to pick up the changes without restarting the application.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_path: Optional path to config file. If not provided,
|
|
||||||
uses the default resolution strategy.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The newly loaded AppConfig instance.
|
|
||||||
"""
|
|
||||||
return _load_and_cache_app_config(config_path)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_app_config() -> None:
|
|
||||||
"""Reset the cached config instance.
|
|
||||||
|
|
||||||
This clears the singleton cache, causing the next call to
|
|
||||||
`get_app_config()` to reload from file. Useful for testing
|
|
||||||
or when switching between different configurations.
|
|
||||||
"""
|
|
||||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
|
||||||
_app_config = None
|
|
||||||
_app_config_path = None
|
|
||||||
_app_config_mtime = None
|
|
||||||
_app_config_is_custom = False
|
|
||||||
|
|
||||||
|
|
||||||
def set_app_config(config: AppConfig) -> None:
|
|
||||||
"""Set a custom config instance.
|
|
||||||
|
|
||||||
This allows injecting a custom or mock config for testing purposes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: The AppConfig instance to use.
|
|
||||||
"""
|
|
||||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
|
||||||
_app_config = config
|
|
||||||
_app_config_path = None
|
|
||||||
_app_config_mtime = None
|
|
||||||
_app_config_is_custom = True
|
|
||||||
|
|
||||||
|
|
||||||
def peek_current_app_config() -> AppConfig | None:
|
|
||||||
"""Return the runtime-scoped AppConfig override, if one is active."""
|
|
||||||
return _current_app_config.get()
|
|
||||||
|
|
||||||
|
|
||||||
def push_current_app_config(config: AppConfig) -> None:
|
|
||||||
"""Push a runtime-scoped AppConfig override for the current execution context."""
|
|
||||||
stack = _current_app_config_stack.get()
|
|
||||||
_current_app_config_stack.set(stack + (_current_app_config.get(),))
|
|
||||||
_current_app_config.set(config)
|
|
||||||
|
|
||||||
|
|
||||||
def pop_current_app_config() -> None:
|
|
||||||
"""Pop the latest runtime-scoped AppConfig override for the current execution context."""
|
|
||||||
stack = _current_app_config_stack.get()
|
|
||||||
if not stack:
|
|
||||||
_current_app_config.set(None)
|
|
||||||
return
|
|
||||||
previous = stack[-1]
|
|
||||||
_current_app_config_stack.set(stack[:-1])
|
|
||||||
_current_app_config.set(previous)
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
CheckpointerType = Literal["memory", "sqlite", "postgres"]
|
CheckpointerType = Literal["memory", "sqlite", "postgres"]
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ CheckpointerType = Literal["memory", "sqlite", "postgres"]
|
|||||||
class CheckpointerConfig(BaseModel):
|
class CheckpointerConfig(BaseModel):
|
||||||
"""Configuration for LangGraph state persistence checkpointer."""
|
"""Configuration for LangGraph state persistence checkpointer."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
type: CheckpointerType = Field(
|
type: CheckpointerType = Field(
|
||||||
description="Checkpointer backend type. "
|
description="Checkpointer backend type. "
|
||||||
"'memory' is in-process only (lost on restart). "
|
"'memory' is in-process only (lost on restart). "
|
||||||
@@ -23,24 +25,3 @@ class CheckpointerConfig(BaseModel):
|
|||||||
"For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. "
|
"For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. "
|
||||||
"For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.",
|
"For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global configuration instance — None means no checkpointer is configured.
|
|
||||||
_checkpointer_config: CheckpointerConfig | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_checkpointer_config() -> CheckpointerConfig | None:
|
|
||||||
"""Get the current checkpointer configuration, or None if not configured."""
|
|
||||||
return _checkpointer_config
|
|
||||||
|
|
||||||
|
|
||||||
def set_checkpointer_config(config: CheckpointerConfig | None) -> None:
|
|
||||||
"""Set the checkpointer configuration."""
|
|
||||||
global _checkpointer_config
|
|
||||||
_checkpointer_config = config
|
|
||||||
|
|
||||||
|
|
||||||
def load_checkpointer_config_from_dict(config_dict: dict) -> None:
|
|
||||||
"""Load checkpointer configuration from a dictionary."""
|
|
||||||
global _checkpointer_config
|
|
||||||
_checkpointer_config = CheckpointerConfig(**config_dict)
|
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class DatabaseConfig(BaseModel):
|
class DatabaseConfig(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
backend: Literal["memory", "sqlite", "postgres"] = Field(
|
backend: Literal["memory", "sqlite", "postgres"] = Field(
|
||||||
default="memory",
|
default="memory",
|
||||||
description=("Storage backend for both checkpointer and application data. 'memory' for development (no persistence across restarts), 'sqlite' for single-node deployment, 'postgres' for production multi-node deployment."),
|
description=("Storage backend for both checkpointer and application data. 'memory' for development (no persistence across restarts), 'sqlite' for single-node deployment, 'postgres' for production multi-node deployment."),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Per-invocation context for DeerFlow agent execution.
|
||||||
|
|
||||||
|
Injected via LangGraph Runtime. Middleware and tools access this
|
||||||
|
via Runtime[DeerFlowContext] parameters, through resolve_context().
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeerFlowContext:
|
||||||
|
"""Typed, immutable, per-invocation context injected via LangGraph Runtime.
|
||||||
|
|
||||||
|
Fields are all known at run start and never change during execution.
|
||||||
|
Mutable runtime state (e.g. sandbox_id) flows through ThreadState, not here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
app_config: AppConfig
|
||||||
|
thread_id: str
|
||||||
|
agent_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_context(runtime: Any) -> DeerFlowContext:
|
||||||
|
"""Return the typed DeerFlowContext that the runtime carries.
|
||||||
|
|
||||||
|
Gateway mode (``DeerFlowClient``, ``run_agent``) always attaches a typed
|
||||||
|
``DeerFlowContext`` via ``agent.astream(context=...)``; the LangGraph
|
||||||
|
Server path uses ``langgraph.json`` registration where the top-level
|
||||||
|
``make_lead_agent`` loads ``AppConfig`` from disk itself, so we still
|
||||||
|
arrive here with a typed context.
|
||||||
|
|
||||||
|
Only the dict/None shapes that legacy tests used to exercise would fall
|
||||||
|
through this function; we now reject them loudly instead of papering
|
||||||
|
over the missing context with an ambient ``AppConfig`` lookup.
|
||||||
|
"""
|
||||||
|
ctx = getattr(runtime, "context", None)
|
||||||
|
if isinstance(ctx, DeerFlowContext):
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"resolve_context: runtime.context is not a DeerFlowContext "
|
||||||
|
"(got type %s). Every entry point must attach one at invoke time — "
|
||||||
|
"Gateway/Client via agent.astream(context=DeerFlowContext(...)), "
|
||||||
|
"LangGraph Server via the make_lead_agent boundary that loads "
|
||||||
|
"AppConfig.from_file()." % type(ctx).__name__
|
||||||
|
)
|
||||||
@@ -11,6 +11,8 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
class McpOAuthConfig(BaseModel):
|
class McpOAuthConfig(BaseModel):
|
||||||
"""OAuth configuration for an MCP server (HTTP/SSE transports)."""
|
"""OAuth configuration for an MCP server (HTTP/SSE transports)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(default=True, description="Whether OAuth token injection is enabled")
|
enabled: bool = Field(default=True, description="Whether OAuth token injection is enabled")
|
||||||
token_url: str = Field(description="OAuth token endpoint URL")
|
token_url: str = Field(description="OAuth token endpoint URL")
|
||||||
grant_type: Literal["client_credentials", "refresh_token"] = Field(
|
grant_type: Literal["client_credentials", "refresh_token"] = Field(
|
||||||
@@ -28,12 +30,13 @@ class McpOAuthConfig(BaseModel):
|
|||||||
default_token_type: str = Field(default="Bearer", description="Default token type when missing in token response")
|
default_token_type: str = Field(default="Bearer", description="Default token type when missing in token response")
|
||||||
refresh_skew_seconds: int = Field(default=60, description="Refresh token this many seconds before expiry")
|
refresh_skew_seconds: int = Field(default=60, description="Refresh token this many seconds before expiry")
|
||||||
extra_token_params: dict[str, str] = Field(default_factory=dict, description="Additional form params sent to token endpoint")
|
extra_token_params: dict[str, str] = Field(default_factory=dict, description="Additional form params sent to token endpoint")
|
||||||
model_config = ConfigDict(extra="allow")
|
|
||||||
|
|
||||||
|
|
||||||
class McpServerConfig(BaseModel):
|
class McpServerConfig(BaseModel):
|
||||||
"""Configuration for a single MCP server."""
|
"""Configuration for a single MCP server."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(default=True, description="Whether this MCP server is enabled")
|
enabled: bool = Field(default=True, description="Whether this MCP server is enabled")
|
||||||
type: str = Field(default="stdio", description="Transport type: 'stdio', 'sse', or 'http'")
|
type: str = Field(default="stdio", description="Transport type: 'stdio', 'sse', or 'http'")
|
||||||
command: str | None = Field(default=None, description="Command to execute to start the MCP server (for stdio type)")
|
command: str | None = Field(default=None, description="Command to execute to start the MCP server (for stdio type)")
|
||||||
@@ -43,12 +46,13 @@ class McpServerConfig(BaseModel):
|
|||||||
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
|
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
|
||||||
oauth: McpOAuthConfig | None = Field(default=None, description="OAuth configuration (for sse or http type)")
|
oauth: McpOAuthConfig | None = Field(default=None, description="OAuth configuration (for sse or http type)")
|
||||||
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
|
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
|
||||||
model_config = ConfigDict(extra="allow")
|
|
||||||
|
|
||||||
|
|
||||||
class SkillStateConfig(BaseModel):
|
class SkillStateConfig(BaseModel):
|
||||||
"""Configuration for a single skill's state."""
|
"""Configuration for a single skill's state."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +68,7 @@ class ExtensionsConfig(BaseModel):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Map of skill name to state configuration",
|
description="Map of skill name to state configuration",
|
||||||
)
|
)
|
||||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
model_config = ConfigDict(extra="allow", frozen=True, populate_by_name=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def resolve_config_path(cls, config_path: str | None = None) -> Path | None:
|
def resolve_config_path(cls, config_path: str | None = None) -> Path | None:
|
||||||
@@ -195,62 +199,3 @@ class ExtensionsConfig(BaseModel):
|
|||||||
# Default to enable for public & custom skill
|
# Default to enable for public & custom skill
|
||||||
return skill_category in ("public", "custom")
|
return skill_category in ("public", "custom")
|
||||||
return skill_config.enabled
|
return skill_config.enabled
|
||||||
|
|
||||||
|
|
||||||
_extensions_config: ExtensionsConfig | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_extensions_config() -> ExtensionsConfig:
|
|
||||||
"""Get the extensions config instance.
|
|
||||||
|
|
||||||
Returns a cached singleton instance. Use `reload_extensions_config()` to reload
|
|
||||||
from file, or `reset_extensions_config()` to clear the cache.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The cached ExtensionsConfig instance.
|
|
||||||
"""
|
|
||||||
global _extensions_config
|
|
||||||
if _extensions_config is None:
|
|
||||||
_extensions_config = ExtensionsConfig.from_file()
|
|
||||||
return _extensions_config
|
|
||||||
|
|
||||||
|
|
||||||
def reload_extensions_config(config_path: str | None = None) -> ExtensionsConfig:
|
|
||||||
"""Reload the extensions config from file and update the cached instance.
|
|
||||||
|
|
||||||
This is useful when the config file has been modified and you want
|
|
||||||
to pick up the changes without restarting the application.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_path: Optional path to extensions config file. If not provided,
|
|
||||||
uses the default resolution strategy.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The newly loaded ExtensionsConfig instance.
|
|
||||||
"""
|
|
||||||
global _extensions_config
|
|
||||||
_extensions_config = ExtensionsConfig.from_file(config_path)
|
|
||||||
return _extensions_config
|
|
||||||
|
|
||||||
|
|
||||||
def reset_extensions_config() -> None:
|
|
||||||
"""Reset the cached extensions config instance.
|
|
||||||
|
|
||||||
This clears the singleton cache, causing the next call to
|
|
||||||
`get_extensions_config()` to reload from file. Useful for testing
|
|
||||||
or when switching between different configurations.
|
|
||||||
"""
|
|
||||||
global _extensions_config
|
|
||||||
_extensions_config = None
|
|
||||||
|
|
||||||
|
|
||||||
def set_extensions_config(config: ExtensionsConfig) -> None:
|
|
||||||
"""Set a custom extensions config instance.
|
|
||||||
|
|
||||||
This allows injecting a custom or mock config for testing purposes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: The ExtensionsConfig instance to use.
|
|
||||||
"""
|
|
||||||
global _extensions_config
|
|
||||||
_extensions_config = config
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""Configuration for pre-tool-call authorization."""
|
"""Configuration for pre-tool-call authorization."""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class GuardrailProviderConfig(BaseModel):
|
class GuardrailProviderConfig(BaseModel):
|
||||||
"""Configuration for a guardrail provider."""
|
"""Configuration for a guardrail provider."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
use: str = Field(description="Class path (e.g. 'deerflow.guardrails.builtin:AllowlistProvider')")
|
use: str = Field(description="Class path (e.g. 'deerflow.guardrails.builtin:AllowlistProvider')")
|
||||||
config: dict = Field(default_factory=dict, description="Provider-specific settings passed as kwargs")
|
config: dict = Field(default_factory=dict, description="Provider-specific settings passed as kwargs")
|
||||||
|
|
||||||
@@ -18,31 +20,9 @@ class GuardrailsConfig(BaseModel):
|
|||||||
agent's passport reference, and returns an allow/deny decision.
|
agent's passport reference, and returns an allow/deny decision.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(default=False, description="Enable guardrail middleware")
|
enabled: bool = Field(default=False, description="Enable guardrail middleware")
|
||||||
fail_closed: bool = Field(default=True, description="Block tool calls if provider errors")
|
fail_closed: bool = Field(default=True, description="Block tool calls if provider errors")
|
||||||
passport: str | None = Field(default=None, description="OAP passport path or hosted agent ID")
|
passport: str | None = Field(default=None, description="OAP passport path or hosted agent ID")
|
||||||
provider: GuardrailProviderConfig | None = Field(default=None, description="Guardrail provider configuration")
|
provider: GuardrailProviderConfig | None = Field(default=None, description="Guardrail provider configuration")
|
||||||
|
|
||||||
|
|
||||||
_guardrails_config: GuardrailsConfig | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_guardrails_config() -> GuardrailsConfig:
|
|
||||||
"""Get the guardrails config, returning defaults if not loaded."""
|
|
||||||
global _guardrails_config
|
|
||||||
if _guardrails_config is None:
|
|
||||||
_guardrails_config = GuardrailsConfig()
|
|
||||||
return _guardrails_config
|
|
||||||
|
|
||||||
|
|
||||||
def load_guardrails_config_from_dict(data: dict) -> GuardrailsConfig:
|
|
||||||
"""Load guardrails config from a dict (called during AppConfig loading)."""
|
|
||||||
global _guardrails_config
|
|
||||||
_guardrails_config = GuardrailsConfig.model_validate(data)
|
|
||||||
return _guardrails_config
|
|
||||||
|
|
||||||
|
|
||||||
def reset_guardrails_config() -> None:
|
|
||||||
"""Reset the cached config instance. Used in tests to prevent singleton leaks."""
|
|
||||||
global _guardrails_config
|
|
||||||
_guardrails_config = None
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""Configuration for memory mechanism."""
|
"""Configuration for memory mechanism."""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class MemoryConfig(BaseModel):
|
class MemoryConfig(BaseModel):
|
||||||
"""Configuration for global memory mechanism."""
|
"""Configuration for global memory mechanism."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Whether to enable memory mechanism",
|
description="Whether to enable memory mechanism",
|
||||||
@@ -60,24 +62,3 @@ class MemoryConfig(BaseModel):
|
|||||||
le=8000,
|
le=8000,
|
||||||
description="Maximum tokens to use for memory injection",
|
description="Maximum tokens to use for memory injection",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global configuration instance
|
|
||||||
_memory_config: MemoryConfig = MemoryConfig()
|
|
||||||
|
|
||||||
|
|
||||||
def get_memory_config() -> MemoryConfig:
|
|
||||||
"""Get the current memory configuration."""
|
|
||||||
return _memory_config
|
|
||||||
|
|
||||||
|
|
||||||
def set_memory_config(config: MemoryConfig) -> None:
|
|
||||||
"""Set the memory configuration."""
|
|
||||||
global _memory_config
|
|
||||||
_memory_config = config
|
|
||||||
|
|
||||||
|
|
||||||
def load_memory_config_from_dict(config_dict: dict) -> None:
|
|
||||||
"""Load memory configuration from a dictionary."""
|
|
||||||
global _memory_config
|
|
||||||
_memory_config = MemoryConfig(**config_dict)
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class ModelConfig(BaseModel):
|
|||||||
description="Class path of the model provider(e.g. langchain_openai.ChatOpenAI)",
|
description="Class path of the model provider(e.g. langchain_openai.ChatOpenAI)",
|
||||||
)
|
)
|
||||||
model: str = Field(..., description="Model name")
|
model: str = Field(..., description="Model name")
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
use_responses_api: bool | None = Field(
|
use_responses_api: bool | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Whether to route OpenAI ChatOpenAI calls through the /v1/responses API",
|
description="Whether to route OpenAI ChatOpenAI calls through the /v1/responses API",
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class RunEventsConfig(BaseModel):
|
class RunEventsConfig(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
backend: Literal["memory", "db", "jsonl"] = Field(
|
backend: Literal["memory", "db", "jsonl"] = Field(
|
||||||
default="memory",
|
default="memory",
|
||||||
description="Storage backend for run events. 'memory' for development (no persistence), 'db' for production (SQL queries), 'jsonl' for lightweight single-node persistence.",
|
description="Storage backend for run events. 'memory' for development (no persistence), 'db' for production (SQL queries), 'jsonl' for lightweight single-node persistence.",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
class VolumeMountConfig(BaseModel):
|
class VolumeMountConfig(BaseModel):
|
||||||
"""Configuration for a volume mount."""
|
"""Configuration for a volume mount."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
host_path: str = Field(..., description="Path on the host machine")
|
host_path: str = Field(..., description="Path on the host machine")
|
||||||
container_path: str = Field(..., description="Path inside the container")
|
container_path: str = Field(..., description="Path inside the container")
|
||||||
read_only: bool = Field(default=False, description="Whether the mount is read-only")
|
read_only: bool = Field(default=False, description="Whether the mount is read-only")
|
||||||
@@ -80,4 +82,4 @@ class SandboxConfig(BaseModel):
|
|||||||
description="Maximum characters to keep from ls tool output. Output exceeding this limit is head-truncated. Set to 0 to disable truncation.",
|
description="Maximum characters to keep from ls tool output. Output exceeding this limit is head-truncated. Set to 0 to disable truncation.",
|
||||||
)
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class SkillEvolutionConfig(BaseModel):
|
class SkillEvolutionConfig(BaseModel):
|
||||||
"""Configuration for agent-managed skill evolution."""
|
"""Configuration for agent-managed skill evolution."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Whether the agent can create and modify skills under skills/custom.",
|
description="Whether the agent can create and modify skills under skills/custom.",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
def _default_repo_root() -> Path:
|
def _default_repo_root() -> Path:
|
||||||
@@ -11,6 +11,8 @@ def _default_repo_root() -> Path:
|
|||||||
class SkillsConfig(BaseModel):
|
class SkillsConfig(BaseModel):
|
||||||
"""Configuration for skills system"""
|
"""Configuration for skills system"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
path: str | None = Field(
|
path: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Path to skills directory. If not specified, defaults to ../skills relative to backend directory",
|
description="Path to skills directory. If not specified, defaults to ../skills relative to backend directory",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
StreamBridgeType = Literal["memory", "redis"]
|
StreamBridgeType = Literal["memory", "redis"]
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ StreamBridgeType = Literal["memory", "redis"]
|
|||||||
class StreamBridgeConfig(BaseModel):
|
class StreamBridgeConfig(BaseModel):
|
||||||
"""Configuration for the stream bridge that connects agent workers to SSE endpoints."""
|
"""Configuration for the stream bridge that connects agent workers to SSE endpoints."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
type: StreamBridgeType = Field(
|
type: StreamBridgeType = Field(
|
||||||
default="memory",
|
default="memory",
|
||||||
description="Stream bridge backend type. 'memory' uses in-process asyncio.Queue (single-process only). 'redis' uses Redis Streams (planned for Phase 2, not yet implemented).",
|
description="Stream bridge backend type. 'memory' uses in-process asyncio.Queue (single-process only). 'redis' uses Redis Streams (planned for Phase 2, not yet implemented).",
|
||||||
@@ -22,25 +24,3 @@ class StreamBridgeConfig(BaseModel):
|
|||||||
default=256,
|
default=256,
|
||||||
description="Maximum number of events buffered per run in the memory bridge.",
|
description="Maximum number of events buffered per run in the memory bridge.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global configuration instance — None means no stream bridge is configured
|
|
||||||
# (falls back to memory with defaults).
|
|
||||||
_stream_bridge_config: StreamBridgeConfig | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_stream_bridge_config() -> StreamBridgeConfig | None:
|
|
||||||
"""Get the current stream bridge configuration, or None if not configured."""
|
|
||||||
return _stream_bridge_config
|
|
||||||
|
|
||||||
|
|
||||||
def set_stream_bridge_config(config: StreamBridgeConfig | None) -> None:
|
|
||||||
"""Set the stream bridge configuration."""
|
|
||||||
global _stream_bridge_config
|
|
||||||
_stream_bridge_config = config
|
|
||||||
|
|
||||||
|
|
||||||
def load_stream_bridge_config_from_dict(config_dict: dict) -> None:
|
|
||||||
"""Load stream bridge configuration from a dictionary."""
|
|
||||||
global _stream_bridge_config
|
|
||||||
_stream_bridge_config = StreamBridgeConfig(**config_dict)
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
"""Configuration for the subagent system loaded from config.yaml."""
|
"""Configuration for the subagent system loaded from config.yaml."""
|
||||||
|
|
||||||
import logging
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SubagentOverrideConfig(BaseModel):
|
class SubagentOverrideConfig(BaseModel):
|
||||||
"""Per-agent configuration overrides."""
|
"""Per-agent configuration overrides."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
timeout_seconds: int | None = Field(
|
timeout_seconds: int | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
ge=1,
|
ge=1,
|
||||||
@@ -20,11 +18,59 @@ 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):
|
||||||
"""Configuration for the subagent system."""
|
"""Configuration for the subagent system."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
timeout_seconds: int = Field(
|
timeout_seconds: int = Field(
|
||||||
default=900,
|
default=900,
|
||||||
ge=1,
|
ge=1,
|
||||||
@@ -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,40 +127,16 @@ 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.
|
||||||
|
|
||||||
_subagents_config: SubagentsAppConfig = SubagentsAppConfig()
|
Args:
|
||||||
|
agent_name: The name of the subagent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
def get_subagents_app_config() -> SubagentsAppConfig:
|
Skill names whitelist if overridden, None otherwise (subagent will inherit all enabled skills).
|
||||||
"""Get the current subagents configuration."""
|
"""
|
||||||
return _subagents_config
|
override = self.agents.get(agent_name)
|
||||||
|
if override is not None and override.skills is not None:
|
||||||
|
return override.skills
|
||||||
def load_subagents_config_from_dict(config_dict: dict) -> None:
|
return None
|
||||||
"""Load subagents configuration from a dictionary."""
|
|
||||||
global _subagents_config
|
|
||||||
_subagents_config = SubagentsAppConfig(**config_dict)
|
|
||||||
|
|
||||||
overrides_summary = {}
|
|
||||||
for name, override in _subagents_config.agents.items():
|
|
||||||
parts = []
|
|
||||||
if override.timeout_seconds is not None:
|
|
||||||
parts.append(f"timeout={override.timeout_seconds}s")
|
|
||||||
if override.max_turns is not None:
|
|
||||||
parts.append(f"max_turns={override.max_turns}")
|
|
||||||
if parts:
|
|
||||||
overrides_summary[name] = ", ".join(parts)
|
|
||||||
|
|
||||||
if overrides_summary:
|
|
||||||
logger.info(
|
|
||||||
"Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s",
|
|
||||||
_subagents_config.timeout_seconds,
|
|
||||||
_subagents_config.max_turns,
|
|
||||||
overrides_summary,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"Subagents config loaded: default timeout=%ss, default max_turns=%s, no per-agent overrides",
|
|
||||||
_subagents_config.timeout_seconds,
|
|
||||||
_subagents_config.max_turns,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
ContextSizeType = Literal["fraction", "tokens", "messages"]
|
ContextSizeType = Literal["fraction", "tokens", "messages"]
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ ContextSizeType = Literal["fraction", "tokens", "messages"]
|
|||||||
class ContextSize(BaseModel):
|
class ContextSize(BaseModel):
|
||||||
"""Context size specification for trigger or keep parameters."""
|
"""Context size specification for trigger or keep parameters."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
type: ContextSizeType = Field(description="Type of context size specification")
|
type: ContextSizeType = Field(description="Type of context size specification")
|
||||||
value: int | float = Field(description="Value for the context size specification")
|
value: int | float = Field(description="Value for the context size specification")
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ class ContextSize(BaseModel):
|
|||||||
class SummarizationConfig(BaseModel):
|
class SummarizationConfig(BaseModel):
|
||||||
"""Configuration for automatic conversation summarization."""
|
"""Configuration for automatic conversation summarization."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Whether to enable automatic conversation summarization",
|
description="Whether to enable automatic conversation summarization",
|
||||||
@@ -51,24 +55,22 @@ 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,
|
||||||
# Global configuration instance
|
ge=0,
|
||||||
_summarization_config: SummarizationConfig = SummarizationConfig()
|
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(
|
||||||
def get_summarization_config() -> SummarizationConfig:
|
default=25000,
|
||||||
"""Get the current summarization configuration."""
|
ge=0,
|
||||||
return _summarization_config
|
description="Total token budget reserved for recently-loaded skill files that must be preserved across summarization.",
|
||||||
|
)
|
||||||
|
preserve_recent_skill_tokens_per_skill: int = Field(
|
||||||
def set_summarization_config(config: SummarizationConfig) -> None:
|
default=5000,
|
||||||
"""Set the summarization configuration."""
|
ge=0,
|
||||||
global _summarization_config
|
description="Per-skill token cap when preserving skill files across summarization. Skill reads above this size are not rescued.",
|
||||||
_summarization_config = config
|
)
|
||||||
|
skill_file_read_tool_names: list[str] = Field(
|
||||||
|
default_factory=lambda: ["read_file", "read", "view", "cat"],
|
||||||
def load_summarization_config_from_dict(config_dict: dict) -> None:
|
description="Tool names treated as skill file reads when preserving recently-loaded skills across summarization.",
|
||||||
"""Load summarization configuration from a dictionary."""
|
)
|
||||||
global _summarization_config
|
|
||||||
_summarization_config = SummarizationConfig(**config_dict)
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""Configuration for automatic thread title generation."""
|
"""Configuration for automatic thread title generation."""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class TitleConfig(BaseModel):
|
class TitleConfig(BaseModel):
|
||||||
"""Configuration for automatic thread title generation."""
|
"""Configuration for automatic thread title generation."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Whether to enable automatic title generation",
|
description="Whether to enable automatic title generation",
|
||||||
@@ -30,24 +32,3 @@ class TitleConfig(BaseModel):
|
|||||||
default=("Generate a concise title (max {max_words} words) for this conversation.\nUser: {user_msg}\nAssistant: {assistant_msg}\n\nReturn ONLY the title, no quotes, no explanation."),
|
default=("Generate a concise title (max {max_words} words) for this conversation.\nUser: {user_msg}\nAssistant: {assistant_msg}\n\nReturn ONLY the title, no quotes, no explanation."),
|
||||||
description="Prompt template for title generation",
|
description="Prompt template for title generation",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global configuration instance
|
|
||||||
_title_config: TitleConfig = TitleConfig()
|
|
||||||
|
|
||||||
|
|
||||||
def get_title_config() -> TitleConfig:
|
|
||||||
"""Get the current title configuration."""
|
|
||||||
return _title_config
|
|
||||||
|
|
||||||
|
|
||||||
def set_title_config(config: TitleConfig) -> None:
|
|
||||||
"""Set the title configuration."""
|
|
||||||
global _title_config
|
|
||||||
_title_config = config
|
|
||||||
|
|
||||||
|
|
||||||
def load_title_config_from_dict(config_dict: dict) -> None:
|
|
||||||
"""Load title configuration from a dictionary."""
|
|
||||||
global _title_config
|
|
||||||
_title_config = TitleConfig(**config_dict)
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class TokenUsageConfig(BaseModel):
|
class TokenUsageConfig(BaseModel):
|
||||||
"""Configuration for token usage tracking."""
|
"""Configuration for token usage tracking."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(default=False, description="Enable token usage tracking middleware")
|
enabled: bool = Field(default=False, description="Enable token usage tracking middleware")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class ToolGroupConfig(BaseModel):
|
|||||||
"""Config section for a tool group"""
|
"""Config section for a tool group"""
|
||||||
|
|
||||||
name: str = Field(..., description="Unique name for the tool group")
|
name: str = Field(..., description="Unique name for the tool group")
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
|
|
||||||
|
|
||||||
class ToolConfig(BaseModel):
|
class ToolConfig(BaseModel):
|
||||||
@@ -17,4 +17,4 @@ class ToolConfig(BaseModel):
|
|||||||
...,
|
...,
|
||||||
description="Variable name of the tool provider(e.g. deerflow.sandbox.tools:bash_tool)",
|
description="Variable name of the tool provider(e.g. deerflow.sandbox.tools:bash_tool)",
|
||||||
)
|
)
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Configuration for deferred tool loading via tool_search."""
|
"""Configuration for deferred tool loading via tool_search."""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class ToolSearchConfig(BaseModel):
|
class ToolSearchConfig(BaseModel):
|
||||||
@@ -11,25 +11,9 @@ class ToolSearchConfig(BaseModel):
|
|||||||
via the tool_search tool at runtime.
|
via the tool_search tool at runtime.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Defer tools and enable tool_search",
|
description="Defer tools and enable tool_search",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_tool_search_config: ToolSearchConfig | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_tool_search_config() -> ToolSearchConfig:
|
|
||||||
"""Get the tool search config, loading from AppConfig if needed."""
|
|
||||||
global _tool_search_config
|
|
||||||
if _tool_search_config is None:
|
|
||||||
_tool_search_config = ToolSearchConfig()
|
|
||||||
return _tool_search_config
|
|
||||||
|
|
||||||
|
|
||||||
def load_tool_search_config_from_dict(data: dict) -> ToolSearchConfig:
|
|
||||||
"""Load tool search config from a dict (called during AppConfig loading)."""
|
|
||||||
global _tool_search_config
|
|
||||||
_tool_search_config = ToolSearchConfig.model_validate(data)
|
|
||||||
return _tool_search_config
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
_config_lock = threading.Lock()
|
_config_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ _config_lock = threading.Lock()
|
|||||||
class LangSmithTracingConfig(BaseModel):
|
class LangSmithTracingConfig(BaseModel):
|
||||||
"""Configuration for LangSmith tracing."""
|
"""Configuration for LangSmith tracing."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(...)
|
enabled: bool = Field(...)
|
||||||
api_key: str | None = Field(...)
|
api_key: str | None = Field(...)
|
||||||
project: str = Field(...)
|
project: str = Field(...)
|
||||||
@@ -26,6 +28,8 @@ class LangSmithTracingConfig(BaseModel):
|
|||||||
class LangfuseTracingConfig(BaseModel):
|
class LangfuseTracingConfig(BaseModel):
|
||||||
"""Configuration for Langfuse tracing."""
|
"""Configuration for Langfuse tracing."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
enabled: bool = Field(...)
|
enabled: bool = Field(...)
|
||||||
public_key: str | None = Field(...)
|
public_key: str | None = Field(...)
|
||||||
secret_key: str | None = Field(...)
|
secret_key: str | None = Field(...)
|
||||||
@@ -50,6 +54,8 @@ class LangfuseTracingConfig(BaseModel):
|
|||||||
class TracingConfig(BaseModel):
|
class TracingConfig(BaseModel):
|
||||||
"""Tracing configuration for supported providers."""
|
"""Tracing configuration for supported providers."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
langsmith: LangSmithTracingConfig = Field(...)
|
langsmith: LangSmithTracingConfig = Field(...)
|
||||||
langfuse: LangfuseTracingConfig = Field(...)
|
langfuse: LangfuseTracingConfig = Field(...)
|
||||||
|
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
|
|
||||||
from langchain.chat_models import BaseChatModel
|
from langchain.chat_models import BaseChatModel
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.reflection import resolve_class
|
from deerflow.reflection import resolve_class
|
||||||
from deerflow.tracing import build_tracing_callbacks
|
from deerflow.tracing import build_tracing_callbacks
|
||||||
|
|
||||||
@@ -30,16 +30,39 @@ def _vllm_disable_chat_template_kwargs(chat_template_kwargs: dict) -> dict:
|
|||||||
return disable_kwargs
|
return disable_kwargs
|
||||||
|
|
||||||
|
|
||||||
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, **kwargs) -> BaseChatModel:
|
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,
|
||||||
|
*,
|
||||||
|
app_config: "AppConfig",
|
||||||
|
**kwargs,
|
||||||
|
) -> BaseChatModel:
|
||||||
"""Create a chat model instance from the config.
|
"""Create a chat model instance from the config.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: The name of the model to create. If None, the first model in the config will be used.
|
name: The name of the model to create. If None, the first model in the config will be used.
|
||||||
|
app_config: Application config — required.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A chat model instance.
|
A chat model instance.
|
||||||
"""
|
"""
|
||||||
config = get_app_config()
|
config = app_config
|
||||||
if name is None:
|
if name is None:
|
||||||
name = config.models[0].name
|
name = config.models[0].name
|
||||||
model_config = config.get_model_config(name)
|
model_config = config.get_model_config(name)
|
||||||
@@ -97,6 +120,8 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
|
|||||||
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
|
||||||
|
|
||||||
@@ -113,16 +138,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"
|
||||||
|
|
||||||
# Ensure stream_usage is enabled so that token usage metadata is available
|
# For MindIE models: enforce conservative retry defaults.
|
||||||
# in streaming responses. LangChain's BaseChatOpenAI only defaults
|
# Timeout normalization is handled inside MindIEChatModel itself.
|
||||||
# stream_usage=True when no custom base_url/api_base is set, so models
|
if getattr(model_class, "__name__", "") == "MindIEChatModel":
|
||||||
# hitting third-party endpoints (e.g. doubao, deepseek) silently lose
|
# Enforce max_retries constraint to prevent cascading timeouts.
|
||||||
# usage data. We default it to True unless explicitly configured.
|
model_settings_from_config["max_retries"] = model_settings_from_config.get("max_retries", 1)
|
||||||
if "stream_usage" not in model_settings_from_config and "stream_usage" not in kwargs:
|
|
||||||
if "stream_usage" in getattr(model_class, "model_fields", {}):
|
|
||||||
model_settings_from_config["stream_usage"] = True
|
|
||||||
|
|
||||||
model_instance = model_class(**kwargs, **model_settings_from_config)
|
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)
|
||||||
@@ -24,7 +24,7 @@ from collections.abc import AsyncIterator
|
|||||||
|
|
||||||
from langgraph.types import Checkpointer
|
from langgraph.types import Checkpointer
|
||||||
|
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.runtime.checkpointer.provider import (
|
from deerflow.runtime.checkpointer.provider import (
|
||||||
POSTGRES_CONN_REQUIRED,
|
POSTGRES_CONN_REQUIRED,
|
||||||
POSTGRES_INSTALL,
|
POSTGRES_INSTALL,
|
||||||
@@ -123,11 +123,11 @@ async def _async_checkpointer_from_database(db_config) -> AsyncIterator[Checkpoi
|
|||||||
|
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def make_checkpointer() -> AsyncIterator[Checkpointer]:
|
async def make_checkpointer(app_config: AppConfig) -> AsyncIterator[Checkpointer]:
|
||||||
"""Async context manager that yields a checkpointer for the caller's lifetime.
|
"""Async context manager that yields a checkpointer for the caller's lifetime.
|
||||||
Resources are opened on enter and closed on exit -- no global state::
|
Resources are opened on enter and closed on exit -- no global state::
|
||||||
|
|
||||||
async with make_checkpointer() as checkpointer:
|
async with make_checkpointer(app_config) as checkpointer:
|
||||||
app.state.checkpointer = checkpointer
|
app.state.checkpointer = checkpointer
|
||||||
|
|
||||||
Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*.
|
Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*.
|
||||||
@@ -138,16 +138,14 @@ async def make_checkpointer() -> AsyncIterator[Checkpointer]:
|
|||||||
3. Default InMemorySaver
|
3. Default InMemorySaver
|
||||||
"""
|
"""
|
||||||
|
|
||||||
config = get_app_config()
|
|
||||||
|
|
||||||
# Legacy: standalone checkpointer config takes precedence
|
# Legacy: standalone checkpointer config takes precedence
|
||||||
if config.checkpointer is not None:
|
if app_config.checkpointer is not None:
|
||||||
async with _async_checkpointer(config.checkpointer) as saver:
|
async with _async_checkpointer(app_config.checkpointer) as saver:
|
||||||
yield saver
|
yield saver
|
||||||
return
|
return
|
||||||
|
|
||||||
# Unified database config
|
# Unified database config
|
||||||
db_config = getattr(config, "database", None)
|
db_config = getattr(app_config, "database", None)
|
||||||
if db_config is not None and db_config.backend != "memory":
|
if db_config is not None and db_config.backend != "memory":
|
||||||
async with _async_checkpointer_from_database(db_config) as saver:
|
async with _async_checkpointer_from_database(db_config) as saver:
|
||||||
yield saver
|
yield saver
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ from collections.abc import Iterator
|
|||||||
|
|
||||||
from langgraph.types import Checkpointer
|
from langgraph.types import Checkpointer
|
||||||
|
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
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)
|
||||||
@@ -99,10 +100,13 @@ _checkpointer: Checkpointer | None = None
|
|||||||
_checkpointer_ctx = None # open context manager keeping the connection alive
|
_checkpointer_ctx = None # open context manager keeping the connection alive
|
||||||
|
|
||||||
|
|
||||||
def get_checkpointer() -> Checkpointer:
|
def get_checkpointer(app_config: AppConfig) -> Checkpointer:
|
||||||
"""Return the global sync checkpointer singleton, creating it on first call.
|
"""Return the global sync checkpointer singleton, creating it on first call.
|
||||||
|
|
||||||
Returns an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*.
|
Returns an ``InMemorySaver`` only when ``checkpointer`` is explicitly
|
||||||
|
absent from config.yaml. Any other failure (missing config, invalid
|
||||||
|
backend, connection error) propagates — silent degradation to in-memory
|
||||||
|
would drop persistent-run state on process restart.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ImportError: If the required package for the configured backend is not installed.
|
ImportError: If the required package for the configured backend is not installed.
|
||||||
@@ -113,25 +117,7 @@ def get_checkpointer() -> Checkpointer:
|
|||||||
if _checkpointer is not None:
|
if _checkpointer is not None:
|
||||||
return _checkpointer
|
return _checkpointer
|
||||||
|
|
||||||
# Ensure app config is loaded before checking checkpointer config
|
config = app_config.checkpointer
|
||||||
# This prevents returning InMemorySaver when config.yaml actually has a checkpointer section
|
|
||||||
# but hasn't been loaded yet
|
|
||||||
from deerflow.config.app_config import _app_config
|
|
||||||
from deerflow.config.checkpointer_config import get_checkpointer_config
|
|
||||||
|
|
||||||
config = get_checkpointer_config()
|
|
||||||
|
|
||||||
if config is None and _app_config is None:
|
|
||||||
# Only load app config lazily when neither the app config nor an explicit
|
|
||||||
# checkpointer config has been initialized yet. This keeps tests that
|
|
||||||
# intentionally set the global checkpointer config isolated from any
|
|
||||||
# ambient config.yaml on disk.
|
|
||||||
try:
|
|
||||||
get_app_config()
|
|
||||||
except FileNotFoundError:
|
|
||||||
# In test environments without config.yaml, this is expected.
|
|
||||||
pass
|
|
||||||
config = get_checkpointer_config()
|
|
||||||
if config is None:
|
if config is None:
|
||||||
from langgraph.checkpoint.memory import InMemorySaver
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
|
||||||
@@ -167,25 +153,23 @@ def reset_checkpointer() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def checkpointer_context() -> Iterator[Checkpointer]:
|
def checkpointer_context(app_config: AppConfig) -> Iterator[Checkpointer]:
|
||||||
"""Sync context manager that yields a checkpointer and cleans up on exit.
|
"""Sync context manager that yields a checkpointer and cleans up on exit.
|
||||||
|
|
||||||
Unlike :func:`get_checkpointer`, this does **not** cache the instance —
|
Unlike :func:`get_checkpointer`, this does **not** cache the instance —
|
||||||
each ``with`` block creates and destroys its own connection. Use it in
|
each ``with`` block creates and destroys its own connection. Use it in
|
||||||
CLI scripts or tests where you want deterministic cleanup::
|
CLI scripts or tests where you want deterministic cleanup::
|
||||||
|
|
||||||
with checkpointer_context() as cp:
|
with checkpointer_context(app_config) as cp:
|
||||||
graph.invoke(input, config={"configurable": {"thread_id": "1"}})
|
graph.invoke(input, config={"configurable": {"thread_id": "1"}})
|
||||||
|
|
||||||
Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*.
|
Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*.
|
||||||
"""
|
"""
|
||||||
|
if app_config.checkpointer is None:
|
||||||
config = get_app_config()
|
|
||||||
if config.checkpointer is None:
|
|
||||||
from langgraph.checkpoint.memory import InMemorySaver
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
|
||||||
yield InMemorySaver()
|
yield InMemorySaver()
|
||||||
return
|
return
|
||||||
|
|
||||||
with _sync_checkpointer_cm(config.checkpointer) as saver:
|
with _sync_checkpointer_cm(app_config.checkpointer) as saver:
|
||||||
yield saver
|
yield saver
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ handles token usage accumulation.
|
|||||||
|
|
||||||
Key design decisions:
|
Key design decisions:
|
||||||
- on_llm_new_token is NOT implemented -- only complete messages via on_llm_end
|
- on_llm_new_token is NOT implemented -- only complete messages via on_llm_end
|
||||||
- on_chat_model_start captures structured prompts as llm_request (OpenAI format) and
|
- on_chat_model_start captures structured prompts as llm_request (OpenAI format)
|
||||||
extracts the first human message for run.input, because it is more reliable than
|
|
||||||
on_chain_start (fires on every node) — messages here are fully structured.
|
|
||||||
- on_chain_start with parent_run_id=None emits a run.start trace marking root invocation.
|
|
||||||
- on_llm_end emits llm_response in OpenAI Chat Completions format
|
- on_llm_end emits llm_response in OpenAI Chat Completions format
|
||||||
- Token usage accumulated in memory, written to RunRow on run completion
|
- Token usage accumulated in memory, written to RunRow on run completion
|
||||||
- Caller identification via tags injection (lead_agent / subagent:{name} / middleware:{name})
|
- Caller identification via tags injection (lead_agent / subagent:{name} / middleware:{name})
|
||||||
@@ -21,12 +18,10 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from langchain_core.callbacks import BaseCallbackHandler
|
from langchain_core.callbacks import BaseCallbackHandler
|
||||||
from langchain_core.messages import AnyMessage, BaseMessage, HumanMessage, ToolMessage
|
|
||||||
from langgraph.types import Command
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from deerflow.runtime.events.store.base import RunEventStore
|
from deerflow.runtime.events.store.base import RunEventStore
|
||||||
@@ -77,39 +72,34 @@ class RunJournal(BaseCallbackHandler):
|
|||||||
# LLM request/response tracking
|
# LLM request/response tracking
|
||||||
self._llm_call_index = 0
|
self._llm_call_index = 0
|
||||||
self._cached_prompts: dict[str, list[dict]] = {} # langchain run_id -> OpenAI messages
|
self._cached_prompts: dict[str, list[dict]] = {} # langchain run_id -> OpenAI messages
|
||||||
|
self._cached_models: dict[str, str] = {} # langchain run_id -> model name
|
||||||
|
|
||||||
|
# Tool call ID cache
|
||||||
|
self._tool_call_ids: dict[str, str] = {} # langchain run_id -> tool_call_id
|
||||||
|
|
||||||
# -- Lifecycle callbacks --
|
# -- Lifecycle callbacks --
|
||||||
|
|
||||||
def on_chain_start(
|
def on_chain_start(self, serialized: dict, inputs: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
self,
|
if kwargs.get("parent_run_id") is not None:
|
||||||
serialized: dict[str, Any],
|
return
|
||||||
inputs: dict[str, Any],
|
self._put(
|
||||||
*,
|
event_type="run_start",
|
||||||
run_id: UUID,
|
category="lifecycle",
|
||||||
parent_run_id: UUID | None = None,
|
metadata={"input_preview": str(inputs)[:500]},
|
||||||
tags: list[str] | None = None,
|
)
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
caller = self._identify_caller(tags)
|
|
||||||
if parent_run_id is None:
|
|
||||||
# Root graph invocation — emit a single trace event for the run start.
|
|
||||||
chain_name = (serialized or {}).get("name", "unknown")
|
|
||||||
self._put(
|
|
||||||
event_type="run.start",
|
|
||||||
category="trace",
|
|
||||||
content={"chain": chain_name},
|
|
||||||
metadata={"caller": caller, **(metadata or {})},
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_chain_end(self, outputs: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
def on_chain_end(self, outputs: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
self._put(event_type="run.end", category="outputs", content=outputs, metadata={"status": "success"})
|
if kwargs.get("parent_run_id") is not None:
|
||||||
|
return
|
||||||
|
self._put(event_type="run_end", category="lifecycle", metadata={"status": "success"})
|
||||||
self._flush_sync()
|
self._flush_sync()
|
||||||
|
|
||||||
def on_chain_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
def on_chain_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
|
if kwargs.get("parent_run_id") is not None:
|
||||||
|
return
|
||||||
self._put(
|
self._put(
|
||||||
event_type="run.error",
|
event_type="run_error",
|
||||||
category="error",
|
category="lifecycle",
|
||||||
content=str(error),
|
content=str(error),
|
||||||
metadata={"error_type": type(error).__name__},
|
metadata={"error_type": type(error).__name__},
|
||||||
)
|
)
|
||||||
@@ -117,132 +107,266 @@ class RunJournal(BaseCallbackHandler):
|
|||||||
|
|
||||||
# -- LLM callbacks --
|
# -- LLM callbacks --
|
||||||
|
|
||||||
def on_chat_model_start(
|
def on_chat_model_start(self, serialized: dict, messages: list[list], *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
self,
|
"""Capture structured prompt messages for llm_request event."""
|
||||||
serialized: dict,
|
from deerflow.runtime.converters import langchain_messages_to_openai
|
||||||
messages: list[list[BaseMessage]],
|
|
||||||
*,
|
|
||||||
run_id: UUID,
|
|
||||||
tags: list[str] | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Capture structured prompt messages for llm_request event.
|
|
||||||
|
|
||||||
This is also the canonical place to extract the first human message:
|
|
||||||
messages are fully structured here, it fires only on real LLM calls,
|
|
||||||
and the content is never compressed by checkpoint trimming.
|
|
||||||
"""
|
|
||||||
rid = str(run_id)
|
rid = str(run_id)
|
||||||
self._llm_start_times[rid] = time.monotonic()
|
self._llm_start_times[rid] = time.monotonic()
|
||||||
self._llm_call_index += 1
|
self._llm_call_index += 1
|
||||||
# Mark this run_id as seen so on_llm_end knows not to increment again.
|
|
||||||
self._cached_prompts[rid] = []
|
|
||||||
|
|
||||||
logger.info(f"on_chat_model_start {run_id}: tags={tags} serialized={serialized} messages={messages}")
|
model_name = serialized.get("name", "")
|
||||||
|
self._cached_models[rid] = model_name
|
||||||
|
|
||||||
# Capture the first human message sent to any LLM in this run.
|
# Convert the first message list (LangChain passes list-of-lists)
|
||||||
if not self._first_human_msg:
|
prompt_msgs = messages[0] if messages else []
|
||||||
for batch in messages.reversed():
|
openai_msgs = langchain_messages_to_openai(prompt_msgs)
|
||||||
for m in batch.reversed():
|
self._cached_prompts[rid] = openai_msgs
|
||||||
if isinstance(m, HumanMessage) and m.name != "summary":
|
|
||||||
caller = self._identify_caller(tags)
|
|
||||||
self.set_first_human_message(m.text)
|
|
||||||
self._put(
|
|
||||||
event_type="llm.human.input",
|
|
||||||
category="message",
|
|
||||||
content=m.model_dump(),
|
|
||||||
metadata={"caller": caller},
|
|
||||||
)
|
|
||||||
break
|
|
||||||
if self._first_human_msg:
|
|
||||||
break
|
|
||||||
|
|
||||||
def on_llm_start(self, serialized: dict, prompts: list[str], *, run_id: UUID, parent_run_id: UUID | None = None, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, **kwargs: Any) -> None:
|
caller = self._identify_caller(kwargs)
|
||||||
|
self._put(
|
||||||
|
event_type="llm_request",
|
||||||
|
category="trace",
|
||||||
|
content={"model": model_name, "messages": openai_msgs},
|
||||||
|
metadata={"caller": caller, "llm_call_index": self._llm_call_index},
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_llm_start(self, serialized: dict, prompts: list[str], *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
# Fallback: on_chat_model_start is preferred. This just tracks latency.
|
# Fallback: on_chat_model_start is preferred. This just tracks latency.
|
||||||
self._llm_start_times[str(run_id)] = time.monotonic()
|
self._llm_start_times[str(run_id)] = time.monotonic()
|
||||||
|
|
||||||
def on_llm_end(self, response, *, run_id, parent_run_id, tags, **kwargs) -> None:
|
def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
messages: list[AnyMessage] = []
|
from deerflow.runtime.converters import langchain_to_openai_completion
|
||||||
logger.info(f"on_llm_end {run_id}: response: {tags} {kwargs}")
|
|
||||||
for generation in response.generations:
|
|
||||||
for gen in generation:
|
|
||||||
if hasattr(gen, "message"):
|
|
||||||
messages.append(gen.message)
|
|
||||||
else:
|
|
||||||
logger.warning(f"on_llm_end {run_id}: generation has no message attribute: {gen}")
|
|
||||||
|
|
||||||
for message in messages:
|
try:
|
||||||
caller = self._identify_caller(tags)
|
message = response.generations[0][0].message
|
||||||
|
except (IndexError, AttributeError):
|
||||||
|
logger.debug("on_llm_end: could not extract message from response")
|
||||||
|
return
|
||||||
|
|
||||||
# Latency
|
caller = self._identify_caller(kwargs)
|
||||||
rid = str(run_id)
|
|
||||||
start = self._llm_start_times.pop(rid, None)
|
|
||||||
latency_ms = int((time.monotonic() - start) * 1000) if start else None
|
|
||||||
|
|
||||||
# Token usage from message
|
# Latency
|
||||||
usage = getattr(message, "usage_metadata", None)
|
rid = str(run_id)
|
||||||
usage_dict = dict(usage) if usage else {}
|
start = self._llm_start_times.pop(rid, None)
|
||||||
|
latency_ms = int((time.monotonic() - start) * 1000) if start else None
|
||||||
|
|
||||||
# Resolve call index
|
# Token usage from message
|
||||||
|
usage = getattr(message, "usage_metadata", None)
|
||||||
|
usage_dict = dict(usage) if usage else {}
|
||||||
|
|
||||||
|
# Resolve call index
|
||||||
|
call_index = self._llm_call_index
|
||||||
|
if rid not in self._cached_prompts:
|
||||||
|
# Fallback: on_chat_model_start was not called
|
||||||
|
self._llm_call_index += 1
|
||||||
call_index = self._llm_call_index
|
call_index = self._llm_call_index
|
||||||
if rid not in self._cached_prompts:
|
|
||||||
# Fallback: on_chat_model_start was not called
|
|
||||||
self._llm_call_index += 1
|
|
||||||
call_index = self._llm_call_index
|
|
||||||
|
|
||||||
# Trace event: llm_response (OpenAI completion format)
|
# Clean up caches
|
||||||
self._put(
|
self._cached_prompts.pop(rid, None)
|
||||||
event_type="llm.ai.response",
|
self._cached_models.pop(rid, None)
|
||||||
category="message",
|
|
||||||
content=message.model_dump(),
|
|
||||||
metadata={
|
|
||||||
"caller": caller,
|
|
||||||
"usage": usage_dict,
|
|
||||||
"latency_ms": latency_ms,
|
|
||||||
"llm_call_index": call_index,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Token accumulation
|
# Trace event: llm_response (OpenAI completion format)
|
||||||
if self._track_tokens:
|
content = getattr(message, "content", "")
|
||||||
input_tk = usage_dict.get("input_tokens", 0) or 0
|
self._put(
|
||||||
output_tk = usage_dict.get("output_tokens", 0) or 0
|
event_type="llm_response",
|
||||||
total_tk = usage_dict.get("total_tokens", 0) or 0
|
category="trace",
|
||||||
if total_tk == 0:
|
content=langchain_to_openai_completion(message),
|
||||||
total_tk = input_tk + output_tk
|
metadata={
|
||||||
if total_tk > 0:
|
"caller": caller,
|
||||||
self._total_input_tokens += input_tk
|
"usage": usage_dict,
|
||||||
self._total_output_tokens += output_tk
|
"latency_ms": latency_ms,
|
||||||
self._total_tokens += total_tk
|
"llm_call_index": call_index,
|
||||||
self._llm_call_count += 1
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Message events: only lead_agent gets message-category events.
|
||||||
|
# Content uses message.model_dump() to align with checkpoint format.
|
||||||
|
tool_calls = getattr(message, "tool_calls", None) or []
|
||||||
|
if caller == "lead_agent":
|
||||||
|
resp_meta = getattr(message, "response_metadata", None) or {}
|
||||||
|
model_name = resp_meta.get("model_name") if isinstance(resp_meta, dict) else None
|
||||||
|
if tool_calls:
|
||||||
|
# ai_tool_call: agent decided to use tools
|
||||||
|
self._put(
|
||||||
|
event_type="ai_tool_call",
|
||||||
|
category="message",
|
||||||
|
content=message.model_dump(),
|
||||||
|
metadata={"model_name": model_name, "finish_reason": "tool_calls"},
|
||||||
|
)
|
||||||
|
elif isinstance(content, str) and content:
|
||||||
|
# ai_message: final text reply
|
||||||
|
self._put(
|
||||||
|
event_type="ai_message",
|
||||||
|
category="message",
|
||||||
|
content=message.model_dump(),
|
||||||
|
metadata={"model_name": model_name, "finish_reason": "stop"},
|
||||||
|
)
|
||||||
|
self._last_ai_msg = content
|
||||||
|
self._msg_count += 1
|
||||||
|
|
||||||
|
# Token accumulation
|
||||||
|
if self._track_tokens:
|
||||||
|
input_tk = usage_dict.get("input_tokens", 0) or 0
|
||||||
|
output_tk = usage_dict.get("output_tokens", 0) or 0
|
||||||
|
total_tk = usage_dict.get("total_tokens", 0) or 0
|
||||||
|
if total_tk == 0:
|
||||||
|
total_tk = input_tk + output_tk
|
||||||
|
if total_tk > 0:
|
||||||
|
self._total_input_tokens += input_tk
|
||||||
|
self._total_output_tokens += output_tk
|
||||||
|
self._total_tokens += total_tk
|
||||||
|
self._llm_call_count += 1
|
||||||
|
if caller.startswith("subagent:"):
|
||||||
|
self._subagent_tokens += total_tk
|
||||||
|
elif caller.startswith("middleware:"):
|
||||||
|
self._middleware_tokens += total_tk
|
||||||
|
else:
|
||||||
|
self._lead_agent_tokens += total_tk
|
||||||
|
|
||||||
def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
self._llm_start_times.pop(str(run_id), None)
|
self._llm_start_times.pop(str(run_id), None)
|
||||||
self._put(event_type="llm.error", category="trace", content=str(error))
|
self._put(event_type="llm_error", category="trace", content=str(error))
|
||||||
|
|
||||||
def on_tool_start(self, serialized, input_str, *, run_id, parent_run_id=None, tags=None, metadata=None, inputs=None, **kwargs):
|
# -- Tool callbacks --
|
||||||
"""Handle tool start event, cache tool call ID for later correlation"""
|
|
||||||
tool_call_id = str(run_id)
|
|
||||||
logger.info(f"Tool start for node {run_id}, tool_call_id={tool_call_id}, tags={tags}, metadata={metadata}")
|
|
||||||
|
|
||||||
def on_tool_end(self, output, *, run_id, parent_run_id=None, **kwargs):
|
def on_tool_start(self, serialized: dict, input_str: str, *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
"""Handle tool end event, append message and clear node data"""
|
tool_call_id = kwargs.get("tool_call_id")
|
||||||
try:
|
if tool_call_id:
|
||||||
if isinstance(output, ToolMessage):
|
self._tool_call_ids[str(run_id)] = tool_call_id
|
||||||
msg = cast(ToolMessage, output)
|
self._put(
|
||||||
self._put(event_type="llm.tool.result", category="message", content=msg.model_dump())
|
event_type="tool_start",
|
||||||
elif isinstance(output, Command):
|
category="trace",
|
||||||
cmd = cast(Command, output)
|
metadata={
|
||||||
messages = cmd.update.get("messages", [])
|
"tool_name": serialized.get("name", ""),
|
||||||
for message in messages:
|
"tool_call_id": tool_call_id,
|
||||||
if isinstance(message, BaseMessage):
|
"args": str(input_str)[:2000],
|
||||||
self._put(event_type="llm.tool.result", category="message", content=message.model_dump())
|
},
|
||||||
else:
|
)
|
||||||
logger.warning(f"on_tool_end {run_id}: command update message is not BaseMessage: {type(message)}")
|
|
||||||
else:
|
def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
logger.warning(f"on_tool_end {run_id}: output is not ToolMessage: {type(output)}")
|
from langchain_core.messages import ToolMessage
|
||||||
finally:
|
from langgraph.types import Command
|
||||||
logger.info(f"Tool end for node {run_id}")
|
|
||||||
|
# Tools that update graph state return a ``Command`` (e.g.
|
||||||
|
# ``present_files``). LangGraph later unwraps the inner ToolMessage
|
||||||
|
# into checkpoint state, so to stay checkpoint-aligned we must
|
||||||
|
# extract it here rather than storing ``str(Command(...))``.
|
||||||
|
if isinstance(output, Command):
|
||||||
|
update = getattr(output, "update", None) or {}
|
||||||
|
inner_msgs = update.get("messages") if isinstance(update, dict) else None
|
||||||
|
if isinstance(inner_msgs, list):
|
||||||
|
inner_tool_msg = next((m for m in inner_msgs if isinstance(m, ToolMessage)), None)
|
||||||
|
if inner_tool_msg is not None:
|
||||||
|
output = inner_tool_msg
|
||||||
|
|
||||||
|
# Extract fields from ToolMessage object when LangChain provides one.
|
||||||
|
# LangChain's _format_output wraps tool results into a ToolMessage
|
||||||
|
# with tool_call_id, name, status, and artifact — more complete than
|
||||||
|
# what kwargs alone provides.
|
||||||
|
if isinstance(output, ToolMessage):
|
||||||
|
tool_call_id = output.tool_call_id or kwargs.get("tool_call_id") or self._tool_call_ids.pop(str(run_id), None)
|
||||||
|
tool_name = output.name or kwargs.get("name", "")
|
||||||
|
status = getattr(output, "status", "success") or "success"
|
||||||
|
content_str = output.content if isinstance(output.content, str) else str(output.content)
|
||||||
|
# Use model_dump() for checkpoint-aligned message content.
|
||||||
|
# Override tool_call_id if it was resolved from cache.
|
||||||
|
msg_content = output.model_dump()
|
||||||
|
if msg_content.get("tool_call_id") != tool_call_id:
|
||||||
|
msg_content["tool_call_id"] = tool_call_id
|
||||||
|
else:
|
||||||
|
tool_call_id = kwargs.get("tool_call_id") or self._tool_call_ids.pop(str(run_id), None)
|
||||||
|
tool_name = kwargs.get("name", "")
|
||||||
|
status = "success"
|
||||||
|
content_str = str(output)
|
||||||
|
# Construct checkpoint-aligned dict when output is a plain string.
|
||||||
|
msg_content = ToolMessage(
|
||||||
|
content=content_str,
|
||||||
|
tool_call_id=tool_call_id or "",
|
||||||
|
name=tool_name,
|
||||||
|
status=status,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
# Trace event (always)
|
||||||
|
self._put(
|
||||||
|
event_type="tool_end",
|
||||||
|
category="trace",
|
||||||
|
content=content_str,
|
||||||
|
metadata={
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"tool_call_id": tool_call_id,
|
||||||
|
"status": status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Message event: tool_result (checkpoint-aligned model_dump format)
|
||||||
|
self._put(
|
||||||
|
event_type="tool_result",
|
||||||
|
category="message",
|
||||||
|
content=msg_content,
|
||||||
|
metadata={"tool_name": tool_name, "status": status},
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_tool_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
|
||||||
|
tool_call_id = kwargs.get("tool_call_id") or self._tool_call_ids.pop(str(run_id), None)
|
||||||
|
tool_name = kwargs.get("name", "")
|
||||||
|
|
||||||
|
# Trace event
|
||||||
|
self._put(
|
||||||
|
event_type="tool_error",
|
||||||
|
category="trace",
|
||||||
|
content=str(error),
|
||||||
|
metadata={
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"tool_call_id": tool_call_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Message event: tool_result with error status (checkpoint-aligned)
|
||||||
|
msg_content = ToolMessage(
|
||||||
|
content=str(error),
|
||||||
|
tool_call_id=tool_call_id or "",
|
||||||
|
name=tool_name,
|
||||||
|
status="error",
|
||||||
|
).model_dump()
|
||||||
|
self._put(
|
||||||
|
event_type="tool_result",
|
||||||
|
category="message",
|
||||||
|
content=msg_content,
|
||||||
|
metadata={"tool_name": tool_name, "status": "error"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Custom event callback --
|
||||||
|
|
||||||
|
def on_custom_event(self, name: str, data: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
||||||
|
from deerflow.runtime.serialization import serialize_lc_object
|
||||||
|
|
||||||
|
if name == "summarization":
|
||||||
|
data_dict = data if isinstance(data, dict) else {}
|
||||||
|
self._put(
|
||||||
|
event_type="summarization",
|
||||||
|
category="trace",
|
||||||
|
content=data_dict.get("summary", ""),
|
||||||
|
metadata={
|
||||||
|
"replaced_message_ids": data_dict.get("replaced_message_ids", []),
|
||||||
|
"replaced_count": data_dict.get("replaced_count", 0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._put(
|
||||||
|
event_type="middleware:summarize",
|
||||||
|
category="middleware",
|
||||||
|
content={"role": "system", "content": data_dict.get("summary", "")},
|
||||||
|
metadata={"replaced_count": data_dict.get("replaced_count", 0)},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
event_data = serialize_lc_object(data) if not isinstance(data, dict) else data
|
||||||
|
self._put(
|
||||||
|
event_type=name,
|
||||||
|
category="trace",
|
||||||
|
metadata=event_data if isinstance(event_data, dict) else {"data": event_data},
|
||||||
|
)
|
||||||
|
|
||||||
# -- Internal methods --
|
# -- Internal methods --
|
||||||
|
|
||||||
@@ -307,9 +431,8 @@ class RunJournal(BaseCallbackHandler):
|
|||||||
if exc:
|
if exc:
|
||||||
logger.warning("Journal flush task failed: %s", exc)
|
logger.warning("Journal flush task failed: %s", exc)
|
||||||
|
|
||||||
def _identify_caller(self, tags: list[str] | None, **kwargs) -> str:
|
def _identify_caller(self, kwargs: dict) -> str:
|
||||||
_tags = tags or kwargs.get("tags", [])
|
for tag in kwargs.get("tags") or []:
|
||||||
for tag in _tags:
|
|
||||||
if isinstance(tag, str) and (tag.startswith("subagent:") or tag.startswith("middleware:") or tag == "lead_agent"):
|
if isinstance(tag, str) and (tag.startswith("subagent:") or tag.startswith("middleware:") or tag == "lead_agent"):
|
||||||
return tag
|
return tag
|
||||||
# Default to lead_agent: the main agent graph does not inject
|
# Default to lead_agent: the main agent graph does not inject
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class RunManager:
|
|||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._store = store
|
self._store = store
|
||||||
|
|
||||||
async def _persist_to_store(self, record: RunRecord) -> None:
|
async def _persist_to_store(self, record: RunRecord, *, follow_up_to_run_id: str | None = None) -> None:
|
||||||
"""Best-effort persist run record to backing store."""
|
"""Best-effort persist run record to backing store."""
|
||||||
if self._store is None:
|
if self._store is None:
|
||||||
return
|
return
|
||||||
@@ -68,6 +68,7 @@ class RunManager:
|
|||||||
metadata=record.metadata or {},
|
metadata=record.metadata or {},
|
||||||
kwargs=record.kwargs or {},
|
kwargs=record.kwargs or {},
|
||||||
created_at=record.created_at,
|
created_at=record.created_at,
|
||||||
|
follow_up_to_run_id=follow_up_to_run_id,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to persist run %s to store", record.run_id, exc_info=True)
|
logger.warning("Failed to persist run %s to store", record.run_id, exc_info=True)
|
||||||
@@ -89,6 +90,7 @@ class RunManager:
|
|||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
kwargs: dict | None = None,
|
kwargs: dict | None = None,
|
||||||
multitask_strategy: str = "reject",
|
multitask_strategy: str = "reject",
|
||||||
|
follow_up_to_run_id: str | None = None,
|
||||||
) -> RunRecord:
|
) -> RunRecord:
|
||||||
"""Create a new pending run and register it."""
|
"""Create a new pending run and register it."""
|
||||||
run_id = str(uuid.uuid4())
|
run_id = str(uuid.uuid4())
|
||||||
@@ -107,7 +109,7 @@ class RunManager:
|
|||||||
)
|
)
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._runs[run_id] = record
|
self._runs[run_id] = record
|
||||||
await self._persist_to_store(record)
|
await self._persist_to_store(record, follow_up_to_run_id=follow_up_to_run_id)
|
||||||
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
|
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
@@ -120,7 +122,7 @@ class RunManager:
|
|||||||
async with self._lock:
|
async with self._lock:
|
||||||
# Dict insertion order matches creation order, so reversing it gives
|
# Dict insertion order matches creation order, so reversing it gives
|
||||||
# us deterministic newest-first results even when timestamps tie.
|
# us deterministic newest-first results even when timestamps tie.
|
||||||
return [r for r in self._runs.values() if r.thread_id == thread_id]
|
return [r for r in reversed(self._runs.values()) if r.thread_id == thread_id]
|
||||||
|
|
||||||
async def set_status(self, run_id: str, status: RunStatus, *, error: str | None = None) -> None:
|
async def set_status(self, run_id: str, status: RunStatus, *, error: str | None = None) -> None:
|
||||||
"""Transition a run to a new status."""
|
"""Transition a run to a new status."""
|
||||||
@@ -174,6 +176,7 @@ class RunManager:
|
|||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
kwargs: dict | None = None,
|
kwargs: dict | None = None,
|
||||||
multitask_strategy: str = "reject",
|
multitask_strategy: str = "reject",
|
||||||
|
follow_up_to_run_id: str | None = None,
|
||||||
) -> RunRecord:
|
) -> RunRecord:
|
||||||
"""Atomically check for inflight runs and create a new one.
|
"""Atomically check for inflight runs and create a new one.
|
||||||
|
|
||||||
@@ -227,7 +230,7 @@ class RunManager:
|
|||||||
)
|
)
|
||||||
self._runs[run_id] = record
|
self._runs[run_id] = record
|
||||||
|
|
||||||
await self._persist_to_store(record)
|
await self._persist_to_store(record, follow_up_to_run_id=follow_up_to_run_id)
|
||||||
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
|
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class RunStore(abc.ABC):
|
|||||||
kwargs: dict[str, Any] | None = None,
|
kwargs: dict[str, Any] | None = None,
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
created_at: str | None = None,
|
created_at: str | None = None,
|
||||||
|
follow_up_to_run_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class MemoryRunStore(RunStore):
|
|||||||
kwargs=None,
|
kwargs=None,
|
||||||
error=None,
|
error=None,
|
||||||
created_at=None,
|
created_at=None,
|
||||||
|
follow_up_to_run_id=None,
|
||||||
):
|
):
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
self._runs[run_id] = {
|
self._runs[run_id] = {
|
||||||
@@ -40,6 +41,7 @@ class MemoryRunStore(RunStore):
|
|||||||
"metadata": metadata or {},
|
"metadata": metadata or {},
|
||||||
"kwargs": kwargs or {},
|
"kwargs": kwargs or {},
|
||||||
"error": error,
|
"error": error,
|
||||||
|
"follow_up_to_run_id": follow_up_to_run_id,
|
||||||
"created_at": created_at or now,
|
"created_at": created_at or now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from typing import TYPE_CHECKING, Any, Literal
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
|
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.deer_flow_context import DeerFlowContext
|
||||||
from deerflow.runtime.serialization import serialize
|
from deerflow.runtime.serialization import serialize
|
||||||
from deerflow.runtime.stream_bridge import StreamBridge
|
from deerflow.runtime.stream_bridge import StreamBridge
|
||||||
|
|
||||||
@@ -51,6 +53,8 @@ class RunContext:
|
|||||||
event_store: Any | None = field(default=None)
|
event_store: Any | None = field(default=None)
|
||||||
run_events_config: Any | None = field(default=None)
|
run_events_config: Any | None = field(default=None)
|
||||||
thread_store: Any | None = field(default=None)
|
thread_store: Any | None = field(default=None)
|
||||||
|
follow_up_to_run_id: str | None = field(default=None)
|
||||||
|
app_config: AppConfig | None = field(default=None)
|
||||||
|
|
||||||
|
|
||||||
async def run_agent(
|
async def run_agent(
|
||||||
@@ -75,6 +79,7 @@ async def run_agent(
|
|||||||
event_store = ctx.event_store
|
event_store = ctx.event_store
|
||||||
run_events_config = ctx.run_events_config
|
run_events_config = ctx.run_events_config
|
||||||
thread_store = ctx.thread_store
|
thread_store = ctx.thread_store
|
||||||
|
follow_up_to_run_id = ctx.follow_up_to_run_id
|
||||||
|
|
||||||
run_id = record.run_id
|
run_id = record.run_id
|
||||||
thread_id = record.thread_id
|
thread_id = record.thread_id
|
||||||
@@ -111,6 +116,22 @@ async def run_agent(
|
|||||||
track_token_usage=getattr(run_events_config, "track_token_usage", True),
|
track_token_usage=getattr(run_events_config, "track_token_usage", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
human_msg = _extract_human_message(graph_input)
|
||||||
|
if human_msg is not None:
|
||||||
|
msg_metadata = {}
|
||||||
|
if follow_up_to_run_id:
|
||||||
|
msg_metadata["follow_up_to_run_id"] = follow_up_to_run_id
|
||||||
|
await event_store.put(
|
||||||
|
thread_id=thread_id,
|
||||||
|
run_id=run_id,
|
||||||
|
event_type="human_message",
|
||||||
|
category="message",
|
||||||
|
content=human_msg.model_dump(),
|
||||||
|
metadata=msg_metadata or None,
|
||||||
|
)
|
||||||
|
content = human_msg.content
|
||||||
|
journal.set_first_human_message(content if isinstance(content, str) else str(content))
|
||||||
|
|
||||||
# 1. Mark running
|
# 1. Mark running
|
||||||
await run_manager.set_status(run_id, RunStatus.running)
|
await run_manager.set_status(run_id, RunStatus.running)
|
||||||
|
|
||||||
@@ -144,18 +165,16 @@ async def run_agent(
|
|||||||
|
|
||||||
# 3. Build the agent
|
# 3. Build the agent
|
||||||
from langchain_core.runnables import RunnableConfig
|
from langchain_core.runnables import RunnableConfig
|
||||||
from langgraph.runtime import Runtime
|
|
||||||
|
|
||||||
# Inject runtime context so middlewares can access thread_id
|
# Construct typed context for the agent run.
|
||||||
# (langgraph-cli does this automatically; we must do it manually)
|
# LangGraph's astream(context=...) injects this into Runtime.context
|
||||||
runtime = Runtime(context={"thread_id": thread_id, "run_id": run_id}, store=store)
|
# so middleware/tools can access it via resolve_context().
|
||||||
# If the caller already set a ``context`` key (LangGraph >= 0.6.0
|
if ctx.app_config is None:
|
||||||
# prefers it over ``configurable`` for thread-level data), make
|
raise RuntimeError("RunContext.app_config is required — Gateway must populate it via get_run_context")
|
||||||
# sure ``thread_id`` is available there too.
|
deer_flow_context = DeerFlowContext(
|
||||||
if "context" in config and isinstance(config["context"], dict):
|
app_config=ctx.app_config,
|
||||||
config["context"].setdefault("thread_id", thread_id)
|
thread_id=thread_id,
|
||||||
config["context"].setdefault("run_id", run_id)
|
)
|
||||||
config.setdefault("configurable", {})["__pregel_runtime"] = runtime
|
|
||||||
|
|
||||||
# Inject RunJournal as a LangChain callback handler.
|
# Inject RunJournal as a LangChain callback handler.
|
||||||
# on_llm_end captures token usage; on_chain_start/end captures lifecycle.
|
# on_llm_end captures token usage; on_chain_start/end captures lifecycle.
|
||||||
@@ -207,7 +226,7 @@ async def run_agent(
|
|||||||
if len(lg_modes) == 1 and not stream_subgraphs:
|
if len(lg_modes) == 1 and not stream_subgraphs:
|
||||||
# Single mode, no subgraphs: astream yields raw chunks
|
# Single mode, no subgraphs: astream yields raw chunks
|
||||||
single_mode = lg_modes[0]
|
single_mode = lg_modes[0]
|
||||||
async for chunk in agent.astream(graph_input, config=runnable_config, stream_mode=single_mode):
|
async for chunk in agent.astream(graph_input, config=runnable_config, context=deer_flow_context, stream_mode=single_mode):
|
||||||
if record.abort_event.is_set():
|
if record.abort_event.is_set():
|
||||||
logger.info("Run %s abort requested — stopping", run_id)
|
logger.info("Run %s abort requested — stopping", run_id)
|
||||||
break
|
break
|
||||||
@@ -218,6 +237,7 @@ async def run_agent(
|
|||||||
async for item in agent.astream(
|
async for item in agent.astream(
|
||||||
graph_input,
|
graph_input,
|
||||||
config=runnable_config,
|
config=runnable_config,
|
||||||
|
context=deer_flow_context,
|
||||||
stream_mode=lg_modes,
|
stream_mode=lg_modes,
|
||||||
subgraphs=stream_subgraphs,
|
subgraphs=stream_subgraphs,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from collections.abc import AsyncIterator
|
|||||||
|
|
||||||
from langgraph.store.base import BaseStore
|
from langgraph.store.base import BaseStore
|
||||||
|
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.runtime.store.provider import POSTGRES_CONN_REQUIRED, POSTGRES_STORE_INSTALL, SQLITE_STORE_INSTALL, ensure_sqlite_parent_dir, resolve_sqlite_conn_str
|
from deerflow.runtime.store.provider import POSTGRES_CONN_REQUIRED, POSTGRES_STORE_INSTALL, SQLITE_STORE_INSTALL, ensure_sqlite_parent_dir, resolve_sqlite_conn_str
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -86,7 +86,7 @@ async def _async_store(config) -> AsyncIterator[BaseStore]:
|
|||||||
|
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def make_store() -> AsyncIterator[BaseStore]:
|
async def make_store(app_config: AppConfig) -> AsyncIterator[BaseStore]:
|
||||||
"""Async context manager that yields a Store whose backend matches the
|
"""Async context manager that yields a Store whose backend matches the
|
||||||
configured checkpointer.
|
configured checkpointer.
|
||||||
|
|
||||||
@@ -94,20 +94,18 @@ async def make_store() -> AsyncIterator[BaseStore]:
|
|||||||
:func:`deerflow.runtime.checkpointer.async_provider.make_checkpointer` so
|
:func:`deerflow.runtime.checkpointer.async_provider.make_checkpointer` so
|
||||||
that both singletons always use the same persistence technology::
|
that both singletons always use the same persistence technology::
|
||||||
|
|
||||||
async with make_store() as store:
|
async with make_store(app_config) as store:
|
||||||
app.state.store = store
|
app.state.store = store
|
||||||
|
|
||||||
Yields an :class:`~langgraph.store.memory.InMemoryStore` when no
|
Yields an :class:`~langgraph.store.memory.InMemoryStore` when no
|
||||||
``checkpointer`` section is configured (emits a WARNING in that case).
|
``checkpointer`` section is configured (emits a WARNING in that case).
|
||||||
"""
|
"""
|
||||||
config = get_app_config()
|
if app_config.checkpointer is None:
|
||||||
|
|
||||||
if config.checkpointer is None:
|
|
||||||
from langgraph.store.memory import InMemoryStore
|
from langgraph.store.memory import InMemoryStore
|
||||||
|
|
||||||
logger.warning("No 'checkpointer' section in config.yaml — using InMemoryStore for the store. Thread list will be lost on server restart. Configure a sqlite or postgres backend for persistence.")
|
logger.warning("No 'checkpointer' section in config.yaml — using InMemoryStore for the store. Thread list will be lost on server restart. Configure a sqlite or postgres backend for persistence.")
|
||||||
yield InMemoryStore()
|
yield InMemoryStore()
|
||||||
return
|
return
|
||||||
|
|
||||||
async with _async_store(config.checkpointer) as store:
|
async with _async_store(app_config.checkpointer) as store:
|
||||||
yield store
|
yield store
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user