Merge branch 'main' into fix-2804

This commit is contained in:
Willem Jiang
2026-05-12 15:53:28 +08:00
committed by GitHub
38 changed files with 953 additions and 291 deletions
+3 -2
View File
@@ -9,8 +9,9 @@ JINA_API_KEY=your-jina-api-key
# InfoQuest API Key # InfoQuest API Key
INFOQUEST_API_KEY=your-infoquest-api-key INFOQUEST_API_KEY=your-infoquest-api-key
# CORS Origins (comma-separated) - e.g., http://localhost:3000,http://localhost:3001 # Browser CORS allowlist for split-origin or port-forwarded deployments (comma-separated exact origins).
# CORS_ORIGINS=http://localhost:3000 # Leave unset when using the unified nginx endpoint, e.g. http://localhost:2026.
# GATEWAY_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Optional: # Optional:
# FIRECRAWL_API_KEY=your-firecrawl-api-key # FIRECRAWL_API_KEY=your-firecrawl-api-key
+13 -19
View File
@@ -46,12 +46,12 @@ Docker provides a consistent, isolated environment with all dependencies pre-con
All services will start with hot-reload enabled: All services will start with hot-reload enabled:
- Frontend changes are automatically reloaded - Frontend changes are automatically reloaded
- Backend changes trigger automatic restart - Backend changes trigger automatic restart
- LangGraph server supports hot-reload - Gateway-hosted LangGraph-compatible runtime supports hot-reload
4. **Access the application**: 4. **Access the application**:
- Web Interface: http://localhost:2026 - Web Interface: http://localhost:2026
- API Gateway: http://localhost:2026/api/* - API Gateway: http://localhost:2026/api/*
- LangGraph: http://localhost:2026/api/langgraph/* - LangGraph-compatible API: http://localhost:2026/api/langgraph/*
#### Docker Commands #### Docker Commands
@@ -94,7 +94,7 @@ Use these as practical starting points for development and review environments:
If `make docker-init`, `make docker-start`, or `make docker-stop` fails on Linux with an error like below, your current user likely does not have permission to access the Docker daemon socket: If `make docker-init`, `make docker-start`, or `make docker-stop` fails on Linux with an error like below, your current user likely does not have permission to access the Docker daemon socket:
```text ```text
unable to get image 'deer-flow-dev-langgraph': permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock unable to get image 'deer-flow-gateway': permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
``` ```
Recommended fix: add your current user to the `docker` group so Docker commands work without `sudo`. Recommended fix: add your current user to the `docker` group so Docker commands work without `sudo`.
@@ -131,9 +131,8 @@ Host Machine
Docker Compose (deer-flow-dev) Docker Compose (deer-flow-dev)
├→ nginx (port 2026) ← Reverse proxy ├→ nginx (port 2026) ← Reverse proxy
├→ web (port 3000) ← Frontend with hot-reload ├→ web (port 3000) ← Frontend with hot-reload
├→ api (port 8001) ← Gateway API with hot-reload ├→ gateway (port 8001) ← Gateway API + LangGraph-compatible runtime with hot-reload
├→ langgraph (port 2024) ← LangGraph server with hot-reload └→ provisioner (optional, port 8002) ← Started only in provisioner/K8s sandbox mode
└→ provisioner (optional, port 8002) ← Started only in provisioner/K8s sandbox mode
``` ```
**Benefits of Docker Development**: **Benefits of Docker Development**:
@@ -184,17 +183,13 @@ Required tools:
If you need to start services individually: If you need to start services individually:
1. **Start backend services**: 1. **Start backend service**:
```bash ```bash
# Terminal 1: Start LangGraph Server (port 2024) # Terminal 1: Start Gateway API and embedded LangGraph-compatible runtime (port 8001)
cd backend
make dev
# Terminal 2: Start Gateway API (port 8001)
cd backend cd backend
make gateway make gateway
# Terminal 3: Start Frontend (port 3000) # Terminal 2: Start Frontend (port 3000)
cd frontend cd frontend
pnpm dev pnpm dev
``` ```
@@ -212,10 +207,10 @@ If you need to start services individually:
The nginx configuration provides: The nginx configuration provides:
- Unified entry point on port 2026 - Unified entry point on port 2026
- Routes `/api/langgraph/*` to LangGraph Server (2024) - Gateway owns `/api/langgraph/*` and translates those public LangGraph-compatible paths to its native `/api/*` routers behind nginx
- Routes other `/api/*` endpoints to Gateway API (8001) - Routes other `/api/*` endpoints to Gateway API (8001)
- Routes non-API requests to Frontend (3000) - Routes non-API requests to Frontend (3000)
- Centralized CORS handling - Same-origin API routing; split-origin or port-forwarded browser clients should use the Gateway `GATEWAY_CORS_ORIGINS` allowlist
- SSE/streaming support for real-time agent responses - SSE/streaming support for real-time agent responses
- Optimized timeouts for long-running operations - Optimized timeouts for long-running operations
@@ -235,8 +230,8 @@ deer-flow/
│ └── nginx.local.conf # Nginx config for local dev │ └── nginx.local.conf # Nginx config for local dev
├── backend/ # Backend application ├── backend/ # Backend application
│ ├── src/ │ ├── src/
│ │ ├── gateway/ # Gateway API (port 8001) │ │ ├── gateway/ # Gateway API and LangGraph-compatible runtime (port 8001)
│ │ ├── agents/ # LangGraph agents (port 2024) │ │ ├── agents/ # LangGraph agent definitions
│ │ ├── mcp/ # Model Context Protocol integration │ │ ├── mcp/ # Model Context Protocol integration
│ │ ├── skills/ # Skills system │ │ ├── skills/ # Skills system
│ │ └── sandbox/ # Sandbox execution │ │ └── sandbox/ # Sandbox execution
@@ -256,8 +251,7 @@ Browser
Nginx (port 2026) ← Unified entry point Nginx (port 2026) ← Unified entry point
├→ Frontend (port 3000) ← / (non-API requests) ├→ Frontend (port 3000) ← / (non-API requests)
→ Gateway API (port 8001) ← /api/models, /api/mcp, /api/skills, /api/threads/*/artifacts → Gateway API (port 8001) ← /api/* and /api/langgraph/* (LangGraph-compatible agent interactions)
└→ LangGraph Server (port 2024) ← /api/langgraph/* (agent interactions)
``` ```
## Development Workflow ## Development Workflow
+2
View File
@@ -245,6 +245,8 @@ make down # Stop and remove containers
Access: http://localhost:2026 Access: http://localhost:2026
The unified nginx endpoint is same-origin by default and does not emit browser CORS headers. If you run a split-origin or port-forwarded browser client, set `GATEWAY_CORS_ORIGINS` to comma-separated exact origins such as `http://localhost:3000`; the Gateway then applies the CORS allowlist and matching CSRF origin checks.
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide. See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide.
#### Option 2: Local Development #### Option 2: Local Development
+3 -1
View File
@@ -207,6 +207,8 @@ Configuration priority:
FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled). FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled).
CORS is same-origin by default when requests enter through nginx on port 2026. Split-origin or port-forwarded browser clients must opt in with `GATEWAY_CORS_ORIGINS` (comma-separated exact origins); Gateway `CORSMiddleware` and `CSRFMiddleware` both read that variable so browser CORS and auth-origin checks stay aligned.
**Routers**: **Routers**:
| Router | Endpoints | | Router | Endpoints |
@@ -223,7 +225,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific | | **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id | | **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway. Proxied through nginx: `/api/langgraph/*` Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
### Sandbox System (`packages/harness/deerflow/sandbox/`) ### Sandbox System (`packages/harness/deerflow/sandbox/`)
+22 -19
View File
@@ -14,28 +14,31 @@ DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent
│ │ │ │
/api/langgraph/* │ │ /api/* (other) /api/langgraph/* │ │ /api/* (other)
▼ ▼ ▼ ▼
┌────────────────────┐ ┌────────────────────────┐ ┌──────────────────────────────────────────────┐
LangGraph Server Gateway API (8001) │ Gateway API (8001)
(Port 2024) │ │ FastAPI REST FastAPI REST + LangGraph-compatible runtime
│ │
┌────────────────┐ │ │ Models, MCP, Skills, Models, MCP, Skills, Memory, Uploads,
│ Lead Agent │ │ │ Memory, Uploads, Artifacts, Threads, Runs, Streaming
┌──────────┐ │ │ │ Artifacts
│ │Middleware│ │ │ └────────────────────────┘ ┌────────────────┐ │
│ │ │ Chain │ │ │ │ Lead Agent │
│ │ ────────── │ │ │ │ ──────────
│ │ ┌──────────┐ │ │ │ │Middleware│ │
│ │ │ Tools │ │ │ │ │ │ Chain │ │
│ │ └──────────┘ │ │ │ │ └──────────┘ │
│ │ ┌──────────┐ │ │ │ │ ┌──────────┐ │
│ │ │Subagents │ │ │ │ │ Tools │ │
│ │ └──────────┘ │ │ │ │ └──────────┘ │
────────────────┘ │ ┌──────────┐ │
└────────────────────┘ │ │ │Subagents │ │ │
│ │ └──────────┘ │ │
│ └────────────────┘ │
└──────────────────────────────────────────────┘
``` ```
**Request Routing** (via Nginx): **Request Routing** (via Nginx):
- `/api/langgraph/*`LangGraph Server - agent interactions, threads, streaming - `/api/langgraph/*`Gateway API - LangGraph-compatible agent interactions, threads, runs, and streaming translated to native `/api/*` routers
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup - `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup
- `/` (non-API) → Frontend - Next.js web interface - `/` (non-API) → Frontend - Next.js web interface
+22 -26
View File
@@ -1,6 +1,5 @@
import asyncio import asyncio
import logging import logging
import os
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -9,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.gateway.auth_middleware import AuthMiddleware from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.config import get_gateway_config from app.gateway.config import get_gateway_config
from app.gateway.csrf_middleware import CSRFMiddleware from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins
from app.gateway.deps import langgraph_runtime from app.gateway.deps import langgraph_runtime
from app.gateway.routers import ( from app.gateway.routers import (
agents, agents,
@@ -219,7 +218,9 @@ def create_app() -> FastAPI:
Configured FastAPI application instance. Configured FastAPI application instance.
""" """
config = get_gateway_config() config = get_gateway_config()
docs_kwargs = {"docs_url": "/docs", "redoc_url": "/redoc", "openapi_url": "/openapi.json"} if config.enable_docs else {"docs_url": None, "redoc_url": None, "openapi_url": None} docs_url = "/docs" if config.enable_docs else None
redoc_url = "/redoc" if config.enable_docs else None
openapi_url = "/openapi.json" if config.enable_docs else None
app = FastAPI( app = FastAPI(
title="DeerFlow API Gateway", title="DeerFlow API Gateway",
@@ -239,12 +240,14 @@ API Gateway for DeerFlow - A LangGraph-based AI agent backend with sandbox execu
### Architecture ### Architecture
LangGraph requests are handled by nginx reverse proxy. LangGraph-compatible requests are routed through nginx to this gateway.
This gateway provides custom endpoints for models, MCP configuration, skills, and artifacts. This gateway provides runtime endpoints for agent runs plus custom endpoints for models, MCP configuration, skills, and artifacts.
""", """,
version="0.1.0", version="0.1.0",
lifespan=lifespan, lifespan=lifespan,
**docs_kwargs, docs_url=docs_url,
redoc_url=redoc_url,
openapi_url=openapi_url,
openapi_tags=[ openapi_tags=[
{ {
"name": "models", "name": "models",
@@ -307,25 +310,18 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# CSRF: Double Submit Cookie pattern for state-changing requests # CSRF: Double Submit Cookie pattern for state-changing requests
app.add_middleware(CSRFMiddleware) app.add_middleware(CSRFMiddleware)
# CORS: when GATEWAY_CORS_ORIGINS is set (dev without nginx), add CORS middleware. # CORS: the unified nginx endpoint is same-origin by default. Split-origin
# In production, nginx handles CORS and no middleware is needed. # browser clients must opt in with this explicit Gateway allowlist so CORS
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "") # and CSRF origin checks share the same source of truth.
if cors_origins_env: cors_origins = sorted(get_configured_cors_origins())
cors_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins:
# Validate: wildcard origin with credentials is a security misconfiguration app.add_middleware(
for origin in cors_origins: CORSMiddleware,
if origin == "*": allow_origins=cors_origins,
logger.error("GATEWAY_CORS_ORIGINS contains wildcard '*' with allow_credentials=True. This is a security misconfiguration — browsers will reject the response. Use explicit scheme://host:port origins instead.") allow_credentials=True,
cors_origins = [o for o in cors_origins if o != "*"] allow_methods=["*"],
break allow_headers=["*"],
if cors_origins: )
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers # Include routers
# Models API is mounted at /api/models # Models API is mounted at /api/models
@@ -374,7 +370,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(runs.router) app.include_router(runs.router)
@app.get("/health", tags=["health"]) @app.get("/health", tags=["health"])
async def health_check() -> dict: async def health_check() -> dict[str, str]:
"""Health check endpoint. """Health check endpoint.
Returns: Returns:
-3
View File
@@ -8,7 +8,6 @@ class GatewayConfig(BaseModel):
host: str = Field(default="0.0.0.0", description="Host to bind the gateway server") host: str = Field(default="0.0.0.0", description="Host to bind the gateway server")
port: int = Field(default=8001, description="Port to bind the gateway server") port: int = Field(default=8001, description="Port to bind the gateway server")
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"], description="Allowed CORS origins")
enable_docs: bool = Field(default=True, description="Enable Swagger/ReDoc/OpenAPI endpoints") enable_docs: bool = Field(default=True, description="Enable Swagger/ReDoc/OpenAPI endpoints")
@@ -19,11 +18,9 @@ def get_gateway_config() -> GatewayConfig:
"""Get gateway config, loading from environment if available.""" """Get gateway config, loading from environment if available."""
global _gateway_config global _gateway_config
if _gateway_config is None: if _gateway_config is None:
cors_origins_str = os.getenv("CORS_ORIGINS", "http://localhost:3000")
_gateway_config = GatewayConfig( _gateway_config = GatewayConfig(
host=os.getenv("GATEWAY_HOST", "0.0.0.0"), host=os.getenv("GATEWAY_HOST", "0.0.0.0"),
port=int(os.getenv("GATEWAY_PORT", "8001")), port=int(os.getenv("GATEWAY_PORT", "8001")),
cors_origins=cors_origins_str.split(","),
enable_docs=os.getenv("GATEWAY_ENABLE_DOCS", "true").lower() == "true", enable_docs=os.getenv("GATEWAY_ENABLE_DOCS", "true").lower() == "true",
) )
return _gateway_config return _gateway_config
+7 -2
View File
@@ -6,7 +6,7 @@ State-changing operations require CSRF protection.
import os import os
import secrets import secrets
from collections.abc import Callable from collections.abc import Awaitable, Callable
from urllib.parse import urlsplit from urllib.parse import urlsplit
from fastapi import Request, Response from fastapi import Request, Response
@@ -106,6 +106,11 @@ def _configured_cors_origins() -> set[str]:
return origins return origins
def get_configured_cors_origins() -> set[str]:
"""Return normalized explicit browser origins from GATEWAY_CORS_ORIGINS."""
return _configured_cors_origins()
def _first_header_value(value: str | None) -> str | None: def _first_header_value(value: str | None) -> str | None:
"""Return the first value from a comma-separated proxy header.""" """Return the first value from a comma-separated proxy header."""
if not value: if not value:
@@ -172,7 +177,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp) -> None: def __init__(self, app: ASGIApp) -> None:
super().__init__(app) super().__init__(app)
async def dispatch(self, request: Request, call_next: Callable) -> Response: async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
_is_auth = is_auth_endpoint(request) _is_auth = is_auth_endpoint(request)
if should_check_csrf(request) and _is_auth and not is_allowed_auth_origin(request): if should_check_csrf(request) and _is_auth and not is_allowed_auth_origin(request):
+19
View File
@@ -19,6 +19,7 @@ from langchain_core.messages import HumanMessage
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
from app.gateway.utils import sanitize_log_param from app.gateway.utils import sanitize_log_param
from deerflow.config.app_config import get_app_config
from deerflow.runtime import ( from deerflow.runtime import (
END_SENTINEL, END_SENTINEL,
HEARTBEAT_SENTINEL, HEARTBEAT_SENTINEL,
@@ -267,6 +268,23 @@ 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_
body_context = getattr(body, "context", None) or {}
model_name = body_context.get("model_name")
# Coerce non-string model_name values to str before truncation.
if model_name is not None and not isinstance(model_name, str):
model_name = str(model_name)
# Validate model against the allowlist when a model_name is provided.
if model_name:
app_config = get_app_config()
resolved = app_config.get_model_config(model_name)
if resolved is None:
raise HTTPException(
status_code=400,
detail=f"Model {model_name!r} is not in the configured model allowlist",
)
try: try:
record = await run_mgr.create_or_reject( record = await run_mgr.create_or_reject(
thread_id, thread_id,
@@ -275,6 +293,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,
model_name=model_name,
) )
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
+12 -18
View File
@@ -6,16 +6,16 @@ This document provides a complete reference for the DeerFlow backend APIs.
DeerFlow backend exposes two sets of APIs: DeerFlow backend exposes two sets of APIs:
1. **LangGraph API** - Agent interactions, threads, and streaming (`/api/langgraph/*`) 1. **LangGraph-compatible API** - Agent interactions, threads, and streaming (`/api/langgraph/*`)
2. **Gateway API** - Models, MCP, skills, uploads, and artifacts (`/api/*`) 2. **Gateway API** - Models, MCP, skills, uploads, and artifacts (`/api/*`)
All APIs are accessed through the Nginx reverse proxy at port 2026. All APIs are accessed through the Nginx reverse proxy at port 2026.
## LangGraph API ## LangGraph-compatible API
Base URL: `/api/langgraph` Base URL: `/api/langgraph`
The LangGraph API is provided by the LangGraph server and follows the LangGraph SDK conventions. The public LangGraph-compatible API follows LangGraph SDK conventions. In the unified nginx deployment, Gateway owns `/api/langgraph/*` and translates those paths to its native `/api/*` run, thread, and streaming routers.
### Threads ### Threads
@@ -104,17 +104,11 @@ Content-Type: application/json
**Recursion Limit:** **Recursion Limit:**
`config.recursion_limit` caps the number of graph steps LangGraph will execute `config.recursion_limit` caps the number of graph steps LangGraph will execute
in a single run. The `/api/langgraph/*` endpoints go straight to the LangGraph in a single run. The unified Gateway path defaults to `100` in
server and therefore inherit LangGraph's native default of **25**, which is `build_run_config` (see `backend/app/gateway/services.py`), which is a safer
too low for plan-mode or subagent-heavy runs — the agent typically errors out starting point for plan-mode or subagent-heavy runs. Clients can still set
with `GraphRecursionError` after the first round of subagent results comes `recursion_limit` explicitly in the request body; increase it if you run deeply
back, before the lead agent can synthesize the final answer. nested subagent graphs.
DeerFlow's own Gateway and IM-channel paths mitigate this by defaulting to
`100` in `build_run_config` (see `backend/app/gateway/services.py`), but
clients calling the LangGraph API directly must set `recursion_limit`
explicitly in the request body. `100` matches the Gateway default and is a
safe starting point; increase it if you run deeply nested subagent graphs.
**Configurable Options:** **Configurable Options:**
- `model_name` (string): Override the default model - `model_name` (string): Override the default model
@@ -649,7 +643,7 @@ curl -X POST http://localhost:2026/api/langgraph/threads/abc123/runs \
}' }'
``` ```
> The `/api/langgraph/*` endpoints bypass DeerFlow's Gateway and inherit > The unified Gateway path defaults `config.recursion_limit` to 100 for
> LangGraph's native `recursion_limit` default of 25, which is too low for > plan-mode and subagent-heavy runs. Clients may still set
> plan-mode or subagent runs. Set `config.recursion_limit` explicitly — see > `config.recursion_limit` explicitly — see the [Create Run](#create-run)
> the [Create Run](#create-run) section for details. > section for details.
+10 -10
View File
@@ -14,8 +14,8 @@ This document provides a comprehensive overview of the DeerFlow backend architec
│ Nginx (Port 2026) │ │ Nginx (Port 2026) │
│ Unified Reverse Proxy Entry Point │ │ Unified Reverse Proxy Entry Point │
│ ┌────────────────────────────────────────────────────────────────────┐ │ │ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ /api/langgraph/* → LangGraph Server (2024) │ │ │ │ /api/langgraph/* → Gateway LangGraph-compatible runtime (8001) │ │
│ │ /api/* → Gateway API (8001) │ │ │ │ /api/* → Gateway REST APIs (8001) │ │
│ │ /* → Frontend (3000) │ │ │ │ /* → Frontend (3000) │ │
│ └────────────────────────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────┬────────────────────────────────────────┘ └─────────────────────────────────┬────────────────────────────────────────┘
@@ -24,8 +24,8 @@ This document provides a comprehensive overview of the DeerFlow backend architec
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
LangGraph Server │ │ Gateway API │ │ Frontend │ Embedded Runtime │ │ Gateway API │ │ Frontend │
(Port 2024) │ │ (Port 8001) │ │ (Port 3000) │ (inside Gateway) │ │ (Port 8001) │ │ (Port 3000) │
│ │ │ │ │ │ │ │ │ │ │ │
│ - Agent Runtime │ │ - Models API │ │ - Next.js App │ │ - Agent Runtime │ │ - Models API │ │ - Next.js App │
│ - Thread Mgmt │ │ - MCP Config │ │ - React UI │ │ - Thread Mgmt │ │ - MCP Config │ │ - React UI │
@@ -52,9 +52,9 @@ This document provides a comprehensive overview of the DeerFlow backend architec
## Component Details ## Component Details
### LangGraph Server ### Embedded LangGraph Runtime
The LangGraph server is the core agent runtime, built on LangGraph for robust multi-agent workflow orchestration. The LangGraph-compatible runtime runs inside the Gateway process and is built on LangGraph for robust multi-agent workflow orchestration.
**Entry Point**: `packages/harness/deerflow/agents/lead_agent/agent.py:make_lead_agent` **Entry Point**: `packages/harness/deerflow/agents/lead_agent/agent.py:make_lead_agent`
@@ -78,7 +78,7 @@ The LangGraph server is the core agent runtime, built on LangGraph for robust mu
### Gateway API ### Gateway API
FastAPI application providing REST endpoints for non-agent operations. FastAPI application providing REST endpoints plus the public LangGraph-compatible `/api/langgraph/*` runtime routes.
**Entry Point**: `app/gateway/app.py` **Entry Point**: `app/gateway/app.py`
@@ -353,10 +353,10 @@ SKILL.md Format:
POST /api/langgraph/threads/{thread_id}/runs POST /api/langgraph/threads/{thread_id}/runs
{"input": {"messages": [{"role": "user", "content": "Hello"}]}} {"input": {"messages": [{"role": "user", "content": "Hello"}]}}
2. Nginx → LangGraph Server (2024) 2. Nginx → Gateway API (8001)
Proxied to LangGraph server Routes `/api/langgraph/*` to the Gateway's LangGraph-compatible runtime
3. LangGraph Server 3. Embedded LangGraph runtime
a. Load/create thread state a. Load/create thread state
b. Execute middleware chain: b. Execute middleware chain:
- ThreadDataMiddleware: Set up paths - ThreadDataMiddleware: Set up paths
@@ -36,42 +36,73 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
@staticmethod @staticmethod
def _message_tool_calls(msg) -> list[dict]: def _message_tool_calls(msg) -> list[dict]:
"""Return normalized tool calls from structured fields or raw provider payloads.""" """Return normalized tool calls from structured fields or raw provider payloads.
LangChain stores malformed provider function calls in ``invalid_tool_calls``.
They do not execute, but provider adapters may still serialize enough of
the call id/name back into the next request that strict OpenAI-compatible
validators expect a matching ToolMessage. Treat them as dangling calls so
the next model request stays well-formed and the model sees a recoverable
tool error instead of another provider 400.
"""
normalized: list[dict] = []
tool_calls = getattr(msg, "tool_calls", None) or [] tool_calls = getattr(msg, "tool_calls", None) or []
if tool_calls: normalized.extend(list(tool_calls))
return list(tool_calls)
raw_tool_calls = (getattr(msg, "additional_kwargs", None) or {}).get("tool_calls") or [] raw_tool_calls = (getattr(msg, "additional_kwargs", None) or {}).get("tool_calls") or []
normalized: list[dict] = [] if not tool_calls:
for raw_tc in raw_tool_calls: for raw_tc in raw_tool_calls:
if not isinstance(raw_tc, dict): 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 {},
}
)
for invalid_tc in getattr(msg, "invalid_tool_calls", None) or []:
if not isinstance(invalid_tc, dict):
continue 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( normalized.append(
{ {
"id": raw_tc.get("id"), "id": invalid_tc.get("id"),
"name": name or "unknown", "name": invalid_tc.get("name") or "unknown",
"args": args if isinstance(args, dict) else {}, "args": {},
"invalid": True,
"error": invalid_tc.get("error"),
} }
) )
return normalized return normalized
@staticmethod
def _synthetic_tool_message_content(tool_call: dict) -> str:
if tool_call.get("invalid"):
error = tool_call.get("error")
if isinstance(error, str) and error:
return f"[Tool call could not be executed because its arguments were invalid: {error}]"
return "[Tool call could not be executed because its arguments were invalid.]"
return "[Tool call was interrupted and did not return a result.]"
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.
@@ -114,7 +145,7 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
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(
ToolMessage( ToolMessage(
content="[Tool call was interrupted and did not return a result.]", content=self._synthetic_tool_message_content(tc),
tool_call_id=tc_id, tool_call_id=tc_id,
name=tc.get("name", "unknown"), name=tc.get("name", "unknown"),
status="error", status="error",
+2 -43
View File
@@ -1,11 +1,6 @@
"""Load MCP tools using langchain-mcp-adapters.""" """Load MCP tools using langchain-mcp-adapters."""
import asyncio
import atexit
import concurrent.futures
import logging import logging
from collections.abc import Callable
from typing import Any
from langchain_core.tools import BaseTool from langchain_core.tools import BaseTool
@@ -13,46 +8,10 @@ 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 from deerflow.reflection import resolve_variable
from deerflow.tools.sync import make_sync_tool_wrapper
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Global thread pool for sync tool invocation in async environments
_SYNC_TOOL_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="mcp-sync-tool")
# Register shutdown hook for the global executor
atexit.register(lambda: _SYNC_TOOL_EXECUTOR.shutdown(wait=False))
def _make_sync_tool_wrapper(coro: Callable[..., Any], tool_name: str) -> Callable[..., Any]:
"""Build a synchronous wrapper for an asynchronous tool coroutine.
Args:
coro: The tool's asynchronous coroutine.
tool_name: Name of the tool (for logging).
Returns:
A synchronous function that correctly handles nested event loops.
"""
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
try:
if loop is not None and loop.is_running():
# Use global executor to avoid nested loop issues and improve performance
future = _SYNC_TOOL_EXECUTOR.submit(asyncio.run, coro(*args, **kwargs))
return future.result()
else:
return asyncio.run(coro(*args, **kwargs))
except Exception as e:
logger.error(f"Error invoking MCP tool '{tool_name}' via sync wrapper: {e}", exc_info=True)
raise
return sync_wrapper
async def get_mcp_tools() -> list[BaseTool]: async def get_mcp_tools() -> list[BaseTool]:
"""Get all tools from enabled MCP servers. """Get all tools from enabled MCP servers.
@@ -126,7 +85,7 @@ async def get_mcp_tools() -> list[BaseTool]:
# Patch tools to support sync invocation, as deerflow client streams synchronously # Patch tools to support sync invocation, as deerflow client streams synchronously
for tool in tools: for tool in tools:
if getattr(tool, "func", None) is None and getattr(tool, "coroutine", None) is not None: if getattr(tool, "func", None) is None and getattr(tool, "coroutine", None) is not None:
tool.func = _make_sync_tool_wrapper(tool.coroutine, tool.name) tool.func = make_sync_tool_wrapper(tool.coroutine, tool.name)
return tools return tools
@@ -23,6 +23,18 @@ class RunRepository(RunStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory self._sf = session_factory
@staticmethod
def _normalize_model_name(model_name: str | None) -> str | None:
"""Normalize model_name for storage: strip whitespace, truncate to 128 chars."""
if model_name is None:
return None
if not isinstance(model_name, str):
model_name = str(model_name)
normalized = model_name.strip()
if len(normalized) > 128:
normalized = normalized[:128]
return normalized
@staticmethod @staticmethod
def _safe_json(obj: Any) -> Any: def _safe_json(obj: Any) -> Any:
"""Ensure obj is JSON-serializable. Falls back to model_dump() or str().""" """Ensure obj is JSON-serializable. Falls back to model_dump() or str()."""
@@ -70,6 +82,7 @@ class RunRepository(RunStore):
thread_id, thread_id,
assistant_id=None, assistant_id=None,
user_id: str | None | _AutoSentinel = AUTO, user_id: str | None | _AutoSentinel = AUTO,
model_name: str | None = None,
status="pending", status="pending",
multitask_strategy="reject", multitask_strategy="reject",
metadata=None, metadata=None,
@@ -85,6 +98,7 @@ class RunRepository(RunStore):
thread_id=thread_id, thread_id=thread_id,
assistant_id=assistant_id, assistant_id=assistant_id,
user_id=resolved_user_id, user_id=resolved_user_id,
model_name=self._normalize_model_name(model_name),
status=status, status=status,
multitask_strategy=multitask_strategy, multitask_strategy=multitask_strategy,
metadata_json=self._safe_json(metadata) or {}, metadata_json=self._safe_json(metadata) or {},
@@ -20,12 +20,13 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time import time
from collections.abc import Mapping
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
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 langchain_core.messages import AIMessage, AnyMessage, BaseMessage, HumanMessage, ToolMessage
from langgraph.types import Command from langgraph.types import Command
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -71,6 +72,7 @@ class RunJournal(BaseCallbackHandler):
# Dedup: LangChain may fire on_llm_end multiple times for the same run_id # Dedup: LangChain may fire on_llm_end multiple times for the same run_id
self._counted_llm_run_ids: set[str] = set() self._counted_llm_run_ids: set[str] = set()
self._counted_external_source_ids: set[str] = set() self._counted_external_source_ids: set[str] = set()
self._counted_message_llm_run_ids: set[str] = set()
# Convenience fields # Convenience fields
self._last_ai_msg: str | None = None self._last_ai_msg: str | None = None
@@ -86,6 +88,50 @@ class RunJournal(BaseCallbackHandler):
# -- Lifecycle callbacks -- # -- Lifecycle callbacks --
@staticmethod
def _message_text(message: BaseMessage) -> str:
"""Extract displayable text from a message's mixed content shape."""
content = getattr(message, "content", None)
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, Mapping):
text = block.get("text")
if isinstance(text, str):
parts.append(text)
else:
nested = block.get("content")
if isinstance(nested, str):
parts.append(nested)
return "".join(parts)
if isinstance(content, Mapping):
for key in ("text", "content"):
value = content.get(key)
if isinstance(value, str):
return value
text = getattr(message, "text", None)
if isinstance(text, str):
return text
return ""
def _record_message_summary(self, message: BaseMessage, *, caller: str | None = None) -> None:
"""Update run-level convenience fields for persisted run rows."""
self._msg_count += 1
# ``last_ai_message`` should represent the lead agent's user-facing
# answer. Middleware/subagent model calls and empty tool-call-only
# AI messages must not overwrite the last useful assistant text.
is_ai_message = isinstance(message, AIMessage) or getattr(message, "type", None) == "ai"
if is_ai_message and (caller is None or caller == "lead_agent"):
text = self._message_text(message).strip()
if text:
self._last_ai_msg = text[:2000]
def on_chain_start( def on_chain_start(
self, self,
serialized: dict[str, Any], serialized: dict[str, Any],
@@ -164,6 +210,7 @@ class RunJournal(BaseCallbackHandler):
content=m.model_dump(), content=m.model_dump(),
metadata={"caller": caller}, metadata={"caller": caller},
) )
self._record_message_summary(m, caller=caller)
break break
if self._first_human_msg: if self._first_human_msg:
break break
@@ -222,6 +269,8 @@ class RunJournal(BaseCallbackHandler):
"llm_call_index": call_index, "llm_call_index": call_index,
}, },
) )
if rid not in self._counted_message_llm_run_ids:
self._record_message_summary(message, caller=caller)
# Token accumulation (dedup by langchain run_id to avoid double-counting # Token accumulation (dedup by langchain run_id to avoid double-counting
# when the callback fires more than once for the same response) # when the callback fires more than once for the same response)
@@ -245,6 +294,9 @@ class RunJournal(BaseCallbackHandler):
else: else:
self._lead_agent_tokens += total_tk self._lead_agent_tokens += total_tk
if messages:
self._counted_message_llm_run_ids.add(str(run_id))
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))
@@ -260,12 +312,14 @@ class RunJournal(BaseCallbackHandler):
if isinstance(output, ToolMessage): if isinstance(output, ToolMessage):
msg = cast(ToolMessage, output) msg = cast(ToolMessage, output)
self._put(event_type="llm.tool.result", category="message", content=msg.model_dump()) self._put(event_type="llm.tool.result", category="message", content=msg.model_dump())
self._record_message_summary(msg)
elif isinstance(output, Command): elif isinstance(output, Command):
cmd = cast(Command, output) cmd = cast(Command, output)
messages = cmd.update.get("messages", []) messages = cmd.update.get("messages", [])
for message in messages: for message in messages:
if isinstance(message, BaseMessage): if isinstance(message, BaseMessage):
self._put(event_type="llm.tool.result", category="message", content=message.model_dump()) self._put(event_type="llm.tool.result", category="message", content=message.model_dump())
self._record_message_summary(message)
else: else:
logger.warning(f"on_tool_end {run_id}: command update message is not BaseMessage: {type(message)}") logger.warning(f"on_tool_end {run_id}: command update message is not BaseMessage: {type(message)}")
else: else:
@@ -36,6 +36,7 @@ class RunRecord:
abort_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False) abort_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
abort_action: str = "interrupt" abort_action: str = "interrupt"
error: str | None = None error: str | None = None
model_name: str | None = None
class RunManager: class RunManager:
@@ -65,6 +66,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,
model_name=record.model_name,
) )
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)
@@ -137,6 +139,18 @@ class RunManager:
logger.warning("Failed to persist status update for run %s", run_id, exc_info=True) logger.warning("Failed to persist status update for run %s", run_id, exc_info=True)
logger.info("Run %s -> %s", run_id, status.value) logger.info("Run %s -> %s", run_id, status.value)
async def update_model_name(self, run_id: str, model_name: str | None) -> None:
"""Update the model name for a run."""
async with self._lock:
record = self._runs.get(run_id)
if record is None:
logger.warning("update_model_name called for unknown run %s", run_id)
return
record.model_name = model_name
record.updated_at = _now_iso()
await self._persist_to_store(record)
logger.info("Run %s model_name=%s", run_id, model_name)
async def cancel(self, run_id: str, *, action: str = "interrupt") -> bool: async def cancel(self, run_id: str, *, action: str = "interrupt") -> bool:
"""Request cancellation of a run. """Request cancellation of a run.
@@ -171,6 +185,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",
model_name: 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.
@@ -221,6 +236,7 @@ class RunManager:
kwargs=kwargs or {}, kwargs=kwargs or {},
created_at=now, created_at=now,
updated_at=now, updated_at=now,
model_name=model_name,
) )
self._runs[run_id] = record self._runs[run_id] = record
@@ -23,6 +23,7 @@ class RunStore(abc.ABC):
thread_id: str, thread_id: str,
assistant_id: str | None = None, assistant_id: str | None = None,
user_id: str | None = None, user_id: str | None = None,
model_name: str | None = None,
status: str = "pending", status: str = "pending",
multitask_strategy: str = "reject", multitask_strategy: str = "reject",
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
@@ -22,6 +22,7 @@ class MemoryRunStore(RunStore):
thread_id, thread_id,
assistant_id=None, assistant_id=None,
user_id=None, user_id=None,
model_name=None,
status="pending", status="pending",
multitask_strategy="reject", multitask_strategy="reject",
metadata=None, metadata=None,
@@ -35,6 +36,7 @@ class MemoryRunStore(RunStore):
"thread_id": thread_id, "thread_id": thread_id,
"assistant_id": assistant_id, "assistant_id": assistant_id,
"user_id": user_id, "user_id": user_id,
"model_name": model_name,
"status": status, "status": status,
"multitask_strategy": multitask_strategy, "multitask_strategy": multitask_strategy,
"metadata": metadata or {}, "metadata": metadata or {},
@@ -230,6 +230,17 @@ async def run_agent(
else: else:
agent = agent_factory(config=runnable_config) agent = agent_factory(config=runnable_config)
# Capture the effective (resolved) model name from the agent's metadata.
# _resolve_model_name in agent.py may return the default model if the
# requested name is not in the allowlist — this update ensures the
# persisted model_name reflects the actual model used.
if record.model_name is not None:
resolved = getattr(agent, "metadata", {}) or {}
if isinstance(resolved, dict):
effective = resolved.get("model_name")
if effective and effective != record.model_name:
await run_manager.update_model_name(record.run_id, effective)
# 4. Attach checkpointer and store # 4. Attach checkpointer and store
if checkpointer is not None: if checkpointer is not None:
agent.checkpointer = checkpointer agent.checkpointer = checkpointer
@@ -26,7 +26,7 @@ class SubagentConfig:
name: str name: str
description: str description: str
system_prompt: str system_prompt: str | None = None
tools: list[str] | None = None tools: list[str] | None = None
disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"]) disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"])
skills: list[str] | None = None skills: list[str] | None = None
@@ -286,11 +286,13 @@ class SubagentExecutor:
# Reuse shared middleware composition with lead agent. # Reuse shared middleware composition with lead agent.
middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name=self.model_name, lazy_init=True) middlewares = build_subagent_runtime_middlewares(app_config=app_config, model_name=self.model_name, lazy_init=True)
# system_prompt is included in initial state messages (see _build_initial_state)
# to avoid multiple SystemMessages which some LLM APIs don't support.
return create_agent( return create_agent(
model=model, model=model,
tools=tools if tools is not None else self.tools, tools=tools if tools is not None else self.tools,
middleware=middlewares, middleware=middlewares,
system_prompt=self.config.system_prompt, system_prompt=None,
state_schema=ThreadState, state_schema=ThreadState,
) )
@@ -365,14 +367,25 @@ class SubagentExecutor:
Returns: Returns:
Initial state dictionary and tools filtered by loaded skill metadata. Initial state dictionary and tools filtered by loaded skill metadata.
""" """
# Load skills as conversation items (Codex pattern) # Load skills as conversation items (Codex pattern)
skills = await self._load_skills() skills = await self._load_skills()
filtered_tools = self._apply_skill_allowed_tools(skills) filtered_tools = self._apply_skill_allowed_tools(skills)
skill_messages = await self._load_skill_messages(skills) skill_messages = await self._load_skill_messages(skills)
# Combine system_prompt and skills into a single SystemMessage.
# Some LLM APIs reject multiple SystemMessages with
# "System message must be at the beginning."
system_parts: list[str] = []
if self.config.system_prompt:
system_parts.append(self.config.system_prompt)
for skill_msg in skill_messages:
system_parts.append(skill_msg.content)
messages: list[Any] = [] messages: list[Any] = []
# Skill content injected as developer/system messages before the task if system_parts:
messages.extend(skill_messages) messages.append(SystemMessage(content="\n\n".join(system_parts)))
# Then the actual task # Then the actual task
messages.append(HumanMessage(content=task)) messages.append(HumanMessage(content=task))
@@ -10,11 +10,11 @@ from weakref import WeakValueDictionary
from langchain.tools import tool from langchain.tools import tool
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.mcp.tools import _make_sync_tool_wrapper
from deerflow.skills.security_scanner import scan_skill_content from deerflow.skills.security_scanner import scan_skill_content
from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.storage.skill_storage import SkillStorage from deerflow.skills.storage.skill_storage import SkillStorage
from deerflow.skills.types import SKILL_MD_FILE from deerflow.skills.types import SKILL_MD_FILE
from deerflow.tools.sync import make_sync_tool_wrapper
from deerflow.tools.types import Runtime from deerflow.tools.types import Runtime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -235,4 +235,4 @@ async def skill_manage_tool(
) )
skill_manage_tool.func = _make_sync_tool_wrapper(_skill_manage_impl, "skill_manage") skill_manage_tool.func = make_sync_tool_wrapper(_skill_manage_impl, "skill_manage")
@@ -0,0 +1,36 @@
"""Utilities for invoking async tools from synchronous agent paths."""
import asyncio
import atexit
import concurrent.futures
import logging
from collections.abc import Callable
from typing import Any
logger = logging.getLogger(__name__)
# Shared thread pool for sync tool invocation in async environments.
_SYNC_TOOL_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="tool-sync")
atexit.register(lambda: _SYNC_TOOL_EXECUTOR.shutdown(wait=False))
def make_sync_tool_wrapper(coro: Callable[..., Any], tool_name: str) -> Callable[..., Any]:
"""Build a synchronous wrapper for an asynchronous tool coroutine."""
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
try:
if loop is not None and loop.is_running():
future = _SYNC_TOOL_EXECUTOR.submit(asyncio.run, coro(*args, **kwargs))
return future.result()
return asyncio.run(coro(*args, **kwargs))
except Exception as e:
logger.error("Error invoking tool %r via sync wrapper: %s", tool_name, e, exc_info=True)
raise
return sync_wrapper
@@ -8,6 +8,7 @@ from deerflow.reflection import resolve_variable
from deerflow.sandbox.security import is_host_bash_allowed from deerflow.sandbox.security import is_host_bash_allowed
from deerflow.tools.builtins import ask_clarification_tool, present_file_tool, task_tool, view_image_tool from deerflow.tools.builtins import ask_clarification_tool, present_file_tool, task_tool, view_image_tool
from deerflow.tools.builtins.tool_search import reset_deferred_registry from deerflow.tools.builtins.tool_search import reset_deferred_registry
from deerflow.tools.sync import make_sync_tool_wrapper
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,6 +34,13 @@ def _is_host_bash_tool(tool: object) -> bool:
return False return False
def _ensure_sync_invocable_tool(tool: BaseTool) -> BaseTool:
"""Attach a sync wrapper to async-only tools used by sync agent callers."""
if getattr(tool, "func", None) is None and getattr(tool, "coroutine", None) is not None:
tool.func = make_sync_tool_wrapper(tool.coroutine, tool.name)
return tool
def get_available_tools( def get_available_tools(
groups: list[str] | None = None, groups: list[str] | None = None,
include_mcp: bool = True, include_mcp: bool = True,
@@ -77,7 +85,7 @@ def get_available_tools(
cfg.use, cfg.use,
) )
loaded_tools = [t for _, t in loaded_tools_raw] loaded_tools = [_ensure_sync_invocable_tool(t) for _, t in loaded_tools_raw]
# Conditionally add tools based on config # Conditionally add tools based on config
builtin_tools = BUILTIN_TOOLS.copy() builtin_tools = BUILTIN_TOOLS.copy()
@@ -14,6 +14,10 @@ def _ai_with_tool_calls(tool_calls):
return AIMessage(content="", tool_calls=tool_calls) return AIMessage(content="", tool_calls=tool_calls)
def _ai_with_invalid_tool_calls(invalid_tool_calls):
return AIMessage(content="", tool_calls=[], invalid_tool_calls=invalid_tool_calls)
def _tool_msg(tool_call_id, name="test_tool"): def _tool_msg(tool_call_id, name="test_tool"):
return ToolMessage(content="result", tool_call_id=tool_call_id, name=name) return ToolMessage(content="result", tool_call_id=tool_call_id, name=name)
@@ -22,6 +26,16 @@ def _tc(name="bash", tc_id="call_1"):
return {"name": name, "id": tc_id, "args": {}} return {"name": name, "id": tc_id, "args": {}}
def _invalid_tc(name="write_file", tc_id="write_file:36", error="Failed to parse tool arguments: malformed JSON"):
return {
"type": "invalid_tool_call",
"name": name,
"id": tc_id,
"args": '{"description":"write report","path":"/mnt/user-data/outputs/report.md","content":"bad {"json"}"}',
"error": error,
}
class TestBuildPatchedMessagesNoPatch: class TestBuildPatchedMessagesNoPatch:
def test_empty_messages(self): def test_empty_messages(self):
mw = DanglingToolCallMiddleware() mw = DanglingToolCallMiddleware()
@@ -144,6 +158,42 @@ class TestBuildPatchedMessagesPatching:
assert patched[1].name == "bash" assert patched[1].name == "bash"
assert patched[1].status == "error" assert patched[1].status == "error"
def test_invalid_tool_call_is_patched(self):
mw = DanglingToolCallMiddleware()
msgs = [_ai_with_invalid_tool_calls([_invalid_tc()])]
patched = mw._build_patched_messages(msgs)
assert patched is not None
assert len(patched) == 2
assert isinstance(patched[1], ToolMessage)
assert patched[1].tool_call_id == "write_file:36"
assert patched[1].name == "write_file"
assert patched[1].status == "error"
assert "arguments were invalid" in patched[1].content
assert "Failed to parse tool arguments" in patched[1].content
def test_valid_and_invalid_tool_calls_are_both_patched(self):
mw = DanglingToolCallMiddleware()
msgs = [
AIMessage(
content="",
tool_calls=[_tc("bash", "call_1")],
invalid_tool_calls=[_invalid_tc()],
)
]
patched = mw._build_patched_messages(msgs)
assert patched is not None
tool_msgs = [m for m in patched if isinstance(m, ToolMessage)]
assert len(tool_msgs) == 2
assert {tm.tool_call_id for tm in tool_msgs} == {"call_1", "write_file:36"}
def test_invalid_tool_call_already_responded_is_not_patched(self):
mw = DanglingToolCallMiddleware()
msgs = [
_ai_with_invalid_tool_calls([_invalid_tc()]),
_tool_msg("write_file:36", "write_file"),
]
assert mw._build_patched_messages(msgs) is None
class TestWrapModelCall: class TestWrapModelCall:
def test_no_patch_passthrough(self): def test_no_patch_passthrough(self):
+42
View File
@@ -122,3 +122,45 @@ def test_health_still_works_when_docs_disabled():
resp = client.get("/health") resp = client.get("/health")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["status"] == "healthy" assert resp.json()["status"] == "healthy"
# ---------------------------------------------------------------------------
# Runtime CORS behavior
# ---------------------------------------------------------------------------
def _make_gateway_client(cors_origins: str) -> TestClient:
with patch.dict(os.environ, {"GATEWAY_CORS_ORIGINS": cors_origins}):
_reset_gateway_config()
from app.gateway.app import create_app
return TestClient(create_app())
def test_gateway_cors_allows_configured_origin():
"""GATEWAY_CORS_ORIGINS should control actual browser CORS responses."""
client = _make_gateway_client("https://app.example")
response = client.get("/health", headers={"Origin": "https://app.example"})
assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "https://app.example"
assert response.headers["access-control-allow-credentials"] == "true"
def test_gateway_cors_rejects_unconfigured_origin():
client = _make_gateway_client("https://app.example")
response = client.get("/health", headers={"Origin": "https://evil.example"})
assert response.status_code == 200
assert "access-control-allow-origin" not in response.headers
def test_gateway_cors_normalizes_configured_default_port():
client = _make_gateway_client("https://app.example:443")
response = client.get("/health", headers={"Origin": "https://app.example"})
assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "https://app.example"
@@ -53,6 +53,29 @@ def test_nginx_routes_official_langgraph_prefix_to_gateway_api():
assert "proxy_pass http://gateway" in content or "proxy_pass http://$gateway_upstream" in content assert "proxy_pass http://gateway" in content or "proxy_pass http://$gateway_upstream" in content
def test_nginx_defers_cors_to_gateway_allowlist():
for path in ("docker/nginx/nginx.local.conf", "docker/nginx/nginx.conf"):
content = _read(path)
assert "Access-Control-Allow-Origin" not in content
assert "Access-Control-Allow-Methods" not in content
assert "Access-Control-Allow-Headers" not in content
assert "Access-Control-Allow-Credentials" not in content
assert "proxy_hide_header 'Access-Control-Allow-" not in content
assert "if ($request_method = 'OPTIONS')" not in content
def test_gateway_cors_configuration_uses_gateway_allowlist():
gateway_config = _read("backend/app/gateway/config.py")
gateway_app = _read("backend/app/gateway/app.py")
csrf_middleware = _read("backend/app/gateway/csrf_middleware.py")
assert not re.search(r"(?<!GATEWAY_)[\"']CORS_ORIGINS[\"']", gateway_config)
assert "cors_origins" not in gateway_config
assert "get_configured_cors_origins" in gateway_app
assert "GATEWAY_CORS_ORIGINS" in csrf_middleware
def test_frontend_rewrites_langgraph_prefix_to_gateway(): def test_frontend_rewrites_langgraph_prefix_to_gateway():
next_config = _read("frontend/next.config.js") next_config = _read("frontend/next.config.js")
api_client = _read("frontend/src/core/api/api-client.ts") api_client = _read("frontend/src/core/api/api-client.ts")
+8 -8
View File
@@ -5,7 +5,8 @@ import pytest
from langchain_core.tools import StructuredTool from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from deerflow.mcp.tools import _make_sync_tool_wrapper, get_mcp_tools from deerflow.mcp.tools import get_mcp_tools
from deerflow.tools.sync import make_sync_tool_wrapper
class MockArgs(BaseModel): class MockArgs(BaseModel):
@@ -51,14 +52,13 @@ def test_mcp_tool_sync_wrapper_generation():
def test_mcp_tool_sync_wrapper_in_running_loop(): def test_mcp_tool_sync_wrapper_in_running_loop():
"""Test the actual helper function from production code (Fix for Comment 1 & 3).""" """Test the shared sync wrapper from production code."""
async def mock_coro(x: int): async def mock_coro(x: int):
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
return f"async_result: {x}" return f"async_result: {x}"
# Test the real helper function exported from deerflow.mcp.tools sync_func = make_sync_tool_wrapper(mock_coro, "test_tool")
sync_func = _make_sync_tool_wrapper(mock_coro, "test_tool")
async def run_in_loop(): async def run_in_loop():
# This call should succeed due to ThreadPoolExecutor in the real helper # This call should succeed due to ThreadPoolExecutor in the real helper
@@ -70,16 +70,16 @@ def test_mcp_tool_sync_wrapper_in_running_loop():
def test_mcp_tool_sync_wrapper_exception_logging(): def test_mcp_tool_sync_wrapper_exception_logging():
"""Test the actual helper's error logging (Fix for Comment 3).""" """Test the shared sync wrapper's error logging."""
async def error_coro(): async def error_coro():
raise ValueError("Tool failure") raise ValueError("Tool failure")
sync_func = _make_sync_tool_wrapper(error_coro, "error_tool") sync_func = make_sync_tool_wrapper(error_coro, "error_tool")
with patch("deerflow.mcp.tools.logger.error") as mock_log_error: with patch("deerflow.tools.sync.logger.error") as mock_log_error:
with pytest.raises(ValueError, match="Tool failure"): with pytest.raises(ValueError, match="Tool failure"):
sync_func() sync_func()
mock_log_error.assert_called_once() mock_log_error.assert_called_once()
# Verify the tool name is in the log message # Verify the tool name is in the log message
assert "error_tool" in mock_log_error.call_args[0][0] assert mock_log_error.call_args[0][1] == "error_tool"
+93
View File
@@ -339,6 +339,99 @@ class TestConvenienceFields:
data = j.get_completion_data() data = j.get_completion_data()
assert data["first_human_message"] == "What is AI?" assert data["first_human_message"] == "What is AI?"
@pytest.mark.anyio
async def test_completion_data_counts_human_ai_and_tool_messages(self, journal_setup):
from langchain_core.messages import HumanMessage, ToolMessage
j, _ = journal_setup
j.on_chat_model_start({}, [[HumanMessage(content="Question")]], run_id=uuid4(), tags=["lead_agent"])
j.on_llm_end(_make_llm_response("Answer"), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
j.on_tool_end(ToolMessage(content="Tool result", tool_call_id="call_1", name="search"), run_id=uuid4())
data = j.get_completion_data()
assert data["message_count"] == 3
assert data["first_human_message"] == "Question"
assert data["last_ai_message"] == "Answer"
@pytest.mark.anyio
async def test_tool_call_only_ai_does_not_clear_last_ai_message(self, journal_setup):
j, _ = journal_setup
j.on_llm_end(_make_llm_response("Useful answer"), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
j.on_llm_end(
_make_llm_response("", tool_calls=[{"id": "call_1", "name": "search", "args": {}}]),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
data = j.get_completion_data()
assert data["message_count"] == 2
assert data["last_ai_message"] == "Useful answer"
@pytest.mark.anyio
async def test_last_ai_message_extracts_mixed_content_without_extra_newlines(self, journal_setup):
j, _ = journal_setup
j.on_llm_end(
_make_llm_response(
[
{"type": "text", "text": "First "},
{"type": "text", "content": "second"},
" third",
{"type": "image", "url": "ignored"},
]
),
run_id=uuid4(),
parent_run_id=None,
tags=["lead_agent"],
)
data = j.get_completion_data()
assert data["message_count"] == 1
assert data["last_ai_message"] == "First second third"
@pytest.mark.anyio
async def test_last_ai_message_extracts_mapping_content(self, journal_setup):
j, _ = journal_setup
j.on_llm_end(_make_llm_response({"content": "Nested answer"}), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
data = j.get_completion_data()
assert data["message_count"] == 1
assert data["last_ai_message"] == "Nested answer"
@pytest.mark.anyio
async def test_duplicate_llm_run_id_does_not_double_count_message_summary(self, journal_setup):
j, _ = journal_setup
run_id = uuid4()
j.on_llm_end(_make_llm_response("Answer", usage=None), run_id=run_id, parent_run_id=None, tags=["lead_agent"])
j.on_llm_end(
_make_llm_response("Answer", usage={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}),
run_id=run_id,
parent_run_id=None,
tags=["lead_agent"],
)
data = j.get_completion_data()
assert data["message_count"] == 1
assert data["last_ai_message"] == "Answer"
assert data["total_tokens"] == 15
@pytest.mark.anyio
async def test_subagent_ai_does_not_overwrite_lead_last_ai_message(self, journal_setup):
j, _ = journal_setup
j.on_llm_end(_make_llm_response("Lead answer"), run_id=uuid4(), parent_run_id=None, tags=["lead_agent"])
j.on_llm_end(_make_llm_response("Subagent detail"), run_id=uuid4(), parent_run_id=None, tags=["subagent:research"])
data = j.get_completion_data()
assert data["message_count"] == 2
assert data["last_ai_message"] == "Lead answer"
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_completion_data(self, journal_setup): async def test_get_completion_data(self, journal_setup):
j, _ = journal_setup j, _ = journal_setup
+51
View File
@@ -5,6 +5,7 @@ import re
import pytest import pytest
from deerflow.runtime import RunManager, RunStatus from deerflow.runtime import RunManager, RunStatus
from deerflow.runtime.runs.store.memory import MemoryRunStore
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
@@ -141,3 +142,53 @@ async def test_create_defaults(manager: RunManager):
assert record.kwargs == {} assert record.kwargs == {}
assert record.multitask_strategy == "reject" assert record.multitask_strategy == "reject"
assert record.assistant_id is None assert record.assistant_id is None
@pytest.mark.anyio
async def test_model_name_create_or_reject():
"""create_or_reject should accept and persist model_name."""
from deerflow.runtime.runs.schemas import DisconnectMode
store = MemoryRunStore()
mgr = RunManager(store=store)
record = await mgr.create_or_reject(
"thread-1",
assistant_id="lead_agent",
on_disconnect=DisconnectMode.cancel,
metadata={"key": "val"},
kwargs={"input": {}},
multitask_strategy="reject",
model_name="anthropic.claude-sonnet-4-20250514-v1:0",
)
assert record.model_name == "anthropic.claude-sonnet-4-20250514-v1:0"
assert record.status == RunStatus.pending
# Verify model_name was persisted to store
stored = await store.get(record.run_id)
assert stored is not None
assert stored["model_name"] == "anthropic.claude-sonnet-4-20250514-v1:0"
# Verify retrieval returns the model_name via in-memory record
fetched = mgr.get(record.run_id)
assert fetched is not None
assert fetched.model_name == "anthropic.claude-sonnet-4-20250514-v1:0"
@pytest.mark.anyio
async def test_model_name_default_is_none():
"""create_or_reject without model_name should default to None."""
from deerflow.runtime.runs.schemas import DisconnectMode
store = MemoryRunStore()
mgr = RunManager(store=store)
record = await mgr.create_or_reject(
"thread-1",
on_disconnect=DisconnectMode.cancel,
model_name=None,
)
assert record.model_name is None
stored = await store.get(record.run_id)
assert stored["model_name"] is None
+29
View File
@@ -249,3 +249,32 @@ class TestRunRepository:
rows = await repo.list_by_thread("t1", user_id=None) rows = await repo.list_by_thread("t1", user_id=None)
assert len(rows) == 2 assert len(rows) == 2
await _cleanup() await _cleanup()
@pytest.mark.anyio
async def test_model_name_persistence(self, tmp_path):
"""RunRepository should persist, normalize, and truncate model_name correctly via SQL."""
from deerflow.persistence.engine import get_session_factory, init_engine
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
repo = RunRepository(get_session_factory())
await repo.put("run-1", thread_id="thread-1", model_name="gpt-4o")
row = await repo.get("run-1")
assert row is not None
assert row["model_name"] == "gpt-4o"
long_name = "a" * 200
await repo.put("run-2", thread_id="thread-1", model_name=long_name)
row2 = await repo.get("run-2")
assert row2["model_name"] == "a" * 128
await repo.put("run-3", thread_id="thread-1", model_name=123)
row3 = await repo.get("run-3")
assert row3["model_name"] == "123"
await repo.put("run-4", thread_id="thread-1", model_name=None)
row4 = await repo.get("run-4")
assert row4["model_name"] is None
await _cleanup()
+183 -1
View File
@@ -291,7 +291,7 @@ class TestAgentConstruction:
assert captured["agent"]["model"] is model assert captured["agent"]["model"] is model
assert captured["agent"]["middleware"] is middlewares assert captured["agent"]["middleware"] is middlewares
assert captured["agent"]["tools"] == [] assert captured["agent"]["tools"] == []
assert captured["agent"]["system_prompt"] == base_config.system_prompt assert captured["agent"]["system_prompt"] is None # system_prompt is merged into initial state messages
@pytest.mark.anyio @pytest.mark.anyio
async def test_load_skill_messages_uses_explicit_app_config_for_skill_storage( async def test_load_skill_messages_uses_explicit_app_config_for_skill_storage(
@@ -331,6 +331,124 @@ class TestAgentConstruction:
assert len(messages) == 1 assert len(messages) == 1
assert "Use demo skill" in messages[0].content assert "Use demo skill" in messages[0].content
@pytest.mark.anyio
async def test_build_initial_state_consolidates_system_prompt_and_skills(
self,
classes,
base_config,
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
"""_build_initial_state merges system_prompt and skills into one SystemMessage."""
SubagentExecutor = classes["SubagentExecutor"]
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
skill_file = skill_dir / "SKILL.md"
skill_file.write_text("Skill instructions here", encoding="utf-8")
monkeypatch.setattr(
sys.modules["deerflow.skills.storage"],
"get_or_new_skill_storage",
lambda *, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: [SimpleNamespace(name="my-skill", skill_file=skill_file, allowed_tools=None)]),
)
executor = SubagentExecutor(
config=base_config,
tools=[],
thread_id="test-thread",
)
state, _filtered_tools = await executor._build_initial_state("Do the task")
messages = state["messages"]
# Should have exactly 2 messages: one combined SystemMessage + one HumanMessage
assert len(messages) == 2
from langchain_core.messages import HumanMessage, SystemMessage
assert isinstance(messages[0], SystemMessage)
assert isinstance(messages[1], HumanMessage)
# SystemMessage should contain both the system_prompt and skill content
assert base_config.system_prompt in messages[0].content
assert "Skill instructions here" in messages[0].content
# HumanMessage should be the task
assert messages[1].content == "Do the task"
@pytest.mark.anyio
async def test_build_initial_state_no_skills_only_system_prompt(
self,
classes,
base_config,
monkeypatch: pytest.MonkeyPatch,
):
"""_build_initial_state works when there are no skills."""
SubagentExecutor = classes["SubagentExecutor"]
monkeypatch.setattr(
sys.modules["deerflow.skills.storage"],
"get_or_new_skill_storage",
lambda *, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: []),
)
executor = SubagentExecutor(
config=base_config,
tools=[],
thread_id="test-thread",
)
state, _filtered_tools = await executor._build_initial_state("Do the task")
messages = state["messages"]
from langchain_core.messages import HumanMessage, SystemMessage
assert len(messages) == 2
assert isinstance(messages[0], SystemMessage)
assert base_config.system_prompt in messages[0].content
assert isinstance(messages[1], HumanMessage)
@pytest.mark.anyio
async def test_build_initial_state_no_system_prompt_with_skills(
self,
classes,
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
"""_build_initial_state works when there is no system_prompt but there are skills."""
SubagentConfig = classes["SubagentConfig"]
config = SubagentConfig(
name="test-agent",
description="Test agent",
system_prompt=None,
max_turns=10,
timeout_seconds=60,
)
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
skill_file = skill_dir / "SKILL.md"
skill_file.write_text("Skill content", encoding="utf-8")
monkeypatch.setattr(
sys.modules["deerflow.skills.storage"],
"get_or_new_skill_storage",
lambda *, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: [SimpleNamespace(name="my-skill", skill_file=skill_file, allowed_tools=None)]),
)
SubagentExecutor = classes["SubagentExecutor"]
executor = SubagentExecutor(config=config, tools=[], thread_id="test-thread")
state, _filtered_tools = await executor._build_initial_state("Do the task")
messages = state["messages"]
from langchain_core.messages import HumanMessage, SystemMessage
assert len(messages) == 2
assert isinstance(messages[0], SystemMessage)
assert "Skill content" in messages[0].content
assert isinstance(messages[1], HumanMessage)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Async Execution Path Tests # Async Execution Path Tests
@@ -514,6 +632,70 @@ class TestAsyncExecutionPath:
assert result.status == SubagentStatus.COMPLETED assert result.status == SubagentStatus.COMPLETED
assert "Task" in result.result assert "Task" in result.result
@pytest.mark.anyio
async def test_aexecute_passes_at_most_one_system_message_to_agent(
self,
classes,
base_config,
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
"""Regression: messages sent to agent.astream must contain at most one
SystemMessage and it must be the first message.
This catches any regression where system_prompt would be re-injected
via create_agent() (e.g. system_prompt not passed as None) and appear
as a second SystemMessage, which providers like vLLM and Xinference
reject with "System message must be at the beginning."
"""
from langchain_core.messages import AIMessage, SystemMessage
SubagentExecutor = classes["SubagentExecutor"]
SubagentStatus = classes["SubagentStatus"]
# Set up a skill so both system_prompt AND skill content are present,
# maximising the chance of catching a double-SystemMessage regression.
skill_dir = tmp_path / "regression-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("Skill instruction text", encoding="utf-8")
monkeypatch.setattr(
sys.modules["deerflow.skills.storage"],
"get_or_new_skill_storage",
lambda *, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: [SimpleNamespace(name="regression-skill", skill_file=skill_dir / "SKILL.md", allowed_tools=None)]),
)
captured_states: list[dict] = []
async def capturing_astream(state, **kwargs):
captured_states.append(state)
yield {"messages": [AIMessage(content="Done", id="msg-1")]}
mock_agent = MagicMock()
mock_agent.astream = capturing_astream
executor = SubagentExecutor(
config=base_config,
tools=[],
thread_id="test-thread",
)
with patch.object(executor, "_create_agent", return_value=mock_agent):
result = await executor._aexecute("Do something")
assert result.status == SubagentStatus.COMPLETED
assert len(captured_states) == 1, "astream should be called exactly once"
initial_messages = captured_states[0]["messages"]
system_messages = [m for m in initial_messages if isinstance(m, SystemMessage)]
assert len(system_messages) <= 1, f"Expected at most 1 SystemMessage but got {len(system_messages)}: {system_messages}"
if system_messages:
assert initial_messages[0] is system_messages[0], "SystemMessage must be the first message in the conversation"
# The consolidated SystemMessage must carry both the system_prompt
# and all skill content — nothing should be split across two messages.
assert base_config.system_prompt in system_messages[0].content
assert "Skill instruction text" in system_messages[0].content
class TestSkillAllowedTools: class TestSkillAllowedTools:
@pytest.mark.anyio @pytest.mark.anyio
+41 -1
View File
@@ -10,7 +10,8 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from langchain_core.tools import BaseTool, tool from langchain_core.tools import BaseTool, StructuredTool, tool
from pydantic import BaseModel, Field
from deerflow.tools.tools import get_available_tools from deerflow.tools.tools import get_available_tools
@@ -19,6 +20,10 @@ from deerflow.tools.tools import get_available_tools
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class AsyncToolArgs(BaseModel):
x: int = Field(..., description="test input")
@tool @tool
def _tool_alpha(x: str) -> str: def _tool_alpha(x: str) -> str:
"""Alpha tool.""" """Alpha tool."""
@@ -52,10 +57,45 @@ def _make_minimal_config(tools):
config.tools = tools config.tools = tools
config.models = [] config.models = []
config.tool_search.enabled = False config.tool_search.enabled = False
config.skill_evolution.enabled = False
config.sandbox = MagicMock() config.sandbox = MagicMock()
config.acp_agents = {}
return config return config
@patch("deerflow.tools.tools.get_app_config")
@patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True)
@patch("deerflow.tools.tools.reset_deferred_registry")
def test_config_loaded_async_only_tool_gets_sync_wrapper(mock_reset, mock_bash, mock_cfg):
"""Config-loaded async-only tools can still be invoked by sync clients."""
async def async_tool_impl(x: int) -> str:
return f"result: {x}"
async_tool = StructuredTool(
name="async_tool",
description="Async-only test tool.",
args_schema=AsyncToolArgs,
func=None,
coroutine=async_tool_impl,
)
tool_cfg = MagicMock()
tool_cfg.name = "async_tool"
tool_cfg.group = "test"
tool_cfg.use = "tests.fake:async_tool"
mock_cfg.return_value = _make_minimal_config([tool_cfg])
with (
patch("deerflow.tools.tools.resolve_variable", return_value=async_tool),
patch("deerflow.tools.tools.BUILTIN_TOOLS", []),
):
result = get_available_tools(include_mcp=False, app_config=mock_cfg.return_value)
assert async_tool in result
assert async_tool.func is not None
assert async_tool.invoke({"x": 42}) == "result: 42"
@patch("deerflow.tools.tools.get_app_config") @patch("deerflow.tools.tools.get_app_config")
@patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True) @patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True)
@patch("deerflow.tools.tools.reset_deferred_registry") @patch("deerflow.tools.tools.reset_deferred_registry")
+3 -3
View File
@@ -4224,11 +4224,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
+5 -15
View File
@@ -28,21 +28,11 @@ http {
set $gateway_upstream gateway:8001; set $gateway_upstream gateway:8001;
set $frontend_upstream frontend:3000; set $frontend_upstream frontend:3000;
# Hide CORS headers from upstream to prevent duplicates # Keep the unified nginx endpoint same-origin by default. When split
proxy_hide_header 'Access-Control-Allow-Origin'; # frontend/backend or port-forwarded deployments need browser CORS,
proxy_hide_header 'Access-Control-Allow-Methods'; # configure the Gateway allowlist with GATEWAY_CORS_ORIGINS so CORS and
proxy_hide_header 'Access-Control-Allow-Headers'; # CSRF origin checks stay aligned instead of approving every origin at
proxy_hide_header 'Access-Control-Allow-Credentials'; # the proxy layer.
# CORS headers for all responses (nginx handles CORS centrally)
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' '*' always;
# Handle OPTIONS requests (CORS preflight)
if ($request_method = 'OPTIONS') {
return 204;
}
# LangGraph-compatible API routes served by Gateway. # LangGraph-compatible API routes served by Gateway.
# Rewrites /api/langgraph/* to /api/* before proxying to Gateway. # Rewrites /api/langgraph/* to /api/* before proxying to Gateway.
+5 -15
View File
@@ -28,21 +28,11 @@ http {
listen [::]:2026; listen [::]:2026;
server_name _; server_name _;
# Hide CORS headers from upstream to prevent duplicates # Keep the unified nginx endpoint same-origin by default. When split
proxy_hide_header 'Access-Control-Allow-Origin'; # frontend/backend or port-forwarded deployments need browser CORS,
proxy_hide_header 'Access-Control-Allow-Methods'; # configure the Gateway allowlist with GATEWAY_CORS_ORIGINS so CORS and
proxy_hide_header 'Access-Control-Allow-Headers'; # CSRF origin checks stay aligned instead of approving every origin at
proxy_hide_header 'Access-Control-Allow-Credentials'; # the proxy layer.
# CORS headers for all responses (nginx handles CORS centrally)
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' '*' always;
# Handle OPTIONS requests (CORS preflight)
if ($request_method = 'OPTIONS') {
return 204;
}
# LangGraph-compatible API routes served by Gateway. # LangGraph-compatible API routes served by Gateway.
# Rewrites /api/langgraph/* to /api/* before proxying to Gateway. # Rewrites /api/langgraph/* to /api/* before proxying to Gateway.
+1 -1
View File
@@ -68,7 +68,7 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.26.2", "motion": "^12.26.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next": "^16.1.7", "next": "^16.2.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nextra": "^4.6.1", "nextra": "^4.6.1",
"nextra-theme-docs": "^4.6.1", "nextra-theme-docs": "^4.6.1",
+84 -70
View File
@@ -156,17 +156,17 @@ importers:
specifier: ^5.1.6 specifier: ^5.1.6
version: 5.1.6 version: 5.1.6
next: next:
specifier: ^16.1.7 specifier: ^16.2.6
version: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
nextra: nextra:
specifier: ^4.6.1 specifier: ^4.6.1
version: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 4.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
nextra-theme-docs: nextra-theme-docs:
specifier: ^4.6.1 specifier: ^4.6.1
version: 4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) version: 4.6.1(@types/react@19.2.13)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
nuxt-og-image: nuxt-og-image:
specifier: ^5.1.13 specifier: ^5.1.13
version: 5.1.13(@unhead/vue@2.1.4(vue@3.5.28(typescript@5.9.3)))(unstorage@1.17.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vue@3.5.28(typescript@5.9.3)) version: 5.1.13(@unhead/vue@2.1.4(vue@3.5.28(typescript@5.9.3)))(unstorage@1.17.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vue@3.5.28(typescript@5.9.3))
@@ -437,8 +437,8 @@ packages:
'@emnapi/core@1.8.1': '@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
'@emnapi/runtime@1.9.0': '@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==} resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@emnapi/wasi-threads@1.1.0': '@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
@@ -1018,56 +1018,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12': '@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next/env@16.1.7': '@next/env@16.2.6':
resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==} resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
'@next/eslint-plugin-next@15.5.12': '@next/eslint-plugin-next@15.5.12':
resolution: {integrity: sha512-+ZRSDFTv4aC96aMb5E41rMjysx8ApkryevnvEYZvPZO52KvkqP5rNExLUXJFr9P4s0f3oqNQR6vopCZsPWKDcQ==} resolution: {integrity: sha512-+ZRSDFTv4aC96aMb5E41rMjysx8ApkryevnvEYZvPZO52KvkqP5rNExLUXJFr9P4s0f3oqNQR6vopCZsPWKDcQ==}
'@next/swc-darwin-arm64@16.1.7': '@next/swc-darwin-arm64@16.2.6':
resolution: {integrity: sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==} resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/swc-darwin-x64@16.1.7': '@next/swc-darwin-x64@16.2.6':
resolution: {integrity: sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==} resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/swc-linux-arm64-gnu@16.1.7': '@next/swc-linux-arm64-gnu@16.2.6':
resolution: {integrity: sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==} resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@next/swc-linux-arm64-musl@16.1.7': '@next/swc-linux-arm64-musl@16.2.6':
resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==} resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@next/swc-linux-x64-gnu@16.1.7': '@next/swc-linux-x64-gnu@16.2.6':
resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==} resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@next/swc-linux-x64-musl@16.1.7': '@next/swc-linux-x64-musl@16.2.6':
resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==} resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@next/swc-win32-arm64-msvc@16.1.7': '@next/swc-win32-arm64-msvc@16.2.6':
resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==} resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/swc-win32-x64-msvc@16.1.7': '@next/swc-win32-x64-msvc@16.2.6':
resolution: {integrity: sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==} resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -1912,6 +1912,9 @@ packages:
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
'@t3-oss/env-core@0.12.0': '@t3-oss/env-core@0.12.0':
resolution: {integrity: sha512-lOPj8d9nJJTt81mMuN9GMk8x5veOt7q9m11OSnCBJhwp1QrL/qR+M8Y467ULBSm9SunosryWNbmQQbgoiMgcdw==} resolution: {integrity: sha512-lOPj8d9nJJTt81mMuN9GMk8x5veOt7q9m11OSnCBJhwp1QrL/qR+M8Y467ULBSm9SunosryWNbmQQbgoiMgcdw==}
peerDependencies: peerDependencies:
@@ -2652,8 +2655,8 @@ packages:
base64-js@1.5.1: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.8: baseline-browser-mapping@2.10.29:
resolution: {integrity: sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==} resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
@@ -2710,8 +2713,8 @@ packages:
camelize@1.0.1: camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
caniuse-lite@1.0.30001780: caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
canvas-confetti@1.9.4: canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
@@ -4389,8 +4392,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.1.7: next@16.2.6:
resolution: {integrity: sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==} resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -5013,6 +5016,11 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
semver@7.8.0:
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
engines: {node: '>=10'}
hasBin: true
server-only@0.0.1: server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
@@ -6066,7 +6074,7 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/runtime@1.9.0': '@emnapi/runtime@1.10.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
@@ -6343,7 +6351,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5': '@img/sharp-wasm32@0.34.5':
dependencies: dependencies:
'@emnapi/runtime': 1.9.0 '@emnapi/runtime': 1.10.0
optional: true optional: true
'@img/sharp-win32-arm64@0.34.5': '@img/sharp-win32-arm64@0.34.5':
@@ -6598,38 +6606,38 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12': '@napi-rs/wasm-runtime@0.2.12':
dependencies: dependencies:
'@emnapi/core': 1.8.1 '@emnapi/core': 1.8.1
'@emnapi/runtime': 1.9.0 '@emnapi/runtime': 1.10.0
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true optional: true
'@next/env@16.1.7': {} '@next/env@16.2.6': {}
'@next/eslint-plugin-next@15.5.12': '@next/eslint-plugin-next@15.5.12':
dependencies: dependencies:
fast-glob: 3.3.1 fast-glob: 3.3.1
'@next/swc-darwin-arm64@16.1.7': '@next/swc-darwin-arm64@16.2.6':
optional: true optional: true
'@next/swc-darwin-x64@16.1.7': '@next/swc-darwin-x64@16.2.6':
optional: true optional: true
'@next/swc-linux-arm64-gnu@16.1.7': '@next/swc-linux-arm64-gnu@16.2.6':
optional: true optional: true
'@next/swc-linux-arm64-musl@16.1.7': '@next/swc-linux-arm64-musl@16.2.6':
optional: true optional: true
'@next/swc-linux-x64-gnu@16.1.7': '@next/swc-linux-x64-gnu@16.2.6':
optional: true optional: true
'@next/swc-linux-x64-musl@16.1.7': '@next/swc-linux-x64-musl@16.2.6':
optional: true optional: true
'@next/swc-win32-arm64-msvc@16.1.7': '@next/swc-win32-arm64-msvc@16.2.6':
optional: true optional: true
'@next/swc-win32-x64-msvc@16.1.7': '@next/swc-win32-x64-msvc@16.2.6':
optional: true optional: true
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
@@ -7192,7 +7200,7 @@ snapshots:
'@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@react-types/shared': 3.33.1(react@19.2.4) '@react-types/shared': 3.33.1(react@19.2.4)
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.21
clsx: 2.1.1 clsx: 2.1.1
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
@@ -7203,13 +7211,13 @@ snapshots:
'@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@react-stately/flags': 3.1.2 '@react-stately/flags': 3.1.2
'@react-types/shared': 3.33.1(react@19.2.4) '@react-types/shared': 3.33.1(react@19.2.4)
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.21
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
'@react-aria/ssr@3.9.10(react@19.2.4)': '@react-aria/ssr@3.9.10(react@19.2.4)':
dependencies: dependencies:
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.21
react: 19.2.4 react: 19.2.4
'@react-aria/utils@3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@react-aria/utils@3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
@@ -7218,18 +7226,18 @@ snapshots:
'@react-stately/flags': 3.1.2 '@react-stately/flags': 3.1.2
'@react-stately/utils': 3.11.0(react@19.2.4) '@react-stately/utils': 3.11.0(react@19.2.4)
'@react-types/shared': 3.33.1(react@19.2.4) '@react-types/shared': 3.33.1(react@19.2.4)
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.21
clsx: 2.1.1 clsx: 2.1.1
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
'@react-stately/flags@3.1.2': '@react-stately/flags@3.1.2':
dependencies: dependencies:
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.21
'@react-stately/utils@3.11.0(react@19.2.4)': '@react-stately/utils@3.11.0(react@19.2.4)':
dependencies: dependencies:
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.21
react: 19.2.4 react: 19.2.4
'@react-types/shared@3.33.1(react@19.2.4)': '@react-types/shared@3.33.1(react@19.2.4)':
@@ -7437,6 +7445,10 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@swc/helpers@0.5.21':
dependencies:
tslib: 2.8.1
'@t3-oss/env-core@0.12.0(typescript@5.9.3)(zod@3.25.76)': '@t3-oss/env-core@0.12.0(typescript@5.9.3)(zod@3.25.76)':
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
@@ -8249,7 +8261,7 @@ snapshots:
base64-js@1.5.1: {} base64-js@1.5.1: {}
baseline-browser-mapping@2.10.8: {} baseline-browser-mapping@2.10.29: {}
best-effort-json-parser@1.2.1: {} best-effort-json-parser@1.2.1: {}
@@ -8313,7 +8325,7 @@ snapshots:
camelize@1.0.1: {} camelize@1.0.1: {}
caniuse-lite@1.0.30001780: {} caniuse-lite@1.0.30001792: {}
canvas-confetti@1.9.4: {} canvas-confetti@1.9.4: {}
@@ -9643,7 +9655,7 @@ snapshots:
is-bun-module@2.0.0: is-bun-module@2.0.0:
dependencies: dependencies:
semver: 7.7.4 semver: 7.8.0
is-callable@1.2.7: {} is-callable@1.2.7: {}
@@ -10531,25 +10543,25 @@ snapshots:
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
'@next/env': 16.1.7 '@next/env': 16.2.6
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.8 baseline-browser-mapping: 2.10.29
caniuse-lite: 1.0.30001780 caniuse-lite: 1.0.30001792
postcss: 8.4.31 postcss: 8.4.31
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(react@19.2.4) styled-jsx: 5.1.6(react@19.2.4)
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 16.1.7 '@next/swc-darwin-arm64': 16.2.6
'@next/swc-darwin-x64': 16.1.7 '@next/swc-darwin-x64': 16.2.6
'@next/swc-linux-arm64-gnu': 16.1.7 '@next/swc-linux-arm64-gnu': 16.2.6
'@next/swc-linux-arm64-musl': 16.1.7 '@next/swc-linux-arm64-musl': 16.2.6
'@next/swc-linux-x64-gnu': 16.1.7 '@next/swc-linux-x64-gnu': 16.2.6
'@next/swc-linux-x64-musl': 16.1.7 '@next/swc-linux-x64-musl': 16.2.6
'@next/swc-win32-arm64-msvc': 16.1.7 '@next/swc-win32-arm64-msvc': 16.2.6
'@next/swc-win32-x64-msvc': 16.1.7 '@next/swc-win32-x64-msvc': 16.2.6
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@playwright/test': 1.59.1 '@playwright/test': 1.59.1
sharp: 0.34.5 sharp: 0.34.5
@@ -10557,13 +10569,13 @@ snapshots:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
nextra-theme-docs@4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): nextra-theme-docs@4.6.1(@types/react@19.2.13)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
dependencies: dependencies:
'@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
clsx: 2.1.1 clsx: 2.1.1
next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
nextra: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) nextra: 4.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react: 19.2.4 react: 19.2.4
react-compiler-runtime: 19.1.0-rc.3(react@19.2.4) react-compiler-runtime: 19.1.0-rc.3(react@19.2.4)
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
@@ -10575,7 +10587,7 @@ snapshots:
- immer - immer
- use-sync-external-store - use-sync-external-store
nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): nextra@4.6.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies: dependencies:
'@formatjs/intl-localematcher': 0.6.2 '@formatjs/intl-localematcher': 0.6.2
'@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -10596,7 +10608,7 @@ snapshots:
mdast-util-gfm: 3.1.0 mdast-util-gfm: 3.1.0
mdast-util-to-hast: 13.2.1 mdast-util-to-hast: 13.2.1
negotiator: 1.0.0 negotiator: 1.0.0
next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4 react: 19.2.4
react-compiler-runtime: 19.1.0-rc.3(react@19.2.4) react-compiler-runtime: 19.1.0-rc.3(react@19.2.4)
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
@@ -10925,7 +10937,7 @@ snapshots:
postcss@8.4.31: postcss@8.4.31:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.12
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
@@ -11365,6 +11377,8 @@ snapshots:
semver@7.7.4: {} semver@7.7.4: {}
semver@7.8.0: {}
server-only@0.0.1: {} server-only@0.0.1: {}
set-function-length@1.2.2: set-function-length@1.2.2:
@@ -11393,7 +11407,7 @@ snapshots:
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
detect-libc: 2.1.2 detect-libc: 2.1.2
semver: 7.7.4 semver: 7.8.0
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5