Compare commits

..

2 Commits

Author SHA1 Message Date
Willem Jiang
573968a78d release: v2.2.0-rc0 2026-10-29 20:53:45 +08:00
Willem Jiang
c133f5e0da chore(release): keep deerflow-harness in the version-source lockstep
bump_version.sh now also rewrites backend/packages/harness/pyproject.toml
(deerflow-harness is a workspace member, so `uv lock` moves its
backend/uv.lock entry too), and verify_versions.sh gates on it in both
tag and mutual-equality modes. RELEASING.md and AGENTS.md describe the
five version sources.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-29 14:48:16 +08:00
1317 changed files with 51628 additions and 157371 deletions

View File

@ -62,9 +62,6 @@ docker/
skills/ skills/
frontend/.next frontend/.next
frontend/node_modules frontend/node_modules
# Frontend unit tests can import fixtures outside frontend/ (for example, examples/),
# so exclude them from the frontend-only Docker build context.
frontend/tests
backend/.venv backend/.venv
backend/htmlcov backend/htmlcov
backend/.coverage backend/.coverage
@ -76,23 +73,6 @@ backend/.coverage
# root that the build client cannot read. # root that the build client cannot read.
**/.deer-flow/ **/.deer-flow/
backend/sandbox/ backend/sandbox/
# Local configuration holds model API keys, MCP credentials, frontend secrets and
# local HTTPS keys (frontend/*.pem, the frontend .gitignore's mkcert pair).
# Compose and Helm supply it at runtime. The config resolvers still read the legacy
# backend/ copies, and `COPY backend ./backend` / `COPY frontend ./frontend` would
# otherwise bake them into every image layer built from this checkout.
config.yaml
config.yaml.bak
extensions_config.json
mcp_config.json
backend/config.yaml
backend/config.yaml.bak
backend/extensions_config.json
backend/mcp_config.json
**/.env*.local
frontend/*.pem
*.md *.md
!README.md !README.md
!frontend/README.md !frontend/README.md
@ -104,17 +84,3 @@ frontend/*.pem
!backend/extensions/ !backend/extensions/
!backend/extensions/sources/ !backend/extensions/sources/
!backend/extensions/sources/** !backend/extensions/sources/**
# The re-inclusion above wins over every earlier rule, so the files the extension
# manager refuses to snapshot (deerflow/extensions/manager.py) are excluded again
# here. A snapshot the manager wrote never contains them; one edited by hand
# must not carry credentials into the image either.
backend/extensions/sources/**/.env
backend/extensions/sources/**/.env.*
backend/extensions/sources/**/.npmrc
backend/extensions/sources/**/.pypirc
backend/extensions/sources/**/credentials.json
backend/extensions/sources/**/*.key
backend/extensions/sources/**/*.pem
backend/extensions/sources/**/*.p12
backend/extensions/sources/**/*.pfx

View File

@ -1,40 +1,37 @@
# Serper API Key (Google Search) - https://serper.dev # Serper API Key (Google Search) - https://serper.dev
# SERPER_API_KEY=your-serper-api-key SERPER_API_KEY=your-serper-api-key
# Optional operator-controlled Serper-compatible API base URL, without /search or /images. # Optional operator-controlled Serper-compatible API base URL, without /search or /images.
# The configured API key is sent in X-API-KEY to this host; use a trusted provider. # The configured API key is sent in X-API-KEY to this host; use a trusted provider.
SERPER_BASE_URL=https://google.serper.dev SERPER_BASE_URL=https://google.serper.dev
# Exa API Key - https://exa.ai # Exa API Key - https://exa.ai
# EXA_API_KEY=your-exa-api-key EXA_API_KEY=your-exa-api-key
# Optional operator-controlled Exa-compatible API base URL for the Exa web_search and web_fetch tools. # Optional operator-controlled Exa-compatible API base URL for the Exa web_search and web_fetch tools.
# The configured API key is sent in x-api-key to this host; use a trusted provider. # The configured API key is sent in x-api-key to this host; use a trusted provider.
EXA_BASE_URL=https://api.exa.ai EXA_BASE_URL=https://api.exa.ai
# Serply API Key (Google Search, News and Scholar) - https://serply.io # Serply API Key (Google Search, News and Scholar) - https://serply.io
# SERPLY_API_KEY=your-serply-api-key SERPLY_API_KEY=your-serply-api-key
# TAVILY API Key # TAVILY API Key
# TAVILY_API_KEY=your-tavily-api-key TAVILY_API_KEY=your-tavily-api-key
# Optional Webz.io News Search key (select Webz in make setup) # Optional Webz.io News Search key (select Webz in make setup)
# WEBZ_API_KEY=your-webz-api-key # WEBZ_API_KEY=your-webz-api-key
# Jina API Key (optional; the Jina Reader web_fetch works without one at a lower rate limit) # Jina API Key
# JINA_API_KEY=your-jina-api-key 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
# Sofya API Key (web search and fetch) - https://sofya.co # Sofya API Key (web search and fetch) - https://sofya.co
# SOFYA_API_KEY=your-sofya-api-key SOFYA_API_KEY=your-sofya-api-key
# SpecStream API Key (web search and fetch) - https://specstream.ai
# SPECSTREAM_API_KEY=your-specstream-api-key
# Unbrowse API Key (web fetch) - https://unbrowse.ai/app # Unbrowse API Key (web fetch) - https://unbrowse.ai/app
# UNBROWSE_API_KEY=your-unbrowse-api-key UNBROWSE_API_KEY=your-unbrowse-api-key
# Browser CORS allowlist for split-origin or port-forwarded deployments (comma-separated exact origins). # Browser CORS allowlist for split-origin or port-forwarded deployments (comma-separated exact origins).
# Leave unset when using the unified nginx endpoint, e.g. http://localhost:2026. # Leave unset when using the unified nginx endpoint, e.g. http://localhost:2026.
@ -75,10 +72,6 @@ EXA_BASE_URL=https://api.exa.ai
# DEEPSEEK_API_KEY=your-deepseek-api-key # DEEPSEEK_API_KEY=your-deepseek-api-key
# NOVITA_API_KEY=your-novita-api-key # OpenAI-compatible, see https://novita.ai # NOVITA_API_KEY=your-novita-api-key # OpenAI-compatible, see https://novita.ai
# MINIMAX_API_KEY=your-minimax-api-key # OpenAI-compatible, see https://platform.minimax.io # MINIMAX_API_KEY=your-minimax-api-key # OpenAI-compatible, see https://platform.minimax.io
# MiniMax API host (choose by region):
# China: https://api.minimaxi.com (default)
# International: https://api.minimax.io
# MINIMAX_API_HOST=https://api.minimax.io
# OPPER_API_KEY=your-opper-api-key # OpenAI-compatible, see https://opper.ai # OPPER_API_KEY=your-opper-api-key # OpenAI-compatible, see https://opper.ai
# OpenAI-compatible Images API (used by the image-generation skill) # OpenAI-compatible Images API (used by the image-generation skill)
# IMAGE_GENERATION_PROVIDER=openai # openai-compatible is also accepted # IMAGE_GENERATION_PROVIDER=openai # openai-compatible is also accepted

View File

@ -57,7 +57,7 @@ jobs:
# its peers. # its peers.
fail-fast: false fail-fast: false
matrix: matrix:
shard: [1, 2, 3, 4, 5, 6] shard: [1, 2, 3, 4]
services: services:
postgres: postgres:
@ -105,7 +105,7 @@ jobs:
working-directory: backend working-directory: backend
run: uv sync --group dev --extra postgres run: uv sync --group dev --extra postgres
- name: Run unit tests of backend (shard ${{ matrix.shard }} of ${{ strategy.job-total }}) - name: Run unit tests of backend (shard ${{ matrix.shard }} of 4)
working-directory: backend working-directory: backend
env: env:
# Expose the job's Postgres service to the cross-pod dedupe integration # Expose the job's Postgres service to the cross-pod dedupe integration
@ -114,4 +114,4 @@ jobs:
# exercised in CI. # exercised in CI.
DEERFLOW_TEST_WORKFLOW_POSTGRES: ${{ env.TEST_POSTGRES_URI }} DEERFLOW_TEST_WORKFLOW_POSTGRES: ${{ env.TEST_POSTGRES_URI }}
DEDUPE_TEST_POSTGRES_URL: ${{ env.TEST_POSTGRES_URI }} DEDUPE_TEST_POSTGRES_URL: ${{ env.TEST_POSTGRES_URI }}
run: make test-shard SPLITS=${{ strategy.job-total }} GROUP=${{ matrix.shard }} run: make test-shard SPLITS=4 GROUP=${{ matrix.shard }}

View File

@ -1,104 +0,0 @@
name: Bundled Extension Tests
# Bundled extension examples own their behavior suites, which carry the
# `bundled_extension` marker. The core backend shards exclude them so an
# optional example cannot fail an unrelated core PR; this workflow runs them
# whenever an example, the extension contract, the host they load into or the
# bundled tests change.
#
# The filter is deliberately bounded: the marked modules import `deerflow.*`
# (packages) and `app.gateway.*`, so only the Gateway subtree is listed rather
# than all of `backend/app`. Test files are listed one by one instead of
# `backend/tests/**`, and `test_bundled_extension_ci.py` fails when a module
# carries the marker without appearing here, so this list cannot drift.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'examples/deerflow-extension-*/**'
- 'backend/packages/**'
- 'backend/app/gateway/**'
- 'backend/tests/conftest.py'
- 'backend/tests/support/**'
- 'backend/tests/test_agent_teams_plugin.py'
- 'backend/tests/test_bookmark_plugin.py'
- 'backend/tests/test_bundled_extension_ci.py'
- 'backend/tests/test_jev_classify_extension.py'
- 'backend/tests/test_jev_context_extension.py'
- 'backend/tests/test_jev_result_screening_extension.py'
- 'backend/tests/test_jev_screening_pipeline.py'
- 'backend/tests/test_jev_screening_policy.py'
- 'backend/tests/test_knowledge_center_extension.py'
- 'backend/tests/test_knowledge_management_extension.py'
- 'backend/tests/test_knowledge_platform_extension.py'
- 'backend/tests/test_lightrag_relations_extension.py'
- 'backend/tests/test_report_sections_extension.py'
- 'backend/tests/test_retrieval_plugin_artifacts.py'
- 'backend/tests/test_weknora_extension.py'
- 'backend/tests/test_workflow_plugin.py'
- 'backend/pyproject.toml'
- 'backend/uv.lock'
- 'backend/Makefile'
- '.github/workflows/bundled-extension-tests.yml'
push:
branches: [main, "*-dev"]
paths:
- 'examples/deerflow-extension-*/**'
- 'backend/packages/**'
- 'backend/app/gateway/**'
- 'backend/tests/conftest.py'
- 'backend/tests/support/**'
- 'backend/tests/test_agent_teams_plugin.py'
- 'backend/tests/test_bookmark_plugin.py'
- 'backend/tests/test_bundled_extension_ci.py'
- 'backend/tests/test_jev_classify_extension.py'
- 'backend/tests/test_jev_context_extension.py'
- 'backend/tests/test_jev_result_screening_extension.py'
- 'backend/tests/test_jev_screening_pipeline.py'
- 'backend/tests/test_jev_screening_policy.py'
- 'backend/tests/test_knowledge_center_extension.py'
- 'backend/tests/test_knowledge_management_extension.py'
- 'backend/tests/test_knowledge_platform_extension.py'
- 'backend/tests/test_lightrag_relations_extension.py'
- 'backend/tests/test_report_sections_extension.py'
- 'backend/tests/test_retrieval_plugin_artifacts.py'
- 'backend/tests/test_weknora_extension.py'
- 'backend/tests/test_workflow_plugin.py'
- 'backend/pyproject.toml'
- 'backend/uv.lock'
- 'backend/Makefile'
- '.github/workflows/bundled-extension-tests.yml'
permissions:
contents: read
concurrency:
group: bundled-extension-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
bundled-extension-tests:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v7
with:
# Must match backend/Dockerfile's UV_IMAGE tag; pinned by backend/tests/test_ci_uv_version_pin.py
version: "0.11.1"
- name: Install backend dependencies
working-directory: backend
run: uv sync --group dev
- name: Run bundled extension behavior tests
working-directory: backend
run: make test-bundled-extensions

View File

@ -14,7 +14,6 @@ on:
- 'backend/pyproject.toml' - 'backend/pyproject.toml'
- 'backend/uv.lock' - 'backend/uv.lock'
- 'examples/deerflow-extension-bookmarks/**' - 'examples/deerflow-extension-bookmarks/**'
- 'examples/deerflow-extension-office-preview/**'
- '.github/workflows/e2e-tests.yml' - '.github/workflows/e2e-tests.yml'
pull_request: pull_request:
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
@ -29,7 +28,6 @@ on:
- 'backend/pyproject.toml' - 'backend/pyproject.toml'
- 'backend/uv.lock' - 'backend/uv.lock'
- 'examples/deerflow-extension-bookmarks/**' - 'examples/deerflow-extension-bookmarks/**'
- 'examples/deerflow-extension-office-preview/**'
- '.github/workflows/e2e-tests.yml' - '.github/workflows/e2e-tests.yml'
concurrency: concurrency:
@ -43,15 +41,6 @@ jobs:
e2e-tests: e2e-tests:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Keep the image version aligned with frontend/pnpm-lock.yaml. Browsers and
# OS libraries are installed together, without per-shard CDN downloads.
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
options: --ipc=host
env:
# The host tool cache can contain Python built for a newer OpenSSL than
# this image. uv's standalone Python carries compatible runtime libraries.
UV_PYTHON_PREFERENCE: only-managed
timeout-minutes: 25 timeout-minutes: 25
strategy: strategy:
fail-fast: false fail-fast: false
@ -62,20 +51,21 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
# The bookmark browser test starts the real Python action/router fixture.
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v7
with: with:
# Keep aligned with backend/Dockerfile and other backend workflows. # Keep aligned with backend/Dockerfile and other backend workflows.
version: "0.11.1" version: "0.11.1"
- name: Install container-compatible Python
run: uv python install 3.12
- name: Install backend dependencies (bookmark plugin gateway) - name: Install backend dependencies (bookmark plugin gateway)
working-directory: backend working-directory: backend
run: | run: uv sync --locked
uv sync --locked
uv run --no-sync python -c 'import ssl, sqlite3'
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@ -92,20 +82,9 @@ jobs:
working-directory: frontend working-directory: frontend
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
# Playwright collects the resource suite before selecting a shard. Its - name: Install Playwright Chromium
# synthetic Office fixtures use JSZip from the extension's locked install.
- name: Install Office preview test dependencies
working-directory: examples/deerflow-extension-office-preview
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Verify Playwright Chromium
working-directory: frontend working-directory: frontend
id: chromium run: npx playwright install chromium --with-deps
run: >-
node --input-type=module -e
'import { chromium } from "@playwright/test";
const browser = await chromium.launch();
await browser.close();'
- name: Run E2E tests - name: Run E2E tests
working-directory: frontend working-directory: frontend
@ -121,7 +100,7 @@ jobs:
- name: Run auth recovery E2E tests - name: Run auth recovery E2E tests
# Keep auth coverage unsharded and run it even if the first shard fails. # Keep auth coverage unsharded and run it even if the first shard fails.
if: ${{ !cancelled() && matrix.shard == 1 && steps.chromium.outcome == 'success' }} if: ${{ !cancelled() && matrix.shard == 1 }}
working-directory: frontend working-directory: frontend
run: >- run: >-
pnpm exec playwright test -c playwright.auth.config.ts pnpm exec playwright test -c playwright.auth.config.ts

View File

@ -71,27 +71,21 @@ jobs:
name: Layer 2 — full-stack render (no API key) name: Layer 2 — full-stack render (no API key)
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Match the locked Playwright runtime so the image supplies its browsers.
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
options: --ipc=host
env:
UV_PYTHON_PREFERENCE: only-managed
timeout-minutes: 25 timeout-minutes: 25
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v7
with: with:
# Must match backend/Dockerfile's UV_IMAGE tag; pinned by backend/tests/test_ci_uv_version_pin.py # Must match backend/Dockerfile's UV_IMAGE tag; pinned by backend/tests/test_ci_uv_version_pin.py
version: "0.11.1" version: "0.11.1"
- name: Install container-compatible Python
run: uv python install 3.12
- name: Install backend dependencies (replay gateway) - name: Install backend dependencies (replay gateway)
working-directory: backend working-directory: backend
run: | run: uv sync --group dev
uv sync --group dev
uv run --no-sync python -c 'import ssl, sqlite3'
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@ -103,13 +97,9 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: frontend working-directory: frontend
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Verify Playwright Chromium - name: Install Playwright Chromium
working-directory: frontend working-directory: frontend
run: >- run: npx playwright install chromium --with-deps
node --input-type=module -e
'import { chromium } from "@playwright/test";
const browser = await chromium.launch();
await browser.close();'
- name: Full-stack replay render (DOM assertions are the gate) - name: Full-stack replay render (DOM assertions are the gate)
working-directory: frontend working-directory: frontend
run: pnpm exec playwright test -c playwright.real-backend.config.ts run: pnpm exec playwright test -c playwright.real-backend.config.ts

View File

@ -29,7 +29,7 @@ permissions:
jobs: jobs:
sandbox-image-smoke: sandbox-image-smoke:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 25
env: env:
SANDBOX_SMOKE_IMAGE_REF: ${{ vars.SANDBOX_SMOKE_IMAGE || 'enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest' }} SANDBOX_SMOKE_IMAGE_REF: ${{ vars.SANDBOX_SMOKE_IMAGE || 'enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest' }}
FOWNER_SMOKE_IMAGE_REF: ${{ vars.SANDBOX_FOWNER_SMOKE_IMAGE || 'enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:1.11.0' }} FOWNER_SMOKE_IMAGE_REF: ${{ vars.SANDBOX_FOWNER_SMOKE_IMAGE || 'enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:1.11.0' }}
@ -38,16 +38,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Free disk space for sandbox images
# This job only needs Python and Docker, not the runner's mobile/.NET/Haskell SDKs.
run: |
df -h /
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /usr/local/.ghcup /opt/ghc
docker image prune --all --force
df -h /
- name: Resolve immutable image references for this run - name: Resolve immutable image references for this run
timeout-minutes: 30
# Pull on the runner's platform, then pass only immutable repo@sha256 # Pull on the runner's platform, then pass only immutable repo@sha256
# references to pytest. This keeps both the baseline and the 1.11.0 # references to pytest. This keeps both the baseline and the 1.11.0
# regression run reproducible even if either source tag moves later. # regression run reproducible even if either source tag moves later.
@ -85,6 +76,5 @@ jobs:
run: uv sync --group dev run: uv sync --group dev
- name: Run the live real-image smoke tests - name: Run the live real-image smoke tests
timeout-minutes: 10
working-directory: backend working-directory: backend
run: uv run pytest -m live tests/test_aio_sandbox_local_backend.py -v run: uv run pytest -m live tests/test_aio_sandbox_local_backend.py -v

View File

@ -67,7 +67,7 @@ jobs:
working-directory: backend working-directory: backend
run: | run: |
# Fail instead of silently skipping data-analysis tests on missing extras. # Fail instead of silently skipping data-analysis tests on missing extras.
uv run --locked python -c "import duckdb, openpyxl, pandas, requests, xlrd" uv run --locked python -c "import duckdb, openpyxl, requests"
uv run --locked python -m pytest ../tests/skills -q -rs uv run --locked python -m pytest ../tests/skills -q -rs
skill-review: skill-review:

View File

@ -55,13 +55,7 @@ jobs:
// ---- area: replaces .github/labeler.yml (path -> area) ---- // ---- area: replaces .github/labeler.yml (path -> area) ----
const AREA_RULES = [ const AREA_RULES = [
['area:frontend', [/^frontend\//]], ['area:frontend', [/^frontend\//]],
// Cover both backend packages. Keep the harness exclusions aligned ['area:backend', [/^backend\/app\//, /^backend\/packages\/harness\/deerflow\/(runtime|persistence|config|tools|guardrails|tracing|models|utils|uploads)\//]],
// with the dedicated tree rules below; the offline replay checks this.
['area:backend', [
/^backend\/app\//,
/^backend\/packages\/harness\/(?!deerflow\/(?:agents|subagents|reflection|sandbox|skills|mcp)\/)/,
/^backend\/packages\/extension-api\//,
]],
['area:agents', [/^backend\/packages\/harness\/deerflow\/(agents|subagents|reflection)\//, /(^|\/)langgraph\.json$/, /^backend\/.*\/prompts\//]], ['area:agents', [/^backend\/packages\/harness\/deerflow\/(agents|subagents|reflection)\//, /(^|\/)langgraph\.json$/, /^backend\/.*\/prompts\//]],
['area:sandbox', [/^docker\//, /^backend\/packages\/harness\/deerflow\/sandbox\//, /(^|\/)Dockerfile$/]], ['area:sandbox', [/^docker\//, /^backend\/packages\/harness\/deerflow\/sandbox\//, /(^|\/)Dockerfile$/]],
['area:skills', [/^skills\//, /^backend\/packages\/harness\/deerflow\/skills\//, /^frontend\/src\/core\/skills\//]], ['area:skills', [/^skills\//, /^backend\/packages\/harness\/deerflow\/skills\//, /^frontend\/src\/core\/skills\//]],

325
AGENTS.md
View File

@ -1,122 +1,251 @@
# DeerFlow # AGENTS.md
DeerFlow is an agent harness: a LangGraph-based backend with thread-isolated Guidance source; `CLAUDE.md` imports `@AGENTS.md`.
sandboxes, memory, subagents, skills and MCP tools, and a Next.js frontend. IM Module guides:
channels connect through the Gateway. `CLAUDE.md` imports this file; edit
guidance here, not in the shim.
## How guidance is organized - **[backend/AGENTS.md](backend/AGENTS.md)** — backend depth: harness/app split, agent &
middleware chain, sandbox, MCP, skills, memory, IM channels, persistence/migrations,
config system, test layout.
- **[frontend/AGENTS.md](frontend/AGENTS.md)** — frontend depth: Next.js App Router layout,
thread/streaming data flow, code style, commands.
Rules live in the `AGENTS.md` nearest to the code. Before you edit a directory, ## What is DeerFlow
read every `AGENTS.md` from that directory up to this file. Some coding agents do
not load the nested files on their own.
| Area | Guide | DeerFlow combines LangGraph and Next.js with thread-isolated sandbox execution,
| --- | --- | memory, subagents and extensible tools. IM channels connect through the Gateway.
| Backend: harness, Gateway, channels, tests | [backend/AGENTS.md](backend/AGENTS.md) |
| Frontend | [frontend/AGENTS.md](frontend/AGENTS.md) |
| nginx, Compose, provisioner | [docker/AGENTS.md](docker/AGENTS.md) |
| Orchestration scripts, `scripts/pnpm.py` | [scripts/AGENTS.md](scripts/AGENTS.md) |
| Skill review, skill tests | [skills/AGENTS.md](skills/AGENTS.md) |
| Extension examples | `examples/*/AGENTS.md` |
Humans start at [README.md](README.md), [CONTRIBUTING.md](CONTRIBUTING.md) and ## Service Topology
[docs/guide/](docs/guide/).
## Service topology A single `make dev` / Docker stack runs four cooperating services:
| Service | Port | Role | | Service | Port | Role |
| --- | --- | --- | | --- | --- | --- |
| Nginx | 2026 | The single entry point; open this in the browser | | **Nginx** | `2026` | Unified reverse-proxy entry point — open this in the browser |
| Gateway | 8001 | FastAPI REST API and the embedded agent runtime | | **Gateway API** | `8001` | FastAPI REST API + embedded LangGraph-compatible agent runtime |
| Frontend | 3000 | Next.js web interface | | **Frontend** | `3000` | Next.js web interface |
| Provisioner | 8002 | Optional; only for provisioner or Kubernetes sandboxes | | **Provisioner** | `8002` | Optional — only when sandbox is configured for provisioner/K8s mode |
Nginx serves the frontend, proxies `/api/*` to the Gateway and rewrites Nginx is the single public entry: it proxies `/api/*` to the Gateway, rewriting
`/api/langgraph/*` to the Gateway's native routes. `/api/langgraph/*` onto the Gateway's native routes, and serves the frontend — see
[backend/AGENTS.md](backend/AGENTS.md) for the runtime and router detail. It compresses
HTML and configured textual assets, deliberately leaving SSE, fonts, images, audio, and
video uncompressed at the proxy layer.
Before changing ingress, port bindings or provisioner mounts, read Keep the dedicated `/api/threads/{thread_id}/browser/stream` WebSocket regex before
[service topology details](docs/agent-service-topology.md). the generic threads regex in Docker, local and Helm nginx configs. Upgrade headers
and the 600s read timeout are pinned by `backend/tests/test_nginx_browser_websocket.py`.
Helm uses nginx's default connect/send timeouts for the in-cluster Gateway.
## Repository map Both Compose files publish nginx at `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"`
(README's **loopback default**); bare `"${PORT}:2026"` binds `0.0.0.0`. Root `PORT`
controls Docker ingress only; local Next.js stays on `3000` so `.env` cannot
redirect `make dev`'s port wait. Nginx's IPv4+IPv6 `default_server` and Gateway's
`0.0.0.0:8001` bind are container-internal; only published nginx is external.
Require explicit bind addresses for new published ports;
`backend/tests/test_compose_default_bind_host.py` pins every service in both files.
| Path | Content | Provisioner PVC mode declares `USERDATA_PVC_NAME` once as `user-data`; extra and
| --- | --- | Lark broker credential mounts reuse it with their existing subPaths and read-only
| `backend/packages/harness/` | `deerflow-harness`, import `deerflow.*`: the agent framework | flags. Keep credential mounts broker-only and keep distinct volumes in hostPath mode.
| `backend/packages/extension-api/` | `deerflow-extension-api`: the public extension contract |
| `backend/app/` | The Gateway and the IM channels, import `app.*` |
| `frontend/` | The Next.js app (pnpm) |
| `docker/`, `deploy/helm/` | Compose files, nginx config, provisioner, Helm chart |
| `skills/public/`, `skills/custom/` | Committed skills and local, gitignored skills |
| `contracts/` | JSON contracts shared across components |
| `examples/` | Installable extension examples |
| `scripts/`, `tests/` | Root orchestration scripts, and tests for public skills |
`config.yaml` and `extensions_config.json` are copied from the example files at ## Repository Map
the repository root. Both are gitignored and the Gateway can write them. Do not
commit them.
## Commands ```
deer-flow/
`make help` lists the root targets. First setup is `make config`, then ├── Makefile # Root orchestration: drives the full stack (dev/start/stop, docker, setup)
`make install`, then `make dev`, which serves the stack on `localhost:2026` and ├── config.example.yaml # Template → copy to config.yaml (gitignored) at repo root
writes `logs/gateway.log` and `logs/frontend.log`. ├── extensions_config.example.json # Template → copy to extensions_config.json (gitignored): MCP servers + skills
├── backend/ # Python backend — see backend/AGENTS.md
```bash │ ├── Makefile # Per-module backend commands (dev, gateway, test, lint, migrate-rev)
cd backend && make test && make test-blocking-io # offline suites │ ├── extensions/sources/ # Deployable snapshots of locally installed Python extensions
cd backend && make format # CI runs ruff format --check │ ├── packages/extension-api/ # deerflow-extension-api package (import: deerflow_extension_api.*) — public extension contract
cd backend && python -m pytest tests/path/test_x.py::test_name -q │ ├── packages/harness/ # deerflow-harness package (import: deerflow.*) — agent framework
cd frontend && pnpm check && pnpm test │ └── app/ # FastAPI Gateway + IM channels (import: app.*)
cd frontend && pnpm rstest run <pattern> ├── frontend/ # Next.js frontend (pnpm) — see frontend/AGENTS.md
make check-agent-guidance # size budget of these guides ├── docker/ # docker-compose files, nginx config, provisioner
├── skills/ # Agent skills: public/ (committed), custom/ (gitignored)
│ # Managed integration skill packs are global at .deer-flow/integrations/skills/{provider}/
│ # Integration credentials and enabled state remain per-user
├── contracts/ # Cross-component JSON contracts (e.g. subagent status, skill review)
├── examples/ # Extension examples: deerflow-extension-{example,bookmarks,agent-teams}
├── scripts/ # Root orchestration scripts invoked by the Makefile (check, configure, doctor, support_bundle, serve, nginx, docker, deploy, setup_wizard)
├── tests/ # Root-level tests (currently tests/skills/ — public skill tests)
└── docs/ # Cross-cutting docs, plans, and design notes
``` ```
## Rules for every change Third-party extensions load from operator-controlled `config.yaml -> plugins:`.
This imports code, so the list stays outside API-writable `extensions_config.json`.
Extensions contribute middleware, lifecycle observers, Gateway services, FastAPI
routers, and experimental full-stack plugins. Use
`deerflow extensions install/upgrade/list/enable/disable/remove` or root
`make extension-*` wrappers. Restart Gateway after every mutation. Build hooks
and extension code run with Gateway privileges; use only trusted operator sources.
Transactions, accepted source forms, locking, and contribution contracts:
[extensions guide](backend/packages/harness/deerflow/extensions/AGENTS.md).
User manual: `frontend/src/content/{en,zh}/harness/extensions/`.
- **Evaluate an extension first.** Before you add a feature to core code, check **Extension-first evaluation** — before implementing a feature by editing core code,
whether it can ship as a packaged extension. Core is the place for bug fixes evaluate whether it can ship as a packaged extension through the contribution points
and for changes to the agent loop, memory, context compaction and above. Stay in core for bug fixes and for changes to the agent loop, memory, context
authentication. When the existing hooks cannot express a feature, add a compaction, or authentication. If existing hooks cannot express the feature, add a
generic hook to the extension contract and an extension that uses it; business generic hook to the extension contract plus an extension that uses it — do not
logic is not hard-coded into core. Contract and examples: hard-code business logic into core. Working examples:
[extensions guide](backend/packages/harness/deerflow/extensions/AGENTS.md), `examples/deerflow-extension-{example,bookmarks,agent-teams}`.
`examples/`. [Workflow guide](examples/deerflow-extension-workflows/AGENTS.md): public
- **Extensions are operator-controlled code.** They load from `plugins:` in AgentRuns; POSIX, one process/store; approval and session-authorized recovery; uncertain admissions need reconciliation;
`config.yaml`. The list stays out of the API-writable `extensions_config.json`, no multi-Gateway failover or hard currency budget.
because loading an extension imports code with Gateway privileges. Install
from trusted sources only, and restart the Gateway after every extension
change.
- **Ship tests with every feature and bug fix.**
- **Document behavior next to its topic, in the same change**: user-facing
behavior in `docs/guide/` or `backend/docs/`. `README.md` is an index; change
it only to add or re-point a link.
- **Keep the version fields identical**: `backend/pyproject.toml`,
`backend/packages/harness/pyproject.toml`, `frontend/package.json`, and
`version` and `appVersion` in `deploy/helm/deer-flow/Chart.yaml`.
`scripts/bump_version.sh <ver>` updates them and `backend/uv.lock`, and
`scripts/verify_versions.sh <ver>` checks the result. Tag CI blocks publishing
on drift. See [RELEASING.md](RELEASING.md).
## What belongs in an `AGENTS.md` Knowledge management/space policy: `examples/deerflow-extension-knowledge-{management,platform}/`
(scoped `AGENTS.md`, `CONTRACT.md`). Use existing plugin/router/principal contracts;
retrieval stays independent. Space policy intersects administrator/resource/operation
and upstream grants, never grants chat access. SQLite holds plugin-owned space policy.
Provider stages are not completion evidence; reconcile uncertain writes, never retry.
An agent reads these files on every task, so each line costs attention. A guide Root runtime configs: copy `config.example.yaml` → `config.yaml` and
holds four kinds of content: `extensions_config.example.json` → `extensions_config.json` (MCP/skills).
Both are gitignored and Gateway-editable; schema/resolution:
[backend/AGENTS.md](backend/AGENTS.md).
1. Scope: what the directory owns, in one or two sentences. Skill quality review note:
2. Verification: the exact commands. - `skills/public/skill-reviewer/` is the built-in read-only reviewer (harness tool
3. Traps: where the code misleads, or where a plausible change breaks something `review_skill_package`, contracts in `contracts/skill_review/`). Model-visible review
that a local test run does not show. data is tag-neutralized; raw payloads stay in tool artifacts. See
4. Decisions and their reasons that the code cannot state, and links to docs. [backend/AGENTS.md](backend/AGENTS.md) for the ownership boundaries.
- CI waivers live in `.github/skill-review-waivers.v1.json`, enforced by
`scripts/review_changed_public_skills.py`. Only the manifest from the trusted base
revision can suppress a run, and blocker findings can never be waived.
Values, defaults, field lists, function inventories, test names, issue numbers Scheduled-task note:
and history stay in the code, the tests and git. Write full sentences in the - `scheduler.tool_enabled` (default off) offers owner-bound schedule tools only
positive form and give the reason. `make check-agent-guidance` enforces the size through Gateway capability admission. Interactive turns create/manage their
budget. schedules; scheduled turns can request stopping only their own schedule.
Per-occurrence goal success does not end a recurring schedule.
- The scheduled-task MVP adds a workspace page at `/workspace/scheduled-tasks` plus a
background scheduler gated by `config.yaml -> scheduler.enabled`.
- Scheduled runs are non-interactive: `ask_clarification` is excluded. `non_interactive`,
`disable_clarification`, and `github_token` are honored only for internally-authenticated
callers; client-supplied copies are dropped.
- Busy occurrences persist as `queued`; `scheduler.queue_timeout_seconds` bounds the wait.
Do not reintroduce skip-on-overlap.
### Office preview example ## Commands: Root vs. Module
`examples/deerflow-extension-office-preview/` packages Office readers through the **Root stack commands** (from repo root):
optional `artifactPreviews` browser contract. Keep parser dependencies and resources
in the extension; rebuild its manifest and static module graph after UI changes. ```bash
Test packaged assets with the Office E2E fixtures; rendering must preserve original make setup # Interactive setup wizard (recommended for new users)
downloads and state its search limits. make doctor # Check configuration and system requirements
make support-bundle # Generate redacted troubleshooting summary, AI issue draft, and optional zip
make config # Generate local config files from the examples
make check # Check that required tools are installed
make install # Install all dependencies (frontend + backend + pre-commit hooks)
make extension-install SOURCE=... # Install and enable a trusted Python extension
make extension-upgrade SOURCE=... # Replace an installed extension and keep its config
make extension-list # List configured Python extensions
make extension-enable NAME=... # Enable an installed extension (restart required)
make extension-disable NAME=... # Disable without uninstalling (restart required)
make extension-remove NAME=... # Remove package and config entry (restart required)
make dev # Start all services with hot-reload (Gateway + Frontend + Nginx)
make start # Start all services in production mode (local, optimized); SKIP_FRONTEND_BUILD=1 reuses the last frontend build
make stop # Stop all running services
make up / down # Build/stop the production Docker stack (browser at localhost:2026)
make docker-start / docker-stop / docker-logs # Docker development environment
```
Production uses image Python (`uv run --no-sync`) and `exec` for SIGTERM;
Compose sets `init: true` (`backend/tests/test_gateway_sigterm_delivery.py`).
`make up` waits for Gateway `/health`; failures show Compose status and Gateway logs.
Docker logs/restarts resolve `DEER_FLOW_ROOT` from this checkout before Compose,
as start/stop do.
Full list: `make help`.
**Module commands** (from each module):
```bash
# Backend (see backend/AGENTS.md for the full set)
cd backend && make dev # Gateway API with reload (port 8001)
cd backend && make test # Four parallel shards; excludes live and blocking-I/O tests
cd backend && make test-blocking-io # Strict blocking-I/O suite
cd backend && make lint # ruff check
cd backend && make format # ruff format
# Frontend (see frontend/AGENTS.md for the full set)
cd frontend && pnpm dev # Dev server: Webpack by default (override with DEER_FLOW_DEV_BUNDLER=turbo)
cd frontend && pnpm check # Lint + type check (run before committing)
cd frontend && pnpm test # Unit tests
```
Host pnpm calls use `scripts/pnpm.py`: native Windows tries `pnpm.cmd` before
`pnpm`; POSIX reverses the order. Its Corepack fallback applies the same ordering
to `corepack.cmd` and `corepack`. The runner operates from `frontend/` so
Corepack honors its pinned package-manager version.
### Prerequisites before `make dev`
Run `make config`, then `make install`, then `make dev` on first setup.
`make dev` requires existing config; both runtime configs are API-editable and
gitignored. Never commit them.
### Run a single test
```bash
# Backend (pytest); run one file or one test function
cd backend && python -m pytest tests/test_compose_default_bind_host.py -q
cd backend && python -m pytest tests/path/to/test.py::test_func -q
# Frontend (rstest)
cd frontend && pnpm rstest run <pattern> # e.g. pnpm rstest run my-component
```
### Logs
- Docker stack: `make docker-logs` (or `docker compose -f docker/... logs -f <svc>`).
- Local `make dev`: Gateway and frontend output goes to
`logs/gateway.log` and `logs/frontend.log` in the repository root.
Run `tail -f logs/gateway.log logs/frontend.log` there to follow both.
## References
- Backend work → **[backend/AGENTS.md](backend/AGENTS.md)**
- Frontend work → **[frontend/AGENTS.md](frontend/AGENTS.md)**
- Setup & install → **[Install.md](Install.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**
- Project overview & usage → **[README.md](README.md)** (translations: `README_zh.md`,
`README_ja.md`, `README_fr.md`, `README_ru.md`, `README_pt.md`, `README_ko.md`)
- Security policy → **[SECURITY.md](SECURITY.md)**
- Changes → **[CHANGELOG.md](CHANGELOG.md)**
- Cutting a release → **[RELEASING.md](RELEASING.md)**
## Cross-Cutting Conventions
Repo-wide conventions:
- Update `README.md` for user-facing changes and the relevant `AGENTS.md` for
development/architecture changes in the same change set.
- **Test-driven development** — features and bug fixes ship with tests. Backend tests live
in `backend/tests/` (TDD is mandatory there; see [backend/AGENTS.md](backend/AGENTS.md));
frontend tests live in `frontend/tests/`.
Skill-creator tests must preserve YAML values/body, restore bytecode settings,
and check invalid metadata through offline CLI subprocesses.
Public skill script regressions live in `tests/skills/` and run in Skill Review CI
independently of package review. Exercise dependency fallbacks with the optional
import unavailable and mock HTTP transport to keep tests offline.
Video, podcast, and chart-visualization CLI failures exit non-zero;
diagnostics on stderr, success URLs on stdout; test offline.
- **Before pushing** — backend `make format`, frontend `pnpm check`.
Backend CI enforces `ruff format --check`.
- **Skill text encoding** — use explicit UTF-8 for textual skill resources.
Read `SKILL.md` with `encoding="utf-8-sig"` to strip a leading BOM; write with
`encoding="utf-8"`. Never rely on the platform locale.
- **Version sources must stay in lockstep** — a release version must match identically in
`backend/pyproject.toml`, `backend/packages/harness/pyproject.toml`, `frontend/package.json`,
and `deploy/helm/deer-flow/Chart.yaml` (`version` + `appVersion`), and `backend/uv.lock` must
record the same version for the root `deer-flow` and `deerflow-harness` workspace members
(uv stores its PEP 440 form, e.g. `2.1.0rc0`). Pushing a `v*` git tag triggers CI
that runs `scripts/verify_versions.sh` and **blocks all publishing** if any source drifts.
Before bumping a version, run `scripts/bump_version.sh <ver>` (aligns the five fields and
refreshes `backend/uv.lock` at once — it needs `uv` on `PATH`) and
`scripts/verify_versions.sh <ver>` to catch drift early. See [RELEASING.md](RELEASING.md).
- **Don't edit `CLAUDE.md`** — it only contains `@AGENTS.md`. All agent guidance changes
belong here in `AGENTS.md`; `CLAUDE.md` is a thin import shim.

View File

@ -7,491 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
This section accumulates work toward the **2.3.0** milestone
([2.3.0](https://github.com/bytedance/deer-flow/milestone/4)).
### Added
- **summarization:** The built-in compaction prompt guides the summarizer to
preserve active user requirements, literal approval phrases, action conditions,
and explicit revisions. Explicit `summarization.summary_prompt` configurations
continue to take precedence. ([#8114](https://github.com/bytedance/deer-flow/pull/8114))
- **sandbox:** Short ranged `read_file` calls on uploaded and workspace files
display source line numbers when the result contains 1–40 lines and the
labeled view fits the output budget. ([#8109](https://github.com/bytedance/deer-flow/pull/8109))
- **projects:** Owner-scoped project memory with automatic-extraction, delete and
move-to-instructions controls. Project facts share DeerMem's extraction call
while remaining isolated from user facts. New shelf documents receive
best-effort summaries by default. ([#8117])
- **hitl:** Optional Ask, Auto and Full tool-permission modes, independent of
reasoning mode. Auto uses a separate tool-free review call with an existing
model profile; uncertain or high-risk operations require a human. Cards show
complete arguments and support approve, reject, edit and exact-operation chat
grants with revocation. Root approvals survive restart through checkpoints;
interactive subagents wait through an owner-scoped channel with cancellation
and expiry. Mandatory deployment rules and account permissions apply in every
mode. Personal/platform MCP connections, policy and arguments bind approvals.
Disabled by default; see the configuration guide. `config_version` 66.
- **knowledge:** The WeKnora retrieval extension offers opt-in shared read-only
access for authenticated users, without maintaining a per-user allowlist.
Exact-user allowlists remain the default; shared access requires matching
host-verified identity and grants no management permissions. ([#7944](https://github.com/bytedance/deer-flow/pull/7944))
- **community:** New SpecStream `web_search` and `web_fetch` provider. Search returns
ranked results with a direct answer when the sources agree on one, and fetch
returns a page or document as markdown. ([#7968](https://github.com/bytedance/deer-flow/pull/7968))
- **subagents:** Durable batch items can wait for explicitly named prerequisites.
An optional, once-only lead-Agent follow-up completes a previously requested
summary or delivery after the batch ends, with persisted admission, cancellation
and authorization checks. Active Goals retain continuation ownership. ([#7839](https://github.com/bytedance/deer-flow/pull/7839))
- **chat:** Running Web chats offer Queue and Steer in the composer. Queue
waits for the next turn; Steer lets the main Agent handle updates while
native subagents pause and resume. Unconfirmed messages can be retried
after a Gateway restart. ([#7729])
- **models:** Administrators can select StepFun in **Settings → Models** and
connect an ordinary StepFun API account with an API key and model ID.
([#7988](https://github.com/bytedance/deer-flow/pull/7988))
### Changed
- **checkpoint:** DeltaChannel deployments now require `langgraph-checkpoint`
4.3.0, SQLite saver 3.1.2, and Postgres saver 3.1.3, excluding versions that
could reorder parallel writes during replay or lose paged ancestor history.
The ordering and Postgres pagination contracts now fail on regression instead
of skipping. ([#8139])
### Fixed
- **frontend:** Cron presets in the task form now require minute 0-59, hour
0-23 and day of month 1-31, so an out-of-range expression such as
`0 24 * * *` stays a custom schedule and keeps its original text. Previously,
it was parsed as a preset and could be clamped to a different time when
serialized for submission. Creating or duplicating a task with an invalid
expression, or updating its schedule configuration while the expression is
invalid, returns the Gateway's `422`; edits to other fields do not revalidate
an unchanged schedule. ([#8020])
- **gateway:** Creating a thread with checkpoint-owned keys such as `step`,
`parents` or `counters_since_delta_snapshot` in its metadata no longer
leaves the thread broken. Those keys used to land on the thread's first
checkpoint, so a bad `parents` made the thread's history fail with 500 for
good, and a bad `step` or delta counter made every later turn fail. A
`deerflow_agent_name` key was also copied into the next state update as if
the server had set it. The first checkpoint now takes none of these keys
from the request. ([#8019])
- **mcp:** Two enabled MCP servers could bind the same tool name (server `a`
exposing `b_x` and server `a_b` exposing `x` both bind `a_b_x`). Both
declarations reached the model and the provider silently kept only one.
Duplicate bound names are now dropped with a warning naming the surviving
server, in enabled-server declaration order. Note the surviving server is
stable only while its discovery keeps succeeding: if the winner's discovery
fails transiently, the later duplicate becomes served again, so the
implementation behind a bound name can alternate across cache generations
with server health.
([#8135](https://github.com/bytedance/deer-flow/pull/8135))
- **channels:** WeCom no longer drops images between 2 MB and 10 MB. The
adapter capped image uploads at 2 MB — the voice limit in the same protocol —
so a larger chart was skipped before upload and the turn arrived without it.
The cap now matches the platform's 10 MB for images; the 20 MB file limit is
unchanged. ([#8174])
- **threads:** Deleting a conversation on Windows no longer fails with
`Failed to delete local thread data.` when the workspace holds a file whose
path exceeds `MAX_PATH`. The thread directory is now resolved through the
Windows extended-length namespace before removal.
([#8144](https://github.com/bytedance/deer-flow/pull/8144))
- **channels:** A WeCom group command that follows the bot mention now runs as a
command. WeCom keeps the mention inside the message text (`@RobotA /new`), and
the classifier required a bare leading `/`, so `/new` and `/status` sent in a
group became chat turns. Leading mentions are skipped for the command lookup
only; ordinary chat keeps them and `/connect` is unchanged. Mirrors the Feishu
and DingTalk handling. ([#8118])
- **subagents:** Apply tool allowlists and denylists to middleware-declared
tools even when global authorization is disabled, including the default
denial of `task`. Restricted subagents reject non-Sequence declarations
explicitly, and invalid declarations report middleware-specific configuration
guidance. Final graph assembly checks both explicit and middleware tools;
framework-generated discovery helpers retain their filtered catalogs.
([#8136](https://github.com/bytedance/deer-flow/pull/8136))
- **frontend:** **Share** in a sidebar chat's menu now copies a link to the
deployment you are using. On `localhost` and `127.0.0.1`, the default
`make dev` and Docker addresses, it copied a link to the hosted demo
(`deer-flow-v2.vercel.app`) instead. That link never opened the
conversation, and opening it sent the thread id to an outside host.
([#8126])
- **runtime:** In multi-worker mode (`run_ownership.heartbeat_enabled`), a
worker that had lost its run to a peer could put the row back. Its assembly
evidence write was an unconditional full-row upsert, so after a peer
reclaimed the run it reset the row to `running` and erased the takeover
error. The run could then finish as `success` after the peer had already
reported it failed, or be reclaimed a second time. The evidence is now merged
into the row's metadata only while the worker still holds a live lease on an
active run (`RunStore.merge_owned_metadata()`); otherwise the worker stops
the run before executing it. Custom `RunStore` implementations used with
heartbeats must implement that method. ([#8046])
- **subagents:** Concurrent creates for the same managed-subagent name in shared
file storage now preserve the winning definition and return HTTP 409 to the
losing request instead of reporting two successes and overwriting one definition.
Gateway startup also probes hard-link support and warns if the storage volume
cannot support managed subagent creation. ([#8132](https://github.com/bytedance/deer-flow/pull/8132))
- **agents:** A high-risk tool call without a usable id no longer wedges the
thread when `hitl_approval` is enabled. A provider that omits a tool-call id
(`None`) or repeats one within a response made the middleware pause with an
approval payload the Gateway can never validate: every resume answered 422
and every new run stayed 409-blocked, all-reject included. An empty id was
technically resumable but is denied as well, since strict providers reject it
when the conversation is replayed. Such calls are now removed from the model
response with a visible rejection note instead of pausing, and deny paths no
longer build an invalid `ToolMessage` for a null id. A call whose id an
ordinary call also carries is stripped before it can be answered by result,
so the next request cannot advertise the id twice with two results.
A pause stored before
this change stays unresolvable; in-place recovery is tracked in [#8071].
([#8069])
- **runtime:** A HITL rejection, including the all-reject escape after approval is
disabled or the gated tools change during a pause, now reaches the thread's
message history under the resume run instead of existing only in checkpoint
state. ([#8073](https://github.com/bytedance/deer-flow/pull/8073))
- **summarization:** vLLM compaction counts reasoning tokens replayed to the
model when checking the trigger and estimating how much history to keep.
([#8077](https://github.com/bytedance/deer-flow/pull/8077))
- **gateway:** Posting feedback twice for the same run now returns a 409
instead of a 500. `POST /api/threads/{id}/runs/{run_id}/feedback` allows one
feedback row per user and run; a retry, or feedback on another message of the
same run, hit that unique key and the Gateway failed with an unhandled
database error. The 409 points callers at `PUT` on the same route, which
updates the existing feedback. ([#8064])
- **frontend:** Reopening a conversation restores successfully handed-off shared
artifacts in the Artifacts sidebar. ([#8080](https://github.com/bytedance/deer-flow/pull/8080))
- **sandbox:** AIO commands from independent subagents now run concurrently when
they carry per-call environment variables, including Lark CLI commands.
Commands within one subagent remain ordered and client cleanup drains them.
- **sandbox:** BoxLite VMs recovered at startup now run health checks, commands,
and cleanup on the provider's event loop, avoiding sync/async SDK mismatches
that made healthy recovered VMs fail their first command. ([#8072])
- **uploads:** Re-uploading a file whose name collides with a compound archive
suffix no longer inserts the dedupe counter inside the suffix. A second
`report.tar.gz` is now named `report_1.tar.gz` (matching `report_1.tar` and
`report_1.txt`), instead of `report.tar_1.gz`, so the extension stays intact
for file-type detection. ([#8042](https://github.com/bytedance/deer-flow/pull/8042))
- **gateway:** Deleting a thread no longer reports success while its
conversation stays readable. When the checkpoint store failed during
`DELETE /api/threads/{id}`, the route logged it at DEBUG, removed the
thread's metadata row and returned success; any signed-in user could then
read the leftover title and messages through `GET /threads/{id}`, `/state`,
`/history` and `/goal`, and the owner could not retry. The delete now
returns a 500 and keeps the thread, so the owner can delete it again.
([#7992])
- **gateway:** Creating a thread with a caller-supplied `thread_id` that
already belongs to another user now returns a non-disclosing 404
(`Thread not found`) instead of a 500. The duplicate-key recovery path reads
the surviving row back bypassing the owner scope so the cross-owner case
mirrors `ThreadOwnershipConflictError`; a duplicate key with no surviving row
is still surfaced as a 500. ([#8057])
- **models:** Cancelling a Codex-backed run now closes its upstream HTTP/SSE
connection, including while waiting for response headers, and retry waits
are cancellable. Local resources are released; the remote service may still
continue generation. ([#8053](https://github.com/bytedance/deer-flow/pull/8053))
- **middleware:** With PII redaction enabled, a long run of text without
spaces (such as a pasted hex dump) no longer stalls the Gateway. The email
detector was quadratic in the length of such a run, and redaction re-scans
every user message on every model call, so one 100 KB paste blocked every
thread for about 6 s per model call. The scan is now linear, and it redacts
exactly the same addresses as before. ([#8006])
- **agents:** Token budgets count separate accepted model calls even when a
provider reuses message IDs, so repeated IDs no longer bypass the configured
cap. Hard stops preserve already-completed structured-output tool results.
([#8050](https://github.com/bytedance/deer-flow/pull/8050))
- **subagents:** Managed subagent creation now rejects Windows reserved device
names (`CON`, `PRN`, `AUX`, `NUL`, `COM1-9`, `LPT1-9`) at the shared
`normalize_managed_subagent_name(..., for_creation=True)` validator and
Gateway `/api/subagents` boundary before attempting filesystem writes,
preventing `OSError` failures on Windows hosts while keeping existing
read and delete operations permissive.
- **scheduler:** A stored `schedule_spec` that is JSON null, a list or a string
no longer aborts the scheduler poll. `stored_next_run_at` caught the value
errors from a dropped zone or a newer cron parser, but a non-object spec
fails on `.get`; the launch-failure handler recomputed it again, so the
`AttributeError` escaped `run_once`'s claimed-task loop. Such a task is now
paused with the reason, like the other unusable schedules. The poll loop also
isolates one task's dispatch failure from the rest of its claimed batch, so a
future per-record raise costs one tick instead of every other claimed task.
([#7998])
- **scheduler:** Exceptions escaping the queue-timeout sweep or the
queued-occurrence drain are now contained to that occurrence. The
scheduler logs the failed row and continues with the remaining queue
and the tasks claimed in the same poll. Expired rows are excluded before
owner ranking and the batch limit, and a launch claim enforces the same
cutoff, so a row left queued by a failed sweep neither starts nor crowds
healthy work out of the queue while timeout recovery retries it. ([#7998])
- **scheduler:** One scheduled task whose saved schedule can no longer be
computed no longer stops every other task. When a stored timezone or cron
expression stopped working (a zone missing from the time zone database
after an upgrade, a cron expression a newer croniter rejects), recomputing
its next run raised inside the scheduler poll; once its launch lease
expired, every later poll failed before admitting any due task. That task
is now paused with the reason as its last error, and resuming it reports
the problem until the schedule is fixed. ([#7993])
- **models:** A Claude model using a Claude Code OAuth token no longer fails
every request once the request carries `betas`. The OAuth betas
(`oauth-2025-04-20`, …) were only the client's default `anthropic-beta`
header, and the SDK sends a request's `betas` as that header instead, so a
configured `betas` list (even `[]`), per-call `betas`, or a server tool for
which langchain-anthropic adds a beta left them out and Anthropic rejected
the token. The OAuth betas now lead any request `betas` and any
`anthropic-beta` in `extra_headers`.
([#8061](https://github.com/bytedance/deer-flow/pull/8061))
- **community:** A search or fetch API key that ends in a newline no longer
leaks into the conversation. GroundRoute (`GROUNDROUTE_API_KEY`), InfoQuest,
Tavily, Exa, Jina, Firecrawl, fastCRW and Crawl4AI put the key into an HTTP
header as read, so a key from a file (a Kubernetes Secret created with
`--from-file`) made the transport fail with `Illegal header value b'Bearer
<key>\n'`. That message, key included, became the tool result the model and
the conversation history kept, and four of them also logged it. Keys now go
through `provider_credentials.resolve_api_key` like the other providers', and
a blank configured key falls back to the environment for Tavily, Exa,
Firecrawl and fastCRW. fastCRW without a key of its own no longer lets the
Firecrawl SDK send `FIRECRAWL_API_KEY` to the fastCRW endpoint, and a blank
`TAVILY_API_KEY`, `EXA_API_KEY` or `FIRECRAWL_API_KEY` now counts as missing
instead of reaching the SDK as is. ([#7981])
- **community:** Browserless is the last search/fetch provider that read its
token raw. A `BROWSERLESS_TOKEN` or configured `token` from a file kept its
trailing newline and became the auth query parameter, so every `web_fetch`
and `web_capture` request failed authentication while the key was correct.
Both now go through `provider_credentials.resolve_api_key`, and only an
omitted `token` key falls back to the environment: an explicit `""` or
`null` keeps opting a self-hosted capture endpoint out of authentication.
([#8043])
- **docker:** Images built from a checkout no longer contain its local
configuration. `.dockerignore` did not exclude `config.yaml`,
`config.yaml.bak`, `extensions_config.json` or `mcp_config.json`, so a
checkout that keeps them in the legacy `backend/` location baked its model
API keys and MCP credentials into every gateway image layer through
`COPY backend ./backend`. `frontend/.env.local` reached the frontend image the
same way. They are now excluded at the root and in `backend/`, along with
`.env*.local` at any depth, `frontend/*.pem`, and the credential files the
extension manager refuses inside extension snapshots. A locally built
frontend image no longer inlines `NEXT_PUBLIC_*` values from
`frontend/.env.local`; the Docker stack's single nginx origin needs none of
them. Published CI images were not affected; rebuild local images that were
pushed or shared. ([#7958])
- **agents:** Unfinished todos no longer trigger additional model calls or
tool execution after a loop-detection, run token-budget, provider-safety, or
model output-length stop. Todo state is preserved so users can continue in a
later run. ([#7953])
- **runtime:** A thread can accept new runs and mutations after a transient
database failure leaves a completed operation's reservation in place. The
next request for the thread on the same Gateway worker retries the release,
including in single-worker deployments with lease heartbeats disabled.
([#8068](https://github.com/bytedance/deer-flow/pull/8068))
- **channels:** Long Feishu/Lark answers are no longer lost. Card text was
unbounded and ASCII-escaped, so an answer of about 4,400 CJK characters made
the card request exceed Feishu's 30 KB limit (`230025`); the final patch and
its reply fallback were both rejected, and the running card stayed on its
last streamed text or a bare `▉`. Cards now send CJK unescaped and are
budgeted by the bytes they add to the request. A streamed update shows what
fits, and a longer final answer continues in up to 10 cards replied to the
same message, split at line breaks; a retry resumes after the cards already
delivered. ([#7798])
- **channels:** WeCom group messages now have their own conversation. Every
message used the sender's userid as both chat and topic ID, so a member's
private chat and every group they wrote in shared one DeerFlow thread
wherever no group binding kept them apart: in auth-disabled mode, with
`require_bound_identity: false`, and under legacy group bindings. A group
reply could then draw on the member's private chat or another group, and a
reply sent without its frame went to the private chat. Groups are now keyed
by `chatid` with one thread per member, and private chats keep their
threads. A group bound with its own `/connect` keeps its thread; a legacy
group binding, which covers every group of the bot, starts a new one in
each group. ([#7794])
- **memory:** DeerMem rejects backups with duplicate fact IDs before replacing
saved memory, regardless of entry order. ([#8151])
- **memory:** Importing a backup without a `facts` array now returns HTTP 422
and preserves saved memory. An explicit empty array still clears the selected
fact bucket. ([#8131])
- **memory:** An agent-scoped memory write whose `agent_name` is a Windows
reserved device name (`NUL`, `CON`, `COM1`, ...) is now rejected with a `422`
on the two routes that create that scope, `POST /memory/facts` and
`POST /memory/import`. DeerMem resolves the scope to
`{storage}/users/{uid}/agents/{name}/facts` and creates it on the first write;
the character-class check admitted such names, so the write raised
`FileNotFoundError` and the endpoint answered an opaque `500`. The check reuses
`windows_incompatible_segment()` and applies on every platform. Read and delete
routes stay permissive, so a scope with a reserved-name identifier that an
older release stored on a Linux host can still be exported and cleared.
([#7941])
- **skills:** A custom skill name that is a Windows reserved device name
(`nul`, `con`, `com1`, ...) is now rejected when the skill is created —
`skill_manage` `create` and `.skill` archive install — on every platform. The
hyphen-case grammar admitted such names, and the skill name becomes a
directory under `custom/`, so creating one "succeeded" on Windows without
producing a directory and the first write inside it failed with
`FileNotFoundError`; on Linux the install succeeded but could never be
copied, mounted or exported to a Windows host. The archive path also refuses
member path segments that cannot be created on every host, matching support
file writes. Reading and deleting an existing skill stay permissive, so a
skill an older release created on a Linux host can still be inspected and
removed. ([#7945])
- **sandbox:** Fresh lazy AIO runs with an explicit authorized skill view no
longer create or acquire a sandbox before the first sandbox-backed tool
call. Existing sandbox bindings and providers that require skill uploads
retain their eager synchronization behavior. ([#7782])
- **parsing:** Follow-up suggestions and the `/goal` evaluator keep a literal
`<think>` tag that appears inside a JSON string value, such as an answer
quoting the tag, while reasoning around the JSON is still removed. An
unclosed literal tag inside a string no longer truncates the document and
fails parsing, and reasoning outside the payload still strips and truncates
as before. ([#7907])
- **gateway:** A caller-supplied thread ID that is a Windows reserved device
name (`NUL`, `CON`, `COM1`, ...) is now rejected at the shared
`validate_thread_id()` boundary. Such IDs previously passed validation but
failed workspace creation on Windows hosts with `FileNotFoundError`. The
check reuses `windows_incompatible_segment()` and applies on every platform,
so threads with reserved-name IDs created on Linux hosts will fail
validation on read/resume/upload routes after upgrade (deletion still
works). ([#7892])
- **extensions:** Context-compaction observers now learn which task a
compaction happened in. Inside a lead run or subagent they receive that
task's store instead of a detached one, and `CompactionEvent` gains
`task_id`, `run_id` and `thread_id` (default `None`), copied from the
`TaskInfo` the host now seeds into every task store even when no lifecycle
contributor is registered. A manual `/compact` still gets a detached store
and `None` ids. `deerflow-extension-api` is now 0.2.10; existing
extensions keep working. ([#7831])
- **channels:** A DingTalk reply in AI card mode (`card_template_id`) is no
longer lost when a card request fails, for example without card permission
or during a DingTalk outage. dingtalk-stream logs a failed card request and
returns as if it succeeded, so neither the text running reply nor the
`sampleMarkdown` fallback for the final answer ever ran. The channel now
sends card requests itself and falls back when one fails. ([#7781])
- **harness:** Retrying a failed item no longer reopens a cancelled durable
subagent batch. `retry_item` kept only a paused batch as it was, so a failed
item of a cancelled batch put the batch back to `queued`, the worker claimed
and ran the item, and the batch ended `completed`. The retry route now
answers 409 for a cancelled batch, including when the cancel commits while
the retry is in flight on SQLite, and the workspace no longer offers Retry
there. Retrying in a completed, failed, running or paused batch is
unchanged. ([#7792])
- **gateway:** A run that names a missing or invalid custom agent now says so.
Messaging a chat whose custom agent was deleted, or passing a malformed
`agent_name`, returned the 422 detail "knowledge_scope assistant configuration
could not be resolved" even when no knowledge scope was involved. The 422 now
reports `Agent '<name>' not found`, an invalid `agent_name`, or an agent
config that could not be loaded. ([#8087])
- **agents:** `PUT /api/agents/{name}` with `"description": null` now resets the
description to empty. It used to store the null, which the agent config
rejects, so the agent then failed every load: the update, GET and repair
updates returned 500, the file backend dropped it from the agent list, the
database backend failed the owner's whole list, and chats with it failed.
Agents already stored with a null description load again. ([#7638])
- **sandbox:** Cancelled native Runs now stop their owned AIO and POSIX local
bash processes, including ordinary `nohup` children, before releasing the
execution lease. Concurrent work and background services from successful
earlier turns remain running. ([#7772])
- **agents:** A clarification no longer overwrites an earlier question when the
provider reuses a tool-call ID across turns (for example `call_0` on every
response). Its message ID, which is also the human-input `request_id`, named
only the call ID, so the later question replaced the earlier one in place and
the later call was left without a result. The ID now also names the
AIMessage that issued the call; a retry of the same call keeps its ID.
([#7762])
- **projects:** The trash retention sweep no longer deletes live document
originals when the data directory, `users/`, a user's directory or a
user's `projects/` is a symlink. It protected each document by its resolved
path but walked the unresolved tree, so no document matched and every
original older than 24 hours was removed as an orphan, at Gateway startup
and from `GET /api/trash/documents`. ([#7732])
- **chat:** Cancelling a queued input after dispatch starts no longer
prevents subsequent queued inputs from executing. ([#7746])
- **dev:** `scripts/dev_multi_instance.sh logs all` now prints the nginx logs
as well as both Gateway logs; it printed only the Gateway logs, the same as
`logs` with no argument. `logs nginx` and `logs all` also include `nginx.log`,
the nginx start command's own output, which no option printed before.
([#7723])
- **memory:** `GET /api/memory/config` and `/api/memory/status` now mask
credential values in `backend_config` (`api_key`, tokens, passwords,
`Authorization` headers, at any depth) as `***`. They returned the config as
loaded, with `$VAR` references already resolved, so a DeerMem extraction
model key or a Honcho `api_key` reached every caller with `memory:read` in
plaintext, which with authorization disabled (the default) is every
signed-in user. Non-secret knobs, `api_key_env` names and unset values are
unchanged. ([#7665])
- **gateway:** `GET /threads/{thread_id}/token-usage` now resolves the live
config in a worker thread. `build_context_usage` called the accessor inline,
and `get_app_config()` revalidates on every call — it digests the whole
`config.yaml`, then `extensions_config.json` — so a config edit landing just
before a thread view stalled the Gateway event loop that runs, streams and
every other thread share. The response is unchanged. ([#7727])
- **community:** The shared SSRF guard now returns its documented `Error: ...`
rejection for malformed URLs instead of raising `ValueError`.
`validate_public_http_url` and `validate_delegated_backend_url` called
`urlparse()` unguarded, so an authority it rejects, such as the unmatched
IPv6 bracket in `http://[::1/api`, escaped to the caller. That surfaced as an
unhandled 500 from the personal MCP router and as generic exception text in
the crawl4ai and fastCRW `web_fetch` tools. Both validators now reject any
malformed authority before the scheme check and the private-address escape
hatches, including a raw bracket in userinfo, which RFC 3986 forbids and
Python versions disagree on. This is a fail-closed contract fix plus
version-drift hardening: no host reachable through a well-formed URL becomes
blocked. ([#8079])
## [2.2.0] — 2026-10-29 ## [2.2.0] — 2026-10-29
This section accumulates work toward the **2.2.0** milestone This section accumulates work toward the **2.2.0** milestone
@ -14323,7 +13838,8 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
- **ci:** Consolidate PR/issue labeling and fix the reviewing-job crash and - **ci:** Consolidate PR/issue labeling and fix the reviewing-job crash and
label thrash. ([#3455]) label thrash. ([#3455])
[Unreleased]: https://github.com/bytedance/deer-flow/compare/v2.1.0...HEAD [Unreleased]: https://github.com/bytedance/deer-flow/compare/v2.2.0...HEAD
[2.2.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.2.0
[2.1.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.1.0 [2.1.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.1.0
[2.0.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.0.0 [2.0.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.0.0
@ -15165,7 +14681,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5217]: https://github.com/bytedance/deer-flow/pull/5217 [#5217]: https://github.com/bytedance/deer-flow/pull/5217
[#5219]: https://github.com/bytedance/deer-flow/pull/5219 [#5219]: https://github.com/bytedance/deer-flow/pull/5219
[#5221]: https://github.com/bytedance/deer-flow/pull/5221 [#5221]: https://github.com/bytedance/deer-flow/pull/5221
[#5224]: https://github.com/bytedance/deer-flow/commit/572744975d369fda88168aaa7b481282f939affe [#5224]: https://github.com/bytedance/deer-flow/pull/5224
[#5225]: https://github.com/bytedance/deer-flow/pull/5225 [#5225]: https://github.com/bytedance/deer-flow/pull/5225
[#5227]: https://github.com/bytedance/deer-flow/pull/5227 [#5227]: https://github.com/bytedance/deer-flow/pull/5227
[#5228]: https://github.com/bytedance/deer-flow/pull/5228 [#5228]: https://github.com/bytedance/deer-flow/pull/5228
@ -15174,9 +14690,9 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5236]: https://github.com/bytedance/deer-flow/pull/5236 [#5236]: https://github.com/bytedance/deer-flow/pull/5236
[#5238]: https://github.com/bytedance/deer-flow/pull/5238 [#5238]: https://github.com/bytedance/deer-flow/pull/5238
[#5239]: https://github.com/bytedance/deer-flow/pull/5239 [#5239]: https://github.com/bytedance/deer-flow/pull/5239
[#5244]: https://github.com/bytedance/deer-flow/commit/9ce6fdcb2252ea005b4fa98ca50bd3857e1f681a [#5244]: https://github.com/bytedance/deer-flow/pull/5244
[#5245]: https://github.com/bytedance/deer-flow/pull/5245 [#5245]: https://github.com/bytedance/deer-flow/pull/5245
[#5247]: https://github.com/bytedance/deer-flow/commit/fa89a125267be4213009376ec0e232a0c2411ca8 [#5247]: https://github.com/bytedance/deer-flow/pull/5247
[#5249]: https://github.com/bytedance/deer-flow/pull/5249 [#5249]: https://github.com/bytedance/deer-flow/pull/5249
[#5251]: https://github.com/bytedance/deer-flow/pull/5251 [#5251]: https://github.com/bytedance/deer-flow/pull/5251
[#5254]: https://github.com/bytedance/deer-flow/pull/5254 [#5254]: https://github.com/bytedance/deer-flow/pull/5254
@ -15233,7 +14749,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5367]: https://github.com/bytedance/deer-flow/pull/5367 [#5367]: https://github.com/bytedance/deer-flow/pull/5367
[#5369]: https://github.com/bytedance/deer-flow/pull/5369 [#5369]: https://github.com/bytedance/deer-flow/pull/5369
[#5371]: https://github.com/bytedance/deer-flow/pull/5371 [#5371]: https://github.com/bytedance/deer-flow/pull/5371
[#5373]: https://github.com/bytedance/deer-flow/commit/d5ae3882b6708fa9af7289a0c3c0be3eee157203 [#5373]: https://github.com/bytedance/deer-flow/pull/5373
[#5374]: https://github.com/bytedance/deer-flow/pull/5374 [#5374]: https://github.com/bytedance/deer-flow/pull/5374
[#5375]: https://github.com/bytedance/deer-flow/pull/5375 [#5375]: https://github.com/bytedance/deer-flow/pull/5375
[#5377]: https://github.com/bytedance/deer-flow/pull/5377 [#5377]: https://github.com/bytedance/deer-flow/pull/5377
@ -15260,7 +14776,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5408]: https://github.com/bytedance/deer-flow/pull/5408 [#5408]: https://github.com/bytedance/deer-flow/pull/5408
[#5410]: https://github.com/bytedance/deer-flow/pull/5410 [#5410]: https://github.com/bytedance/deer-flow/pull/5410
[#5411]: https://github.com/bytedance/deer-flow/pull/5411 [#5411]: https://github.com/bytedance/deer-flow/pull/5411
[#5413]: https://github.com/bytedance/deer-flow/commit/daf32cd24632f2cc7465aeb62f2a1e1e768eceb2 [#5413]: https://github.com/bytedance/deer-flow/pull/5413
[#5415]: https://github.com/bytedance/deer-flow/pull/5415 [#5415]: https://github.com/bytedance/deer-flow/pull/5415
[#5416]: https://github.com/bytedance/deer-flow/pull/5416 [#5416]: https://github.com/bytedance/deer-flow/pull/5416
[#5418]: https://github.com/bytedance/deer-flow/pull/5418 [#5418]: https://github.com/bytedance/deer-flow/pull/5418
@ -15443,8 +14959,8 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5736]: https://github.com/bytedance/deer-flow/pull/5736 [#5736]: https://github.com/bytedance/deer-flow/pull/5736
[#5738]: https://github.com/bytedance/deer-flow/pull/5738 [#5738]: https://github.com/bytedance/deer-flow/pull/5738
[#5739]: https://github.com/bytedance/deer-flow/pull/5739 [#5739]: https://github.com/bytedance/deer-flow/pull/5739
[#5740]: https://github.com/bytedance/deer-flow/commit/e7128dc7d6b4d49153b10f7276f010384ec4e6af [#5740]: https://github.com/bytedance/deer-flow/pull/5740
[#5741]: https://github.com/bytedance/deer-flow/commit/363b0fc1b4a5bd6c142d1f1df9fb9743cca550a3 [#5741]: https://github.com/bytedance/deer-flow/pull/5741
[#5745]: https://github.com/bytedance/deer-flow/pull/5745 [#5745]: https://github.com/bytedance/deer-flow/pull/5745
[#5748]: https://github.com/bytedance/deer-flow/pull/5748 [#5748]: https://github.com/bytedance/deer-flow/pull/5748
[#5750]: https://github.com/bytedance/deer-flow/pull/5750 [#5750]: https://github.com/bytedance/deer-flow/pull/5750
@ -15457,7 +14973,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5760]: https://github.com/bytedance/deer-flow/pull/5760 [#5760]: https://github.com/bytedance/deer-flow/pull/5760
[#5761]: https://github.com/bytedance/deer-flow/pull/5761 [#5761]: https://github.com/bytedance/deer-flow/pull/5761
[#5762]: https://github.com/bytedance/deer-flow/pull/5762 [#5762]: https://github.com/bytedance/deer-flow/pull/5762
[#5763]: https://github.com/bytedance/deer-flow/commit/39351315bf5d096fc84ad8307d3702b6aa5b3e24 [#5763]: https://github.com/bytedance/deer-flow/pull/5763
[#5767]: https://github.com/bytedance/deer-flow/pull/5767 [#5767]: https://github.com/bytedance/deer-flow/pull/5767
[#5769]: https://github.com/bytedance/deer-flow/pull/5769 [#5769]: https://github.com/bytedance/deer-flow/pull/5769
[#5775]: https://github.com/bytedance/deer-flow/pull/5775 [#5775]: https://github.com/bytedance/deer-flow/pull/5775
@ -15525,7 +15041,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5880]: https://github.com/bytedance/deer-flow/pull/5880 [#5880]: https://github.com/bytedance/deer-flow/pull/5880
[#5881]: https://github.com/bytedance/deer-flow/pull/5881 [#5881]: https://github.com/bytedance/deer-flow/pull/5881
[#5883]: https://github.com/bytedance/deer-flow/pull/5883 [#5883]: https://github.com/bytedance/deer-flow/pull/5883
[#5884]: https://github.com/bytedance/deer-flow/commit/e8cb5e9c4f5f274401eeb555aff62e713035cbf4 [#5884]: https://github.com/bytedance/deer-flow/pull/5884
[#5888]: https://github.com/bytedance/deer-flow/pull/5888 [#5888]: https://github.com/bytedance/deer-flow/pull/5888
[#5890]: https://github.com/bytedance/deer-flow/pull/5890 [#5890]: https://github.com/bytedance/deer-flow/pull/5890
[#5892]: https://github.com/bytedance/deer-flow/pull/5892 [#5892]: https://github.com/bytedance/deer-flow/pull/5892
@ -15587,9 +15103,9 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5999]: https://github.com/bytedance/deer-flow/pull/5999 [#5999]: https://github.com/bytedance/deer-flow/pull/5999
[#6000]: https://github.com/bytedance/deer-flow/pull/6000 [#6000]: https://github.com/bytedance/deer-flow/pull/6000
[#6008]: https://github.com/bytedance/deer-flow/pull/6008 [#6008]: https://github.com/bytedance/deer-flow/pull/6008
[#6009]: https://github.com/bytedance/deer-flow/commit/b87ebd940227465d611084e5bf49582aaeb4e9bd [#6009]: https://github.com/bytedance/deer-flow/pull/6009
[#6013]: https://github.com/bytedance/deer-flow/pull/6013 [#6013]: https://github.com/bytedance/deer-flow/pull/6013
[#6015]: https://github.com/bytedance/deer-flow/commit/f87a04e581829ff35afd4fa228240071b486192f [#6015]: https://github.com/bytedance/deer-flow/pull/6015
[#6017]: https://github.com/bytedance/deer-flow/pull/6017 [#6017]: https://github.com/bytedance/deer-flow/pull/6017
[#6018]: https://github.com/bytedance/deer-flow/pull/6018 [#6018]: https://github.com/bytedance/deer-flow/pull/6018
[#6020]: https://github.com/bytedance/deer-flow/pull/6020 [#6020]: https://github.com/bytedance/deer-flow/pull/6020
@ -15613,7 +15129,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6060]: https://github.com/bytedance/deer-flow/pull/6060 [#6060]: https://github.com/bytedance/deer-flow/pull/6060
[#6062]: https://github.com/bytedance/deer-flow/pull/6062 [#6062]: https://github.com/bytedance/deer-flow/pull/6062
[#6063]: https://github.com/bytedance/deer-flow/pull/6063 [#6063]: https://github.com/bytedance/deer-flow/pull/6063
[#6066]: https://github.com/bytedance/deer-flow/commit/73fdbcfdf4b5f4037b62bc2ce9d8b60ec04e69f8 [#6066]: https://github.com/bytedance/deer-flow/pull/6066
[#6067]: https://github.com/bytedance/deer-flow/pull/6067 [#6067]: https://github.com/bytedance/deer-flow/pull/6067
[#6068]: https://github.com/bytedance/deer-flow/pull/6068 [#6068]: https://github.com/bytedance/deer-flow/pull/6068
[#6069]: https://github.com/bytedance/deer-flow/pull/6069 [#6069]: https://github.com/bytedance/deer-flow/pull/6069
@ -15639,7 +15155,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6105]: https://github.com/bytedance/deer-flow/pull/6105 [#6105]: https://github.com/bytedance/deer-flow/pull/6105
[#6107]: https://github.com/bytedance/deer-flow/pull/6107 [#6107]: https://github.com/bytedance/deer-flow/pull/6107
[#6109]: https://github.com/bytedance/deer-flow/pull/6109 [#6109]: https://github.com/bytedance/deer-flow/pull/6109
[#6112]: https://github.com/bytedance/deer-flow/commit/52b21d13ac6e674a19cb76013d097e73f108c994 [#6112]: https://github.com/bytedance/deer-flow/pull/6112
[#6113]: https://github.com/bytedance/deer-flow/pull/6113 [#6113]: https://github.com/bytedance/deer-flow/pull/6113
[#6115]: https://github.com/bytedance/deer-flow/pull/6115 [#6115]: https://github.com/bytedance/deer-flow/pull/6115
[#6117]: https://github.com/bytedance/deer-flow/pull/6117 [#6117]: https://github.com/bytedance/deer-flow/pull/6117
@ -15740,7 +15256,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6298]: https://github.com/bytedance/deer-flow/pull/6298 [#6298]: https://github.com/bytedance/deer-flow/pull/6298
[#6299]: https://github.com/bytedance/deer-flow/pull/6299 [#6299]: https://github.com/bytedance/deer-flow/pull/6299
[#6302]: https://github.com/bytedance/deer-flow/pull/6302 [#6302]: https://github.com/bytedance/deer-flow/pull/6302
[#6304]: https://github.com/bytedance/deer-flow/commit/b85e4ae74bb407e1e2396243a8a6e8f5b2420e3f [#6304]: https://github.com/bytedance/deer-flow/pull/6304
[#6305]: https://github.com/bytedance/deer-flow/pull/6305 [#6305]: https://github.com/bytedance/deer-flow/pull/6305
[#6306]: https://github.com/bytedance/deer-flow/pull/6306 [#6306]: https://github.com/bytedance/deer-flow/pull/6306
[#6307]: https://github.com/bytedance/deer-flow/pull/6307 [#6307]: https://github.com/bytedance/deer-flow/pull/6307
@ -16124,7 +15640,6 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7017]: https://github.com/bytedance/deer-flow/pull/7017 [#7017]: https://github.com/bytedance/deer-flow/pull/7017
[#7018]: https://github.com/bytedance/deer-flow/pull/7018 [#7018]: https://github.com/bytedance/deer-flow/pull/7018
[#7020]: https://github.com/bytedance/deer-flow/pull/7020 [#7020]: https://github.com/bytedance/deer-flow/pull/7020
[#7022]: https://github.com/bytedance/deer-flow/issues/7022
[#7023]: https://github.com/bytedance/deer-flow/pull/7023 [#7023]: https://github.com/bytedance/deer-flow/pull/7023
[#7024]: https://github.com/bytedance/deer-flow/pull/7024 [#7024]: https://github.com/bytedance/deer-flow/pull/7024
[#7031]: https://github.com/bytedance/deer-flow/pull/7031 [#7031]: https://github.com/bytedance/deer-flow/pull/7031
@ -16397,47 +15912,3 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7643]: https://github.com/bytedance/deer-flow/pull/7643 [#7643]: https://github.com/bytedance/deer-flow/pull/7643
[#7651]: https://github.com/bytedance/deer-flow/pull/7651 [#7651]: https://github.com/bytedance/deer-flow/pull/7651
[#7659]: https://github.com/bytedance/deer-flow/pull/7659 [#7659]: https://github.com/bytedance/deer-flow/pull/7659
[#7638]: https://github.com/bytedance/deer-flow/pull/7638
[#7665]: https://github.com/bytedance/deer-flow/pull/7665
[#7723]: https://github.com/bytedance/deer-flow/pull/7723
[#7727]: https://github.com/bytedance/deer-flow/pull/7727
[#7729]: https://github.com/bytedance/deer-flow/pull/7729
[#7732]: https://github.com/bytedance/deer-flow/pull/7732
[#7746]: https://github.com/bytedance/deer-flow/pull/7746
[#7762]: https://github.com/bytedance/deer-flow/pull/7762
[#7772]: https://github.com/bytedance/deer-flow/pull/7772
[#7781]: https://github.com/bytedance/deer-flow/pull/7781
[#7782]: https://github.com/bytedance/deer-flow/pull/7782
[#7792]: https://github.com/bytedance/deer-flow/pull/7792
[#7794]: https://github.com/bytedance/deer-flow/pull/7794
[#7798]: https://github.com/bytedance/deer-flow/pull/7798
[#7831]: https://github.com/bytedance/deer-flow/pull/7831
[#7892]: https://github.com/bytedance/deer-flow/pull/7892
[#7907]: https://github.com/bytedance/deer-flow/pull/7907
[#7941]: https://github.com/bytedance/deer-flow/pull/7941
[#7945]: https://github.com/bytedance/deer-flow/issues/7945
[#7953]: https://github.com/bytedance/deer-flow/issues/7953
[#7958]: https://github.com/bytedance/deer-flow/pull/7958
[#7981]: https://github.com/bytedance/deer-flow/pull/7981
[#7992]: https://github.com/bytedance/deer-flow/pull/7992
[#7993]: https://github.com/bytedance/deer-flow/pull/7993
[#7998]: https://github.com/bytedance/deer-flow/issues/7998
[#8006]: https://github.com/bytedance/deer-flow/pull/8006
[#8019]: https://github.com/bytedance/deer-flow/pull/8019
[#8020]: https://github.com/bytedance/deer-flow/pull/8020
[#8043]: https://github.com/bytedance/deer-flow/pull/8043
[#8046]: https://github.com/bytedance/deer-flow/pull/8046
[#8057]: https://github.com/bytedance/deer-flow/pull/8057
[#8064]: https://github.com/bytedance/deer-flow/pull/8064
[#8069]: https://github.com/bytedance/deer-flow/issues/8069
[#8071]: https://github.com/bytedance/deer-flow/issues/8071
[#8072]: https://github.com/bytedance/deer-flow/pull/8072
[#8079]: https://github.com/bytedance/deer-flow/pull/8079
[#8087]: https://github.com/bytedance/deer-flow/pull/8087
[#8117]: https://github.com/bytedance/deer-flow/pull/8117
[#8118]: https://github.com/bytedance/deer-flow/pull/8118
[#8126]: https://github.com/bytedance/deer-flow/pull/8126
[#8131]: https://github.com/bytedance/deer-flow/pull/8131
[#8139]: https://github.com/bytedance/deer-flow/pull/8139
[#8151]: https://github.com/bytedance/deer-flow/pull/8151
[#8174]: https://github.com/bytedance/deer-flow/pull/8174

View File

@ -9,203 +9,6 @@
## [未发布] ## [未发布]
本节累积面向 **2.3.0** 里程碑([2.3.0](https://github.com/bytedance/deer-flow/milestone/4))的工作。
### 新增
- **子智能体:** 后台批次可声明条目前置关系,并可登记一次主 Agent 接续,在批次结束后完成
用户已要求的汇总或交付。接续保留持久化启动记录、取消和授权检查;启用 Goal 时仍由 Goal 负责续跑。([#7839](https://github.com/bytedance/deer-flow/pull/7839))
- **对话:** 网页端在任务运行时,可在输入框选择 Queue(排队执行)或 Steer(调整当前任务)。
Queue 等当前任务结束后再执行下一条消息;Steer 让主 Agent 先处理新要求,原生子 Agent 可暂停后恢复。
Gateway 重启后,尚未确认生效的消息可以手动重试。([#7729])
- **模型:** 管理员可在 **设置 → 模型** 中选择 StepFun(阶跃星辰),
填写 API Key 和模型 ID 即可接入普通 API。([#7988](https://github.com/bytedance/deer-flow/pull/7988))
### 变更
- **Checkpoint:** DeltaChannel 部署现在要求 `langgraph-checkpoint` 4.3.0、
SQLite saver 3.1.2 和 Postgres saver 3.1.3,排除会在重放时重排并行写入或
丢失分页祖先历史的版本。顺序和 Postgres 分页契约在回归时现在会直接失败,
不再跳过。([#8139])
### 修复
- **前端:** 任务表单的 Cron 预设现在要求分钟 0-59、小时 0-23、日期 1-31,
超出范围的表达式(例如 `0 24 * * *`)会保持为自定义并保留原始文本。此前它会被识别为预设,
在序列化以供提交时可能被限制到合法范围内的另一个时间。使用非法表达式创建或复制任务,
或在表达式仍非法时更新调度配置,会由 Gateway 返回 `422`;仅修改其他字段不会重新校验
未变化的调度配置。([#8020])
- **网关:** 创建对话时在元数据中带上 `step`、`parents`、`counters_since_delta_snapshot`
等由检查点自身维护的键,不再让该对话损坏。此前这些键会写入对话的首个检查点,错误的 `parents`
会让该对话的历史记录永久返回 500,错误的 `step` 或增量计数器会让之后每一轮对话都失败;
`deerflow_agent_name` 还会被当作服务端写入的值复制到下一次状态更新中。现在首个检查点不再从
请求中接收这些键。([#8019])
- **通道:** 企业微信不再丢弃 2–10 MB 的图片。此前适配器把图片上限定为 2 MB(同一协议中语音的限制),
超出即在上传前被跳过,消息到达时缺少图片。现在图片上限与平台的 10 MB 对齐;普通文件 20 MB 的限制不变。([#8174])
- **通道:** WeCom 群聊中 `@机器人 /new` 这类带提及的命令现在按命令执行。WeCom 会把提及保留在
正文里,而命令识别要求裸的前导 `/`,于是群里的 `/new`、`/status` 被当成聊天轮次。现在仅在命令
识别时跳过前导提及;普通聊天保留提及,`/connect` 行为不变。与飞书、钉钉的处理一致。([#8118])
- **前端:** 侧边栏对话菜单中的 **分享** 现在复制指向当前部署的链接。此前在 `localhost` 和
`127.0.0.1`(`make dev` 与 Docker 的默认地址)上,复制的是托管演示站
(`deer-flow-v2.vercel.app`)的链接:该链接无法打开对话,打开时还会把线程 ID 发送到外部主机。
([#8126])
- **运行时:** 多 Worker 模式(`run_ownership.heartbeat_enabled`)下,已被其他 Worker
接管的运行不再被原 Worker 恢复。此前装配证据通过无条件的整行 upsert 写入,在对端接管后会把
行改回 `running` 并清除接管错误;运行可能在对端已报告失败后以 `success` 结束,或被再次接管。
现在仅当该 Worker 仍持有活跃运行的有效租约时,才将证据合并到行元数据
(`RunStore.merge_owned_metadata()`),否则在执行前停止该运行。启用心跳的自定义
`RunStore` 实现必须实现该方法。([#8046])
- **网关:** 同一用户对同一次运行重复提交反馈时,现在返回 409 而不是 500。
`POST /api/threads/{id}/runs/{run_id}/feedback` 每个用户每次运行只允许一条反馈;此前重试,
或对同一次运行的另一条消息提交反馈,会触发该唯一约束,Gateway 因未处理的数据库错误而失败。
409 会提示调用方改用同一路由的 `PUT` 更新已有反馈。([#8064])
- **网关:** 删除线程时不再在对话仍可读取的情况下返回成功。此前
`DELETE /api/threads/{id}` 在检查点存储失败时只记录 DEBUG 日志,仍删除线程元数据行并
返回成功;之后任何已登录用户都能通过 `GET /threads/{id}`、`/state`、`/history` 和
`/goal` 读取残留的标题和消息,所有者也无法重试。现在删除会返回 500 并保留线程,所有者
可以再次删除。([#7992])
- **模型:** 取消使用 Codex 的任务时,会关闭上游 HTTP/SSE 连接,包括等待响应头期间;
重试等待也可取消。本地资源会释放,远端服务仍可能继续生成。([#8053](https://github.com/bytedance/deer-flow/pull/8053))
- **中间件:** 启用 PII 脱敏时,不含空格的长文本(例如粘贴的十六进制转储)不再让 Gateway
卡住。此前邮箱检测器的耗时与这类文本长度呈平方关系,而脱敏会在每次模型调用时重新扫描全部
用户消息,因此一次 100 KB 的粘贴会让每次模型调用阻塞所有线程约 6 秒。现在扫描为线性耗时,
且脱敏的地址与此前完全相同。([#8006])
- **调度器:** 队列超时扫描或队列排空中的单条记录出现异常时,调度器会记录该失败并继续处理剩余队列,以及同一轮启动的到期任务。过期记录会在所有者排名和批次限额之前被排除,启动领取还会再次检查同一截止时间,因此超时清理失败时遗留的排队记录既不会启动,也不会挤占正常排队工作的候选位置,只会继续等待超时恢复流程处理。([#7998])
- **调度器:** 一个已保存的计划无法再计算的定时任务,不再让其他所有任务停止运行。此前
已保存的时区或 cron 表达式失效时(例如升级后时区数据库中缺少该时区,或新版 croniter
不再接受该表达式),在调度器轮询中计算其下一次运行时间会抛出异常;其启动租约过期后,
之后每次轮询都会在接纳到期任务之前失败。现在该任务会被暂停,并把原因记录为最近错误;
在修复计划之前,恢复该任务会提示具体问题。([#7993])
- **模型:** 使用 Claude Code OAuth token 的 Claude 模型,不再在请求带有 `betas` 时每次
都失败。此前 OAuth 所需的 beta(`oauth-2025-04-20` 等)只放在客户端默认的
`anthropic-beta` 请求头里,而 SDK 会用请求自身的 `betas` 替换这个请求头;因此只要配置了
`betas`(即使是 `[]`)、调用时传入 `betas`,或使用了 langchain-anthropic 会为其追加 beta
的服务端工具,OAuth beta 就会丢失,Anthropic 随即拒绝该 token。现在 OAuth beta 会排在
请求 `betas` 以及 `extra_headers` 中 `anthropic-beta` 的最前面。
([#8061](https://github.com/bytedance/deer-flow/pull/8061))
- **社区工具:** 以换行结尾的搜索或抓取 API 密钥不再泄露到对话中。GroundRoute
(`GROUNDROUTE_API_KEY`)、InfoQuest、Tavily、Exa、Jina、Firecrawl、fastCRW 和
Crawl4AI 此前把读取到的密钥原样写入 HTTP 请求头;从文件读取的密钥(例如用
`--from-file` 创建的 Kubernetes Secret)会让传输层报出 `Illegal header value
b'Bearer <key>\n'`,这条包含完整密钥的消息成为模型和对话历史保存的工具结果,
其中四个提供方还会把它写入日志。现在这些密钥与其他提供方一样经过
`provider_credentials.resolve_api_key` 处理;Tavily、Exa、Firecrawl 和 fastCRW
的配置密钥为空时会回退到环境变量。fastCRW 没有自己的密钥时,不再让 Firecrawl SDK
把 `FIRECRAWL_API_KEY` 发送到 fastCRW 端点;`TAVILY_API_KEY`、`EXA_API_KEY` 或
`FIRECRAWL_API_KEY` 只含空白时按未设置处理,不再原样交给 SDK。([#7981])
- **社区工具:** Browserless 是最后一个直接读取原始 token 的搜索/抓取提供方。从文件读取的
`BROWSERLESS_TOKEN` 或配置 `token` 会保留尾部换行,并被原样用作鉴权查询参数,导致
`web_fetch` 和 `web_capture` 在密钥本身正确时也全部鉴权失败。现在两者都经过
`provider_credentials.resolve_api_key` 处理,且只有省略 `token` 键时才回退到环境变量:
显式配置 `""` 或 `null` 仍可让自建 capture 端点跳过鉴权。([#8043])
- **docker:** 从本地 checkout 构建的镜像不再包含本地配置。`.dockerignore`
此前没有排除 `config.yaml`、`config.yaml.bak`、`extensions_config.json` 和
`mcp_config.json`,因此配置放在旧位置 `backend/` 时,`COPY backend ./backend`
会把模型 API 密钥和 MCP 凭据写进每一层 gateway 镜像;`frontend/.env.local`
也以同样方式进入前端镜像。现在根目录和 `backend/` 下的这些文件、任意层级的
`.env*.local`、`frontend/*.pem`,以及扩展管理器拒绝写入扩展快照的凭据文件都会
被排除。本地构建的前端镜像不再内联 `frontend/.env.local` 中的 `NEXT_PUBLIC_*`
值;Docker 栈通过同一个 nginx 源提供前端和 Gateway,不需要这些变量。CI 发布的
镜像不受影响;已推送或分享过的本地镜像请重新构建。([#7958])
- **智能体:** 循环检测、运行 token 预算、模型服务安全限制或输出长度限制触发停止后,
未完成的 Todo 不再触发额外的模型调用或工具执行。Todo 状态仍会保留,
用户可在后续运行中继续处理。([#7953])
- **渠道:** 飞书/Lark 的长回答不再丢失。此前卡片文本没有长度上限,并以 ASCII 转义
序列化,约 4,400 个中日韩字符就会让卡片请求超过飞书 30 KB 的限制(`230025`):
最终更新及其回复兜底都会被拒绝,运行中卡片停留在最后一次流式内容或只剩 `▉`。
现在卡片不再转义中日韩字符,并按其在请求中占用的字节计算预算。流式更新只显示能
放下的部分;更长的最终回答会在同一条消息下以最多 10 张卡片按行续发,重试会从
已送达的卡片之后继续。([#7798])
- **渠道:** 企业微信群消息现在拥有独立的会话。此前每条消息都把发送者的 userid
同时用作会话 ID 和话题 ID,因此在没有群绑定加以区分的情况下(免认证模式、
`require_bound_identity: false`,以及旧版群绑定),成员的私聊和其发过言的所有群
共用同一个 DeerFlow 线程:群内回复可能用到该成员私聊或其他群的内容,失去原始
frame 的回复也会被发到私聊。现在群以 `chatid` 为键、每位成员一个线程,私聊线程
保持不变。通过各自 `/connect` 绑定的群保留原有线程;覆盖该机器人所有群的旧版
群绑定会在每个群中开始新线程。([#7794])
- **沙箱:** 使用显式授权技能视图的新 AIO 运行,在启用惰性初始化时,不再于首次调用
沙箱工具前创建或获取沙箱。已有沙箱绑定和需要上传技能的提供方继续保留原有的即时同步行为。
([#7782])
- **扩展:** 上下文压缩观察者现在能知道压缩发生在哪个任务里。在 Lead 运行或子 Agent
内部,观察者收到的是该任务的存储,而不再是分离存储;`CompactionEvent` 新增
`task_id`、`run_id`、`thread_id`(默认 `None`),取自宿主放进每个任务存储的
`TaskInfo`,即使没有注册生命周期贡献者也会放入。手动 `/compact` 仍收到分离存储,
id 为 `None`。`deerflow-extension-api` 升级到 0.2.10,已有扩展不受影响。([#7831])
- **智能体:** 即使模型提供方复用消息 ID,Token 预算也会分别统计已接受的模型调用,
避免重复 ID 绕过配置的上限;触发硬停止时保留已完成的结构化输出工具结果。
([#8050](https://github.com/bytedance/deer-flow/pull/8050))
- **渠道:** 钉钉 AI 卡片模式(`card_template_id`)下,卡片请求失败(例如缺少卡片权限或钉钉服务异常)时
不再丢失回复。dingtalk-stream 会记录失败的卡片请求后按成功返回,因此文本形式的运行中提示和最终答案的
`sampleMarkdown` 兜底都不会执行。现在渠道自行发送卡片请求,并在失败时回退。([#7781])
- **harness:** 重试失败条目不再重新打开已取消的持久子智能体批次。`retry_item`
此前只保留已暂停批次的状态,因此重试已取消批次中的失败条目会把批次改回
`queued`,worker 随即认领并执行该条目,批次最终变为 `completed`。现在对已取消
批次的重试路由返回 409,包括 SQLite 上取消在重试进行中提交的情况;工作区也不再
为这些条目显示重试按钮。在已完成、失败、运行中或已暂停批次中重试的行为不变。
([#7792])
- **网关:** 运行指定的自定义智能体不存在或名称无效时,现在会明确说明原因。此前向一个
自定义智能体已被删除的对话发消息,或传入格式错误的 `agent_name`,即使与知识库范围无关,
也会返回 422 和 "knowledge_scope assistant configuration could not be resolved"。现在
422 会提示 `Agent '<name>' not found`、`agent_name` 无效,或智能体配置无法加载。([#8087])
- **智能体:** `PUT /api/agents/{name}` 传入 `"description": null` 时现在会把描述重置为
空。此前会把 null 原样保存,而智能体配置不接受该值,导致之后每次加载都失败:更新本身、
GET 和修复性更新都返回 500,文件后端会把它从智能体列表中漏掉,数据库后端会让该用户的
整个列表失败,与它的对话也会失败。已以 null 描述保存的智能体现在可以重新加载。([#7638])
- **智能体:** 当模型提供方在不同轮次复用同一个工具调用 ID(例如每次响应都是 `call_0`)时,
澄清提问不再覆盖之前的问题。此前其消息 ID(同时也是人工输入的 `request_id`)只包含调用 ID,
后一个问题会原地替换前一个问题,后一次调用也因此缺少结果。现在 ID 还会包含发起该调用的
AIMessage;同一调用的重试仍保持原 ID。([#7762])
- **项目:** 当数据目录、`users/`、某个用户目录或用户的 `projects/` 是符号链接时,
回收站保留期清扫不再删除仍在使用的文档原件。此前它按解析后的路径保护每个文档,
却遍历未解析的目录树,导致没有任何文档能匹配上,所有超过 24 小时的原件都被当作
孤立文件删除,Gateway 启动时和 `GET /api/trash/documents` 都会触发。([#7732])
- **对话:** 队列分发开始后,取消某条排队输入不再阻止后续排队输入继续执行。([#7746])
- **开发:** `scripts/dev_multi_instance.sh logs all` 现在除两个 Gateway 日志外也会输出
nginx 日志;此前它只输出 Gateway 日志,与不带参数的 `logs` 相同。`logs nginx` 与
`logs all` 还会包含 `nginx.log`(nginx 启动命令自身的输出),此前没有任何选项会输出它。
([#7723])
- **记忆:** `GET /api/memory/config` 与 `/api/memory/status` 现在会把 `backend_config`
中的凭据值(`api_key`、token、密码、`Authorization` 请求头,任意层级)遮蔽为 `***`。
此前它们按加载结果原样返回配置,而 `$VAR` 引用此时已被解析,因此 DeerMem 提取模型的密钥
或 Honcho 的 `api_key` 会以明文返回给所有拥有 `memory:read` 的调用方;在默认关闭授权时,
这就是所有已登录用户。非机密配置项、`api_key_env` 变量名和未设置的值保持不变。([#7665])
- **网关:** `GET /threads/{thread_id}/token-usage` 现在在工作线程里解析实时配置。
此前 `build_context_usage` 直接内联调用该访问器,而 `get_app_config()` 每次调用都会重新校验
——它先对整个 `config.yaml` 取摘要,再对 `extensions_config.json` 做同样的事——所以在有人打开
会话视图前刚好改了配置,就会让运行、消息流与其他线程共用的网关事件循环整段停顿。响应内容不变。([#7727])
## [2.2.0] — 2026-10-29 ## [2.2.0] — 2026-10-29
本节累积面向 **2.2.0** 里程碑([2.2.0](https://github.com/bytedance/deer-flow/milestone/3))的工作。 本节累积面向 **2.2.0** 里程碑([2.2.0](https://github.com/bytedance/deer-flow/milestone/3))的工作。
@ -420,12 +223,6 @@
选择器缺失、是目录或不可读时会在调用 Docker 之前直接失败,而文件删除后 选择器缺失、是目录或不可读时会在调用 Docker 之前直接失败,而文件删除后
`make down` 仍能停止整个栈;不设置或为空的选择器保持可选的仓库根 `.env` `make down` 仍能停止整个栈;不设置或为空的选择器保持可选的仓库根 `.env`
默认值。([#6522]) 默认值。([#6522])
- **HITL:** 新增独立于推理模式的 Ask、Auto、Full 工具权限模式。Auto 通过单独的
无工具模型调用审核具体操作,默认复用现有模型配置;不确定或高风险操作转人工审批。
卡片展示完整参数,支持批准、拒绝、修改参数及可撤销的会话精确授权。主代理审批通过
checkpoint 支持重启恢复;交互式子代理通过属主限定的通道等待,并处理取消和过期。
所有模式均保留部署规则和账户权限,个人/平台 MCP 连接、参数和策略均参与授权绑定。
默认关闭;`config_version` 66。
- **MCP:** 按未变更的服务器缓存完成包装的工具,仅在绑定对账后重新发现缺失或配置 - **MCP:** 按未变更的服务器缓存完成包装的工具,仅在绑定对账后重新发现缺失或配置
发生变化的服务器。将成功但未发现工具的结果与发现失败分开保存,并保留代际、共享 发生变化的服务器。将成功但未发现工具的结果与发现失败分开保存,并保留代际、共享
@ -10812,7 +10609,8 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
- **CI:** 统一 PR / issue 打标签逻辑,修复 reviewing 任务的崩溃与标签抖动。 - **CI:** 统一 PR / issue 打标签逻辑,修复 reviewing 任务的崩溃与标签抖动。
([#3455]) ([#3455])
[未发布]: https://github.com/bytedance/deer-flow/compare/v2.1.0...HEAD [未发布]: https://github.com/bytedance/deer-flow/compare/v2.2.0...HEAD
[2.2.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.2.0
[2.1.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.1.0 [2.1.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.1.0
[2.0.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.0.0 [2.0.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.0.0
@ -11654,7 +11452,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5217]: https://github.com/bytedance/deer-flow/pull/5217 [#5217]: https://github.com/bytedance/deer-flow/pull/5217
[#5219]: https://github.com/bytedance/deer-flow/pull/5219 [#5219]: https://github.com/bytedance/deer-flow/pull/5219
[#5221]: https://github.com/bytedance/deer-flow/pull/5221 [#5221]: https://github.com/bytedance/deer-flow/pull/5221
[#5224]: https://github.com/bytedance/deer-flow/commit/572744975d369fda88168aaa7b481282f939affe [#5224]: https://github.com/bytedance/deer-flow/pull/5224
[#5225]: https://github.com/bytedance/deer-flow/pull/5225 [#5225]: https://github.com/bytedance/deer-flow/pull/5225
[#5227]: https://github.com/bytedance/deer-flow/pull/5227 [#5227]: https://github.com/bytedance/deer-flow/pull/5227
[#5228]: https://github.com/bytedance/deer-flow/pull/5228 [#5228]: https://github.com/bytedance/deer-flow/pull/5228
@ -11663,9 +11461,9 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5236]: https://github.com/bytedance/deer-flow/pull/5236 [#5236]: https://github.com/bytedance/deer-flow/pull/5236
[#5238]: https://github.com/bytedance/deer-flow/pull/5238 [#5238]: https://github.com/bytedance/deer-flow/pull/5238
[#5239]: https://github.com/bytedance/deer-flow/pull/5239 [#5239]: https://github.com/bytedance/deer-flow/pull/5239
[#5244]: https://github.com/bytedance/deer-flow/commit/9ce6fdcb2252ea005b4fa98ca50bd3857e1f681a [#5244]: https://github.com/bytedance/deer-flow/pull/5244
[#5245]: https://github.com/bytedance/deer-flow/pull/5245 [#5245]: https://github.com/bytedance/deer-flow/pull/5245
[#5247]: https://github.com/bytedance/deer-flow/commit/fa89a125267be4213009376ec0e232a0c2411ca8 [#5247]: https://github.com/bytedance/deer-flow/pull/5247
[#5249]: https://github.com/bytedance/deer-flow/pull/5249 [#5249]: https://github.com/bytedance/deer-flow/pull/5249
[#5251]: https://github.com/bytedance/deer-flow/pull/5251 [#5251]: https://github.com/bytedance/deer-flow/pull/5251
[#5254]: https://github.com/bytedance/deer-flow/pull/5254 [#5254]: https://github.com/bytedance/deer-flow/pull/5254
@ -11722,7 +11520,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5367]: https://github.com/bytedance/deer-flow/pull/5367 [#5367]: https://github.com/bytedance/deer-flow/pull/5367
[#5369]: https://github.com/bytedance/deer-flow/pull/5369 [#5369]: https://github.com/bytedance/deer-flow/pull/5369
[#5371]: https://github.com/bytedance/deer-flow/pull/5371 [#5371]: https://github.com/bytedance/deer-flow/pull/5371
[#5373]: https://github.com/bytedance/deer-flow/commit/d5ae3882b6708fa9af7289a0c3c0be3eee157203 [#5373]: https://github.com/bytedance/deer-flow/pull/5373
[#5374]: https://github.com/bytedance/deer-flow/pull/5374 [#5374]: https://github.com/bytedance/deer-flow/pull/5374
[#5375]: https://github.com/bytedance/deer-flow/pull/5375 [#5375]: https://github.com/bytedance/deer-flow/pull/5375
[#5377]: https://github.com/bytedance/deer-flow/pull/5377 [#5377]: https://github.com/bytedance/deer-flow/pull/5377
@ -11749,7 +11547,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5408]: https://github.com/bytedance/deer-flow/pull/5408 [#5408]: https://github.com/bytedance/deer-flow/pull/5408
[#5410]: https://github.com/bytedance/deer-flow/pull/5410 [#5410]: https://github.com/bytedance/deer-flow/pull/5410
[#5411]: https://github.com/bytedance/deer-flow/pull/5411 [#5411]: https://github.com/bytedance/deer-flow/pull/5411
[#5413]: https://github.com/bytedance/deer-flow/commit/daf32cd24632f2cc7465aeb62f2a1e1e768eceb2 [#5413]: https://github.com/bytedance/deer-flow/pull/5413
[#5415]: https://github.com/bytedance/deer-flow/pull/5415 [#5415]: https://github.com/bytedance/deer-flow/pull/5415
[#5416]: https://github.com/bytedance/deer-flow/pull/5416 [#5416]: https://github.com/bytedance/deer-flow/pull/5416
[#5418]: https://github.com/bytedance/deer-flow/pull/5418 [#5418]: https://github.com/bytedance/deer-flow/pull/5418
@ -11932,8 +11730,8 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5736]: https://github.com/bytedance/deer-flow/pull/5736 [#5736]: https://github.com/bytedance/deer-flow/pull/5736
[#5738]: https://github.com/bytedance/deer-flow/pull/5738 [#5738]: https://github.com/bytedance/deer-flow/pull/5738
[#5739]: https://github.com/bytedance/deer-flow/pull/5739 [#5739]: https://github.com/bytedance/deer-flow/pull/5739
[#5740]: https://github.com/bytedance/deer-flow/commit/e7128dc7d6b4d49153b10f7276f010384ec4e6af [#5740]: https://github.com/bytedance/deer-flow/pull/5740
[#5741]: https://github.com/bytedance/deer-flow/commit/363b0fc1b4a5bd6c142d1f1df9fb9743cca550a3 [#5741]: https://github.com/bytedance/deer-flow/pull/5741
[#5745]: https://github.com/bytedance/deer-flow/pull/5745 [#5745]: https://github.com/bytedance/deer-flow/pull/5745
[#5748]: https://github.com/bytedance/deer-flow/pull/5748 [#5748]: https://github.com/bytedance/deer-flow/pull/5748
[#5750]: https://github.com/bytedance/deer-flow/pull/5750 [#5750]: https://github.com/bytedance/deer-flow/pull/5750
@ -11946,7 +11744,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5760]: https://github.com/bytedance/deer-flow/pull/5760 [#5760]: https://github.com/bytedance/deer-flow/pull/5760
[#5761]: https://github.com/bytedance/deer-flow/pull/5761 [#5761]: https://github.com/bytedance/deer-flow/pull/5761
[#5762]: https://github.com/bytedance/deer-flow/pull/5762 [#5762]: https://github.com/bytedance/deer-flow/pull/5762
[#5763]: https://github.com/bytedance/deer-flow/commit/39351315bf5d096fc84ad8307d3702b6aa5b3e24 [#5763]: https://github.com/bytedance/deer-flow/pull/5763
[#5767]: https://github.com/bytedance/deer-flow/pull/5767 [#5767]: https://github.com/bytedance/deer-flow/pull/5767
[#5769]: https://github.com/bytedance/deer-flow/pull/5769 [#5769]: https://github.com/bytedance/deer-flow/pull/5769
[#5775]: https://github.com/bytedance/deer-flow/pull/5775 [#5775]: https://github.com/bytedance/deer-flow/pull/5775
@ -12014,7 +11812,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5880]: https://github.com/bytedance/deer-flow/pull/5880 [#5880]: https://github.com/bytedance/deer-flow/pull/5880
[#5881]: https://github.com/bytedance/deer-flow/pull/5881 [#5881]: https://github.com/bytedance/deer-flow/pull/5881
[#5883]: https://github.com/bytedance/deer-flow/pull/5883 [#5883]: https://github.com/bytedance/deer-flow/pull/5883
[#5884]: https://github.com/bytedance/deer-flow/commit/e8cb5e9c4f5f274401eeb555aff62e713035cbf4 [#5884]: https://github.com/bytedance/deer-flow/pull/5884
[#5888]: https://github.com/bytedance/deer-flow/pull/5888 [#5888]: https://github.com/bytedance/deer-flow/pull/5888
[#5890]: https://github.com/bytedance/deer-flow/pull/5890 [#5890]: https://github.com/bytedance/deer-flow/pull/5890
[#5892]: https://github.com/bytedance/deer-flow/pull/5892 [#5892]: https://github.com/bytedance/deer-flow/pull/5892
@ -12076,9 +11874,9 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5999]: https://github.com/bytedance/deer-flow/pull/5999 [#5999]: https://github.com/bytedance/deer-flow/pull/5999
[#6000]: https://github.com/bytedance/deer-flow/pull/6000 [#6000]: https://github.com/bytedance/deer-flow/pull/6000
[#6008]: https://github.com/bytedance/deer-flow/pull/6008 [#6008]: https://github.com/bytedance/deer-flow/pull/6008
[#6009]: https://github.com/bytedance/deer-flow/commit/b87ebd940227465d611084e5bf49582aaeb4e9bd [#6009]: https://github.com/bytedance/deer-flow/pull/6009
[#6013]: https://github.com/bytedance/deer-flow/pull/6013 [#6013]: https://github.com/bytedance/deer-flow/pull/6013
[#6015]: https://github.com/bytedance/deer-flow/commit/f87a04e581829ff35afd4fa228240071b486192f [#6015]: https://github.com/bytedance/deer-flow/pull/6015
[#6017]: https://github.com/bytedance/deer-flow/pull/6017 [#6017]: https://github.com/bytedance/deer-flow/pull/6017
[#6018]: https://github.com/bytedance/deer-flow/pull/6018 [#6018]: https://github.com/bytedance/deer-flow/pull/6018
[#6020]: https://github.com/bytedance/deer-flow/pull/6020 [#6020]: https://github.com/bytedance/deer-flow/pull/6020
@ -12102,7 +11900,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6060]: https://github.com/bytedance/deer-flow/pull/6060 [#6060]: https://github.com/bytedance/deer-flow/pull/6060
[#6062]: https://github.com/bytedance/deer-flow/pull/6062 [#6062]: https://github.com/bytedance/deer-flow/pull/6062
[#6063]: https://github.com/bytedance/deer-flow/pull/6063 [#6063]: https://github.com/bytedance/deer-flow/pull/6063
[#6066]: https://github.com/bytedance/deer-flow/commit/73fdbcfdf4b5f4037b62bc2ce9d8b60ec04e69f8 [#6066]: https://github.com/bytedance/deer-flow/pull/6066
[#6067]: https://github.com/bytedance/deer-flow/pull/6067 [#6067]: https://github.com/bytedance/deer-flow/pull/6067
[#6068]: https://github.com/bytedance/deer-flow/pull/6068 [#6068]: https://github.com/bytedance/deer-flow/pull/6068
[#6069]: https://github.com/bytedance/deer-flow/pull/6069 [#6069]: https://github.com/bytedance/deer-flow/pull/6069
@ -12128,7 +11926,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6105]: https://github.com/bytedance/deer-flow/pull/6105 [#6105]: https://github.com/bytedance/deer-flow/pull/6105
[#6107]: https://github.com/bytedance/deer-flow/pull/6107 [#6107]: https://github.com/bytedance/deer-flow/pull/6107
[#6109]: https://github.com/bytedance/deer-flow/pull/6109 [#6109]: https://github.com/bytedance/deer-flow/pull/6109
[#6112]: https://github.com/bytedance/deer-flow/commit/52b21d13ac6e674a19cb76013d097e73f108c994 [#6112]: https://github.com/bytedance/deer-flow/pull/6112
[#6113]: https://github.com/bytedance/deer-flow/pull/6113 [#6113]: https://github.com/bytedance/deer-flow/pull/6113
[#6115]: https://github.com/bytedance/deer-flow/pull/6115 [#6115]: https://github.com/bytedance/deer-flow/pull/6115
[#6117]: https://github.com/bytedance/deer-flow/pull/6117 [#6117]: https://github.com/bytedance/deer-flow/pull/6117
@ -12229,7 +12027,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6298]: https://github.com/bytedance/deer-flow/pull/6298 [#6298]: https://github.com/bytedance/deer-flow/pull/6298
[#6299]: https://github.com/bytedance/deer-flow/pull/6299 [#6299]: https://github.com/bytedance/deer-flow/pull/6299
[#6302]: https://github.com/bytedance/deer-flow/pull/6302 [#6302]: https://github.com/bytedance/deer-flow/pull/6302
[#6304]: https://github.com/bytedance/deer-flow/commit/b85e4ae74bb407e1e2396243a8a6e8f5b2420e3f [#6304]: https://github.com/bytedance/deer-flow/pull/6304
[#6305]: https://github.com/bytedance/deer-flow/pull/6305 [#6305]: https://github.com/bytedance/deer-flow/pull/6305
[#6306]: https://github.com/bytedance/deer-flow/pull/6306 [#6306]: https://github.com/bytedance/deer-flow/pull/6306
[#6307]: https://github.com/bytedance/deer-flow/pull/6307 [#6307]: https://github.com/bytedance/deer-flow/pull/6307
@ -12613,7 +12411,6 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7017]: https://github.com/bytedance/deer-flow/pull/7017 [#7017]: https://github.com/bytedance/deer-flow/pull/7017
[#7018]: https://github.com/bytedance/deer-flow/pull/7018 [#7018]: https://github.com/bytedance/deer-flow/pull/7018
[#7020]: https://github.com/bytedance/deer-flow/pull/7020 [#7020]: https://github.com/bytedance/deer-flow/pull/7020
[#7022]: https://github.com/bytedance/deer-flow/issues/7022
[#7023]: https://github.com/bytedance/deer-flow/pull/7023 [#7023]: https://github.com/bytedance/deer-flow/pull/7023
[#7024]: https://github.com/bytedance/deer-flow/pull/7024 [#7024]: https://github.com/bytedance/deer-flow/pull/7024
[#7031]: https://github.com/bytedance/deer-flow/pull/7031 [#7031]: https://github.com/bytedance/deer-flow/pull/7031
@ -12886,35 +12683,3 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7643]: https://github.com/bytedance/deer-flow/pull/7643 [#7643]: https://github.com/bytedance/deer-flow/pull/7643
[#7651]: https://github.com/bytedance/deer-flow/pull/7651 [#7651]: https://github.com/bytedance/deer-flow/pull/7651
[#7659]: https://github.com/bytedance/deer-flow/pull/7659 [#7659]: https://github.com/bytedance/deer-flow/pull/7659
[#7638]: https://github.com/bytedance/deer-flow/pull/7638
[#7665]: https://github.com/bytedance/deer-flow/pull/7665
[#7723]: https://github.com/bytedance/deer-flow/pull/7723
[#7727]: https://github.com/bytedance/deer-flow/pull/7727
[#7729]: https://github.com/bytedance/deer-flow/pull/7729
[#7732]: https://github.com/bytedance/deer-flow/pull/7732
[#7746]: https://github.com/bytedance/deer-flow/pull/7746
[#7762]: https://github.com/bytedance/deer-flow/pull/7762
[#7772]: https://github.com/bytedance/deer-flow/pull/7772
[#7781]: https://github.com/bytedance/deer-flow/pull/7781
[#7792]: https://github.com/bytedance/deer-flow/pull/7792
[#7794]: https://github.com/bytedance/deer-flow/pull/7794
[#7798]: https://github.com/bytedance/deer-flow/pull/7798
[#7831]: https://github.com/bytedance/deer-flow/pull/7831
[#7782]: https://github.com/bytedance/deer-flow/pull/7782
[#7953]: https://github.com/bytedance/deer-flow/issues/7953
[#7958]: https://github.com/bytedance/deer-flow/pull/7958
[#7981]: https://github.com/bytedance/deer-flow/pull/7981
[#7992]: https://github.com/bytedance/deer-flow/pull/7992
[#7993]: https://github.com/bytedance/deer-flow/pull/7993
[#7998]: https://github.com/bytedance/deer-flow/issues/7998
[#8006]: https://github.com/bytedance/deer-flow/pull/8006
[#8019]: https://github.com/bytedance/deer-flow/pull/8019
[#8020]: https://github.com/bytedance/deer-flow/pull/8020
[#8043]: https://github.com/bytedance/deer-flow/pull/8043
[#8046]: https://github.com/bytedance/deer-flow/pull/8046
[#8064]: https://github.com/bytedance/deer-flow/pull/8064
[#8087]: https://github.com/bytedance/deer-flow/pull/8087
[#8118]: https://github.com/bytedance/deer-flow/pull/8118
[#8126]: https://github.com/bytedance/deer-flow/pull/8126
[#8139]: https://github.com/bytedance/deer-flow/pull/8139
[#8174]: https://github.com/bytedance/deer-flow/pull/8174

View File

@ -320,14 +320,6 @@ Nginx (port 2026) ← Unified entry point
git push origin feature/your-feature-name git push origin feature/your-feature-name
``` ```
Please don't include changes to `CHANGELOG.md` or `CHANGELOG_zh.md` in your
pull request. Maintainers add the changelog entry at merge time: this keeps
the Unreleased section's structure and entry ordering consistent and its
English and Chinese files in sync, and it avoids conflicts with other
in-flight merges, where the Unreleased section changes frequently. If your
PR touches the changelog, a reviewer will ask you to drop that hunk — the
rest of the PR is unaffected.
## AI assistance disclosure ## AI assistance disclosure
DeerFlow is an AI project and we welcome AI-assisted contributions. To help DeerFlow is an AI project and we welcome AI-assisted contributions. To help
@ -350,7 +342,7 @@ before review.
python -m pip install -r tests/skills/requirements.txt python -m pip install -r tests/skills/requirements.txt
python -m pytest tests/skills -q python -m pytest tests/skills -q
# Default backend tests (four parallel shards; excludes live, blocking-I/O and bundled-extension tests) # Default backend tests (four parallel shards; excludes live and blocking-I/O tests)
cd backend cd backend
make test make test
@ -360,9 +352,6 @@ make test TEST_JOBS=1
# Strict blocking-I/O tests # Strict blocking-I/O tests
make test-blocking-io make test-blocking-io
# Behavior suites of the bundled extension examples
make test-bundled-extensions
# Live DeerFlowClient integration tests (explicit opt-in) # Live DeerFlowClient integration tests (explicit opt-in)
# Requires a valid root config.yaml and API credentials. # Requires a valid root config.yaml and API credentials.
make test-live make test-live
@ -391,57 +380,9 @@ must also set `DEER_FLOW_RUN_LIVE_TESTS=1`.
Every pull request triggers the following CI workflows: Every pull request triggers the following CI workflows:
- **Backend unit tests** — [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml) - **Backend unit tests** — [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml)
- **Bundled extension tests** — [.github/workflows/bundled-extension-tests.yml](.github/workflows/bundled-extension-tests.yml) (triggered by example packages, `backend/packages/**`, `backend/app/gateway/**` and the marked test modules)
- **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml) - **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml)
- **Frontend E2E tests** — [.github/workflows/e2e-tests.yml](.github/workflows/e2e-tests.yml) (triggered only when `frontend/` files change) - **Frontend E2E tests** — [.github/workflows/e2e-tests.yml](.github/workflows/e2e-tests.yml) (triggered only when `frontend/` files change)
The browser E2E and full-stack replay jobs use the official Playwright image,
which includes Chromium and its system libraries. When upgrading the locked
Playwright version, update the image tags in both browser workflows to match.
Each job launches Chromium before running tests; auth recovery tests run only
after that browser readiness check succeeds.
These containers use uv-managed Python instead of the host's Python tool cache,
so the interpreter's OpenSSL libraries match its runtime. Backend dependency
setup also verifies SSL and SQLite imports before launching fixture gateways.
### Test and diagnostics notes
Backend `make test` runs four duration-balanced shards concurrently and reports
failure if any shard fails. Use `make test TEST_JOBS=1` to run the shards sequentially.
It excludes live external-API, blocking-I/O and bundled-extension coverage.
Run `cd backend && make test-blocking-io` for strict blocking-I/O checks.
Maintainers can run the real `DeerFlowClient` suite with `cd backend && make test-live`.
This command requires a valid root `config.yaml` and API credentials.
It can incur API costs and create local sandboxes, artifacts, or files.
Direct pytest runs additionally require `DEER_FLOW_RUN_LIVE_TESTS=1`.
Bundled extension examples keep their behavior suites in `backend/tests/` under
the `bundled_extension` pytest marker. The core shards exclude them so an
optional example cannot fail an unrelated core PR, and a separate CI workflow
runs them when an example, the public extension contract or the bundled tests
change. Run `cd backend && make test-bundled-extensions` to exercise them
locally. Tests that validate a host mechanism — loading, registration, the
public extension API and authorization — stay in the core shards even when an
example package is the payload. The host/extension compatibility checks (an
extension requiring a newer API than the host provides, authorization of
contributed routes) live in `backend/tests/test_extension_host_contracts.py` so
a broken compatibility guarantee fails core CI.
Regression coverage includes Docker sandbox mode detection and provisioner kubeconfig-path handling tests in `backend/tests/`.
Backend blocking-IO diagnostics are available from the repository root with
`make detect-blocking-io`: it statically scans backend business code for
blocking IO that may run on the backend event loop, prints a concise summary,
and writes complete JSON findings to `.deer-flow/blocking-io-findings.json`.
The JSON includes compact review records with `priority`, `location`,
`blocking_call`, `event_loop_exposure`, `reason`, and `code`.
Gateway artifact serving now forces active web content types (`text/html` and XML documents such as `.xml`, `.xhtml`, and `.svg`) to download as attachments instead of inline rendering, reducing XSS risk for generated artifacts.
Frontend route asset budgets can be checked with `cd frontend && pnpm
perf:check`. The command measures `/login` from a normal production build, then
performs a production static-demo build for the fixture-backed workspace routes.
It measures the unique JavaScript and CSS referenced by representative routes
and writes the detailed result to `.next/performance-results.json`.
## Code Style ## Code Style
- **Backend (Python)**: We use `ruff` for linting and formatting. Run `make format` before committing. - **Backend (Python)**: We use `ruff` for linting and formatting. Run `make format` before committing.

4005
README.md

File diff suppressed because one or more lines are too long

View File

@ -1195,7 +1195,7 @@ Agent settings에서 **Tool access**는 설정된 모든 도구 그룹을 상속
비전을 지원하는 에이전트의 경우 `view_image`와 그 뒤에 이어지는 모델 컨텍스트 이미지 읽기에도 `sandbox:execute`가 필요합니다. 도구 이름만 허용해서는 이미지 파일 접근 권한이 부여되지 않으며, 샌드박스 실행이 거부된 역할은 권한이 변경된 후 이전에 기록된 이미지 메타데이터를 다시 읽을 수 없습니다. 외부 실행 입력과 스레드 상태 업데이트로는 `viewed_images`나 `thread_data`를 설정할 수 없습니다. 저장된 이미지를 호스트 사본에서 읽을 때는 해당 경로가 현재 사용자의 스레드와 기록된 가상 이미지 경로로 해석되어야 합니다. 비전을 지원하는 에이전트의 경우 `view_image`와 그 뒤에 이어지는 모델 컨텍스트 이미지 읽기에도 `sandbox:execute`가 필요합니다. 도구 이름만 허용해서는 이미지 파일 접근 권한이 부여되지 않으며, 샌드박스 실행이 거부된 역할은 권한이 변경된 후 이전에 기록된 이미지 메타데이터를 다시 읽을 수 없습니다. 외부 실행 입력과 스레드 상태 업데이트로는 `viewed_images`나 `thread_data`를 설정할 수 없습니다. 저장된 이미지를 호스트 사본에서 읽을 때는 해당 경로가 현재 사용자의 스레드와 기록된 가상 이미지 경로로 해석되어야 합니다.
각 모델 요청에는 최근에 본 이미지가 최대 4개, base64 이미지 데이터가 최대 28 MiB까지 포함됩니다. 이 제한으로 제외된 이미지는 생략된 것으로 보고되며, 에이전트는 `view_image`를 다시 호출해 이미지를 컨텍스트로 다시 가져올 수 있습니다. 각 모델 요청에는 최근에 본 이미지가 최대 4개, base64 이미지 데이터가 최대 28 MiB까지 포함됩니다. 이 제한으로 제외된 이미지는 생략된 것으로 보고되며, 에이전트는 `view_image`를 다시 호출해 이미지를 컨텍스트로 다시 가져올 수 있습니다.
고급 배포에서는 `config.yaml` 또는 `extensions_config.json`의 `extensions.middlewares` 아래에 `AgentMiddleware` 클래스를 선언하여 에이전트 런타임 자체를 확장할 수도 있습니다. 각 항목은 `module.path:ClassName` 문자열(인자 없는 생성자) 또는 객체 `{class, kwargs, apply_to}`이며, 객체의 `kwargs`는 생성자에 전달됩니다. `kwargs` 값은 JSON 타입(객체, 배열, 문자열, 숫자, 불리언, null)이어야 하며, YAML 날짜와 타임스탬프는 JSON과 일치하도록 ISO 문자열로 변환됩니다. DeerFlow는 각 체인에 대해 선택된 설정 항목을 리드 에이전트와 서브에이전트 파이프라인에 불러오며, 그 위치는 기본 제공 런타임 미들웨어와 루프/토큰 가드 뒤, 그리고 terminal-response/safety/clarification 꼬리 앞입니다. 따라서 엔터프라이즈 포크는 기본 제공 미들웨어 빌더를 패치하지 않고도 도메인 가드레일, 도구 호출 거버넌스, 관측성 훅을 추가할 수 있습니다. 패키지 누락, 잘못된 클래스, 손상된 모듈, 생성자 오류는 에이전트 생성 시점에 명확한 오류로 실패합니다. `config.yaml`과 `extensions_config.json`은 운영자가 제어하는 신뢰된 파일로 취급하세요. 커스텀 도구, 모델, 샌드박스, 가드레일, MCP 서버, MCP 인터셉터 선언과 마찬가지로 미들웨어 경로는 곧 코드 실행입니다. Gateway의 스킬/MCP 토글 엔드포인트는 이 필드를 보존하지만 `extensions.middlewares`에 대한 API 쓰기 경로는 제공하지 않습니다. 객체의 선택적 `apply_to`는 `both`(기본값), `lead`, `subagent`를 지원하며, 문자열 항목과 이 필드를 생략한 객체는 계속 두 파이프라인 모두에 적용됩니다. 해당 체인에서 제외된 항목은 임포트하거나 인스턴스화하지 않습니다. 고급 배포에서는 `config.yaml` 또는 `extensions_config.json`의 `extensions.middlewares` 아래에 `AgentMiddleware` 클래스를 선언하여 에이전트 런타임 자체를 확장할 수도 있습니다. 각 항목은 `module.path:ClassName` 문자열(인자 없는 생성자) 또는 객체 `{class, kwargs}`이며, 객체의 `kwargs`는 생성자에 전달됩니다. `kwargs` 값은 JSON 타입(객체, 배열, 문자열, 숫자, 불리언, null)이어야 하며, YAML 날짜와 타임스탬프는 JSON과 일치하도록 ISO 문자열로 변환됩니다. DeerFlow는 설정된 같은 목록을 리드 에이전트와 서브에이전트 파이프라인에 불러오며, 그 위치는 기본 제공 런타임 미들웨어와 루프/토큰 가드 뒤, 그리고 terminal-response/safety/clarification 꼬리 앞입니다. 따라서 엔터프라이즈 포크는 기본 제공 미들웨어 빌더를 패치하지 않고도 도메인 가드레일, 도구 호출 거버넌스, 관측성 훅을 추가할 수 있습니다. 패키지 누락, 잘못된 클래스, 손상된 모듈, 생성자 오류는 에이전트 생성 시점에 명확한 오류로 실패합니다. `config.yaml`과 `extensions_config.json`은 운영자가 제어하는 신뢰된 파일로 취급하세요. 커스텀 도구, 모델, 샌드박스, 가드레일, MCP 서버, MCP 인터셉터 선언과 마찬가지로 미들웨어 경로는 곧 코드 실행입니다. Gateway의 스킬/MCP 토글 엔드포인트는 이 필드를 보존하지만 `extensions.middlewares`에 대한 API 쓰기 경로는 제공하지 않습니다. 리드 에이전트 전용/서브에이전트 전용 미들웨어 목록을 따로 지정하는 기능은 아직 지원되지 않습니다.
패키지화되고 설정 가능한 런타임 통합에는 DeerFlow의 확장 관리자를 사용하세요. 확장 관리자는 Python 패키지 요구 사항, 공개 HTTPS Git URL, 또는 로컬 디렉터리를 받아 백엔드 전용 `extensions` 의존성 그룹에 패키지를 설치하고, `backend/uv.lock`을 업데이트하며, 시작 시에만 읽는 `config.yaml`의 최상위 `plugins:` 목록에 활성화된 항목을 추가합니다. 패키지화되고 설정 가능한 런타임 통합에는 DeerFlow의 확장 관리자를 사용하세요. 확장 관리자는 Python 패키지 요구 사항, 공개 HTTPS Git URL, 또는 로컬 디렉터리를 받아 백엔드 전용 `extensions` 의존성 그룹에 패키지를 설치하고, `backend/uv.lock`을 업데이트하며, 시작 시에만 읽는 `config.yaml`의 최상위 `plugins:` 목록에 활성화된 항목을 추가합니다.
@ -1842,7 +1842,7 @@ WeChat은 바인딩 후 현재 대화 컨텍스트를 안전하게 저장하기
양식이 그룹 알림 지원 여부를 확인하는 동안에는 로딩 안내를 표시하고 저장된 선택을 유지합니다. 양식이 그룹 알림 지원 여부를 확인하는 동안에는 로딩 안내를 표시하고 저장된 선택을 유지합니다.
그룹은 자동으로 선택되지 않습니다. 선택을 해제하면 새 그룹 알림이 중지되고, 선택을 변경하면 이후 알림에 반영되며, 이미 큐에 들어간 알림은 원래 대상을 유지합니다. 연결이 해제된 바인딩은 대기 중인 알림을 절대 받지 않습니다. 첫 버전에서는 작업 양식 또는 REST API로 설정하며, 대화용 일정 도구의 입력은 기존과 같습니다. [그룹 전달 가이드](backend/docs/IM_CHANNEL_CONNECTIONS.md#group-notifications)를 참고하세요. 그룹은 자동으로 선택되지 않습니다. 선택을 해제하면 새 그룹 알림이 중지되고, 선택을 변경하면 이후 알림에 반영되며, 이미 큐에 들어간 알림은 원래 대상을 유지합니다. 연결이 해제된 바인딩은 대기 중인 알림을 절대 받지 않습니다. 첫 버전에서는 작업 양식 또는 REST API로 설정하며, 대화용 일정 도구의 입력은 기존과 같습니다. [그룹 전달 가이드](backend/docs/IM_CHANNEL_CONNECTIONS.md#feishu-group-notifications)를 참고하세요.
### 수명 주기, 안전 상한, 중지 조건 ### 수명 주기, 안전 상한, 중지 조건

View File

@ -109,7 +109,7 @@ DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[
管理员可在 **设置 → 模型 → 添加模型** 中选择厂商,仅填写 **API Key** 和 管理员可在 **设置 → 模型 → 添加模型** 中选择厂商,仅填写 **API Key** 和
**模型 ID**。后端预置 OpenAI、Anthropic、DeepSeek、百炼/通义千问、火山/豆包、 **模型 ID**。后端预置 OpenAI、Anthropic、DeepSeek、百炼/通义千问、火山/豆包、
Moonshot/Kimi、智谱/Z.AI、MiniMax 和阶跃星辰(StepFun)的端点、认证与适配器,地域和 Coding Plan Moonshot/Kimi、智谱/Z.AI 和 MiniMax 的端点、认证与适配器,地域和 Coding Plan
使用明确区分的选项。模型 ID 不受内置枚举限制;内部名称自动生成且保持稳定。 使用明确区分的选项。模型 ID 不受内置枚举限制;内部名称自动生成且保持稳定。
新模型默认启用,保存后自动刷新聊天及 Agent 的模型选择器,无需重启 Gateway。 新模型默认启用,保存后自动刷新聊天及 Agent 的模型选择器,无需重启 Gateway。
@ -712,18 +712,6 @@ DeerFlow 2.0 不再是一个需要你自己拼装的 framework。它是一个开
## 核心特性 ## 核心特性
### Token 用量统计
点击侧边栏的 **Token 用量**(`/workspace/usage`),查看近 7/30 天的用量、
每日输入/输出 Token、各模型的缓存命中输入 Token,以及支持失败筛选和分页的
运行记录。运行记录包含所有日期,不受图表所选周期限制。
此功能需要 `runs:read` 权限和 SQLite 或 PostgreSQL 数据库,静态演示不提供统计。
数据来自已持久化的服务商用量;运行过程中或服务商未返回用量时可能不完整,不能
作为服务商账单。缓存命中 Token 已包含在输入 Token 中。每日分桶使用浏览器当前
UTC 偏移,并按运行的创建日期归属。页面复用现有 `/api/console` 接口,保留服务端
价格配置及可选的费用、币种字段。
### Skills 与 Tools ### Skills 与 Tools
Skills 是 DeerFlow 能做“几乎任何事”的关键。 Skills 是 DeerFlow 能做“几乎任何事”的关键。
@ -830,11 +818,8 @@ Web UI 输入框支持浏览器侧语音听写。浏览器提供 Web Speech API
/goal finish the implementation and make all tests pass /goal finish the implementation and make all tests pass
/goal # 查看当前激活的 goal /goal # 查看当前激活的 goal
/goal clear # 清除它 /goal clear # 清除它
/goal from-chat # 仅 IM:根据当前对话生成 goal
``` ```
在受支持的 IM 渠道里,`/goal from-chat [提示]` 会从当前对话中归纳 goal,而不是把文字当成目标本身。它会保留对话中已经约定的约束和停止条件,展示保存后的目标,并只启动一次 run。命令后的可选提示用于补充约束,不需要重述完整目标。如果对话不足以形成清楚的目标,则不会保存或启动任何内容,回复会说明原因;如果助手还在等你回答,回复会先请你回复。
每次 Gateway 驱动的 run 结束后,DeerFlow 会用一个 non-thinking 的评估模型,把可见的对话内容(包括助手的工具调用和截短的工具结果)拿去和激活的 goal 比对。工具调用成功本身不代表 goal 已完成;如果助手不得不猜测缺失或含糊的信息,评估模型会报告 `needs_user_input`。评估模型必须返回一个带类型的 blocker(`missing_evidence`、`needs_user_input`、`run_failed`、`external_wait` 或 `goal_not_met_yet`),并附上可见证据。只有在最近一轮 assistant 回复已被持久化 checkpoint、blocker 是 `goal_not_met_yet`、评估期间 thread 没有变化、且无进展熔断器没有触发时,DeerFlow 才会注入一次 hidden continuation。安全上限默认是 8 次 hidden continuation;连续两次相同的无进展评估后就会停止。`/goal clear` 以及任何用户手动输入的新内容,优先级都高于排队中的 continuation。当 goal 被满足时,DeerFlow 会自动清除它,并发布更新后的 thread 状态。同一个 checkpoint 会把这次达成记为 `goal_outcome`,保留到下一次修改 goal。 每次 Gateway 驱动的 run 结束后,DeerFlow 会用一个 non-thinking 的评估模型,把可见的对话内容(包括助手的工具调用和截短的工具结果)拿去和激活的 goal 比对。工具调用成功本身不代表 goal 已完成;如果助手不得不猜测缺失或含糊的信息,评估模型会报告 `needs_user_input`。评估模型必须返回一个带类型的 blocker(`missing_evidence`、`needs_user_input`、`run_failed`、`external_wait` 或 `goal_not_met_yet`),并附上可见证据。只有在最近一轮 assistant 回复已被持久化 checkpoint、blocker 是 `goal_not_met_yet`、评估期间 thread 没有变化、且无进展熔断器没有触发时,DeerFlow 才会注入一次 hidden continuation。安全上限默认是 8 次 hidden continuation;连续两次相同的无进展评估后就会停止。`/goal clear` 以及任何用户手动输入的新内容,优先级都高于排队中的 continuation。当 goal 被满足时,DeerFlow 会自动清除它,并发布更新后的 thread 状态。同一个 checkpoint 会把这次达成记为 `goal_outcome`,保留到下一次修改 goal。
重新生成此前的回答时,会沿用当前对话的 goal 和已用续跑预算,不会恢复已清除或已达成的旧 goal。重放历史 checkpoint 时会移除旧回答的达成记录,避免把它当作新回答已经达成的证明。 重新生成此前的回答时,会沿用当前对话的 goal 和已用续跑预算,不会恢复已清除或已达成的旧 goal。重放历史 checkpoint 时会移除旧回答的达成记录,避免把它当作新回答已经达成的证明。
@ -857,11 +842,6 @@ Web UI 会在输入框上方展示当前激活的 goal,说明自动续跑为
### Sub-Agents ### Sub-Agents
显式后台 `batch_task` 可用 `depends_on` 声明同批条目的前置关系,前置工作完成后即可继续,
无需等待无关任务。用户已要求后续汇总或交付时,可用可选的 `continuation` 登记一次主 Agent 接续;
启用 Goal 时仍由 Goal 负责续跑。执行、取消、授权和恢复范围见
[批次依赖与完成接续说明](backend/docs/BATCH_CONTINUATION.md)。
Sub-agent 是一种执行优化,而不是遇到复杂任务时的默认选择。 Sub-agent 是一种执行优化,而不是遇到复杂任务时的默认选择。
lead agent 只会在委派具有明确净收益时动态拉起 sub-agents,例如真正缩短耗时的并行工作、专业能力收益或上下文隔离收益。存在跨 Agent 依赖或重叠副作用的工作不会并行分派;当专业能力或上下文隔离收益明显占优时,一条有界的顺序任务链仍可交给一个 sub-agent 完成。lead agent 会使用能取得收益的最少 sub-agents,并在每一批完成后重新评估,而不会仅仅因为任务规模大或步骤多就继续拆分。每个 sub-agent 都有自己独立的上下文、工具和终止条件,返回结构化结果后由 lead agent 验证并汇总成完整输出。 lead agent 只会在委派具有明确净收益时动态拉起 sub-agents,例如真正缩短耗时的并行工作、专业能力收益或上下文隔离收益。存在跨 Agent 依赖或重叠副作用的工作不会并行分派;当专业能力或上下文隔离收益明显占优时,一条有界的顺序任务链仍可交给一个 sub-agent 完成。lead agent 会使用能取得收益的最少 sub-agents,并在每一批完成后重新评估,而不会仅仅因为任务规模大或步骤多就继续拆分。每个 sub-agent 都有自己独立的上下文、工具和终止条件,返回结构化结果后由 lead agent 验证并汇总成完整输出。

File diff suppressed because one or more lines are too long

View File

@ -1,63 +1,327 @@
# Backend (`backend/`) # AGENTS.md
The Python backend: the publishable harness in `packages/harness/deerflow/`, the ## Project Overview
public extension contract in `packages/extension-api/`, and the unpublished
Gateway and IM channels in `app/`. In every deployment the agent runtime runs
inside the Gateway. Design docs are in [docs/](docs/); start with
[ARCHITECTURE.md](docs/ARCHITECTURE.md) and [API.md](docs/API.md).
## Guides by area The LangGraph backend provides sandbox execution, persistent memory, subagent delegation, and extensible tools, isolated per thread.
Read the guide for an area before you change it. Service roles and ports: [root service topology](../AGENTS.md#service-topology).
| Area | Guide | **Runtime**:
| --- | --- | - `make dev`, Docker dev, and production all run the agent runtime in Gateway via `RunManager` + `run_agent()` + `StreamBridge` (`packages/harness/deerflow/runtime/`). Nginx exposes that runtime at `/api/langgraph/*` and rewrites it to Gateway's native `/api/*` routers.
| Run streaming, checkpoints, run ownership | [runtime](packages/harness/deerflow/runtime/AGENTS.md) | - Gateway streams `write_file` and `str_replace` argument deltas in bounded batches for multi-mode `messages-tuple` consumers; single-mode message consumers retain the original per-chunk contract. Non-message frames flush pending batches, and `values` remains an optional complete-state snapshot rather than a prerequisite for batching.
| Repositories, leases, scheduled tasks, schema changes | [persistence](packages/harness/deerflow/persistence/AGENTS.md), [migrations](packages/harness/deerflow/persistence/migrations/AGENTS.md) | - `stream_subgraphs` preserves subgraph SSE namespaces (`values|<ns>`, LangGraph Platform style); bare `values` would overwrite the SDK thread view (#4399). Delegated subagents inherit the parent's checkpoint namespace. Root-only consumers (file-tool chunk batcher, subagent event persistence, LLM error-fallback detection) ignore namespaced frames. The web frontend uses root `task_*` events without subgraph streaming.
| Scheduler failure isolation | [design conventions](app/scheduler/guide/failure-isolation.md) | - Background subagent identity is deliberately split: the provider `tool_call_id` remains the correlation key for `ToolMessage`, `task_*` SSE events, persisted lifecycle events, frontend cards, and the public `ExtensionData.scope_id` contract (stored as `SubagentResult.external_task_id`), while `SubagentExecutor.execute_async()` generates a full server-side `execution_id` for `SubagentResult.task_id`, the process-wide registry, polling, cancellation, timeout handling, and cleanup. Provider IDs are not globally unique across parent runs, so they must never become registry ownership keys; scheduler closures retain their own `SubagentResult` rather than resolving ownership again through the mutable registry. Terminal subagent token usage travels in the current run's `ToolMessage.additional_kwargs` and is attributed from message state, never through a process-global provider-ID cache.
| `config.yaml`, `extensions_config.json` | [config](packages/harness/deerflow/config/AGENTS.md) | - Scheduled runs use Gateway's normal run path. `launch_scheduled_thread_run` reads `get_app_config()` at dispatch: `scheduler.recursion_limit` defaults to 1000 (web UI), capped by `max_recursion_limit`; YAML changes apply next run without restart. IM enqueue checks recipients before locking preferences in the outcome transaction.
| Lead agent, prompt, thread state, middleware chain | [agents](packages/harness/deerflow/agents/AGENTS.md), [middlewares](packages/harness/deerflow/agents/middlewares/AGENTS.md) | - Run-history `status` filters are occurrence states, not task states. `ScheduledTaskRunStatus` in `persistence/scheduled_tasks/model.py` is the shared API/repository vocabulary and must match the active and terminal occurrence-status sets. Keep owner lookup before reading history, and apply SQL task/status predicates before pagination; omitted status preserves the existing response.
| Memory, subagents, skills, tools | [memory](packages/harness/deerflow/agents/memory/AGENTS.md), [subagents](packages/harness/deerflow/subagents/AGENTS.md), [skills](packages/harness/deerflow/skills/AGENTS.md), [tools](packages/harness/deerflow/tools/AGENTS.md) | - The background scheduler is single-instance by default. `scheduler.multi_instance=true` opts into lease-aware recovery across Gateway instances and requires shared Postgres, `run_ownership.heartbeat_enabled=true`, and `run_events.backend=db`; otherwise startup rejects the configuration. Live scheduled runs are preserved when a peer starts; expired launch claims return to the durable queue, expired run leases are atomically taken over, stale launch writes are fenced by lease ownership, and the Postgres advisory-locked budget makes `max_concurrent_runs` a shared global cap for `launching`/`running` rows.
| Sandbox, uploads, MCP, models, extensions | [sandbox](packages/harness/deerflow/sandbox/AGENTS.md), [uploads](packages/harness/deerflow/uploads/AGENTS.md), [mcp](packages/harness/deerflow/mcp/AGENTS.md), [models](packages/harness/deerflow/models/AGENTS.md), [extensions](packages/harness/deerflow/extensions/AGENTS.md) | - The multi-process startup gate also fires on `deployment.multi_instance: true`; contract in `docs/CONFIGURATION.md`.
| Trace ID, embedded client, other root modules | [harness package](packages/harness/deerflow/AGENTS.md) | - Long-running MCP work uses a separate durable task runtime (`McpTaskService` + `mcp_tasks`, lease-based recovery) rather than keeping remote task IDs or status polling inside the Agent loop; only submit remains Agent-visible, the database is the source of truth, and `ThreadState` receives only a bounded current-thread projection. Full contract — including notification retries, dead-lettering, and the cancel endpoint's worker-stopped 503 (leases, cancellation fencing, delivery idempotency, management-tool exposure): [packages/harness/deerflow/mcp/AGENTS.md](packages/harness/deerflow/mcp/AGENTS.md).
| Search, fetch and sandbox providers | [community](packages/harness/deerflow/community/AGENTS.md), then the provider's own guide | - Scheduled-task dispatch permits one active occurrence per task via `uq_scheduled_task_run_active` (`task_id WHERE status IN ('queued','launching','running')`). Durable `queued` rows survive restarts; only lease-fenced `launching` may call Gateway launch; `running` references the durable run. Stable admission idempotency keys reuse that run after recovery. Reused-thread `ConflictError` returns `launching` to `queued`; other launch errors become `failed`. Atomic queue claims enforce `max_concurrent_runs` and the per-owner cap, excluding waiting rows; the budget count and its UPDATE are separate statements, so writers must serialize before the count (Postgres advisory lock; SQLite `BEGIN IMMEDIATE`, whose deferred transaction otherwise reserves the writer only at the UPDATE) or claims on distinct rows overshoot the cap. Repeated triggers coalesce; same-thread FIFO blocks behind older active rows. Queue admission, PATCH/resume, pause and delete lock the parent before the occurrence, freezing active task definitions. Pause/delete atomically cancel `queued` work but reject `launching`/`running`; PATCH/resume reject all active states. Only queued conflicts offer pause cancellation. Manual triggers may queue/run while paused. Manual-trial run origins reuse the admitted `scheduled_for`, so an idempotent retry submits the same request instead of colliding with the first attempt.
| Gateway, routers, IM channels | [gateway](app/gateway/AGENTS.md), [routers](app/gateway/routers/AGENTS.md), [channels](app/channels/AGENTS.md) | - Scheduled-task lifecycle, recovery and queue timeout: [persistence guide](packages/harness/deerflow/persistence/AGENTS.md#scheduled-task-lifecycle).
| Tests, benchmarks | [tests](tests/AGENTS.md), [benchmarks](scripts/benchmark/AGENTS.md) | - `POST /api/scheduled-tasks/preview-cron` requires authenticated `threads:read`. Bounded cron previews call the shared scheduler calculator in `asyncio.to_thread`, preserving its DST semantics. Capture the optional aware reference once; return UTC and offset-bearing local occurrences without acquiring task/thread/run stores or dispatching work. This advisory API does not reserve execution.
- Gateway MCP/skill updates write `extensions_config.json` at runtime. Production Compose mounts it read-write (`config.yaml` stays `:ro`); Helm seeds a writable home-volume copy. Hold both `extensions_config_write_lock` and the sidecar advisory `extensions_config_file_lock` during read-modify-write to prevent lost updates across workers. `atomic_write_extensions_config` uses temp-file replacement; only mount-point `EBUSY` permits a non-atomic in-place overwrite (crashes can truncate). Warn about this fallback once per target; propagate other errors. `get_extensions_config()` revalidates reads by file signature; see [config caching](packages/harness/deerflow/config/AGENTS.md). Tests: `tests/test_compose_extensions_config_writable.py`, `tests/test_extensions_config_atomic_write.py`, `tests/test_helm_extensions_config_writable.py`.
- MCP cache reset scope and discovery fencing: [MCP guide](packages/harness/deerflow/mcp/AGENTS.md).
## Verify **Project structure:** The [root repository map](../AGENTS.md#repository-map)
indexes the stack. Backend code lives in `app/` (Gateway and channels),
`packages/harness/deerflow/` (agent runtime), and `packages/extension-api/`
(public extension contracts). Tests, detailed docs, and standalone benchmarks
live in `tests/`, `docs/`, and `scripts/benchmark/` respectively. Module-local
`AGENTS.md` files describe their implementation boundaries.
## Important Development Guidelines
### Documentation Update Policy
Every code change must keep docs accurate and current: update `README.md` for
user-facing behavior and the relevant `AGENTS.md` for development changes.
`CLAUDE.md` imports `AGENTS.md`; do not edit the shim.
Image result URL policy: [image search guide](packages/harness/deerflow/community/image_search/AGENTS.md).
### Backend Benchmarks
`scripts/benchmark/context_snapshot/`: explicit `run-live` needs provider env
vars; `summarize` and pytest are offline. See its README for the protocol.
Benchmarks in `scripts/benchmark/` must be standalone and reproducible. Import
production functions; never duplicate them or introduce an alternative runtime.
Sandbox benchmarks reuse production lifecycle. Reject negative/NaN timing values
before provider creation, export no secrets, and fail on final cleanup errors.
See the sandbox benchmark README for supported options and tests.
- Pin every external dataset by immutable revision and SHA-256. Callers provide
the local dataset path; evaluation commands must not silently download data.
- Never commit upstream dataset text, credentials, complete provider requests,
or response headers. Committed manifests may contain stable IDs and source
locators. Synthetic cases must identify themselves as synthetic.
- Read provider credentials and endpoints from named environment variables.
Version model IDs, inference parameters, prompts, retry rules, clocks, and
random seeds in the evaluation config.
- Public raw results may contain case IDs, policy decisions, model hypotheses,
grades, and non-secret response metadata. Keep dataset questions, reference
answers, memory content, and full provider payloads in ignored local run
directories.
- Use fixed clocks and deterministic ordering for offline selection. Results
must record the config, manifest, prompt, dataset, and git revisions used.
`scripts/benchmark/deermem_eviction/` evaluates the production
`select_facts_for_capacity()` implementation used by DeerMem. It compares only
the historical `confidence` policy and PR #4789's opt-in `hybrid-v1`; do not add
another eviction strategy to this evaluation. Run its offline checks from
`backend/`:
```bash ```bash
make test # offline suite in parallel shards PYTHONPATH=. uv run python -m scripts.benchmark.deermem_eviction validate-contracts
make test-blocking-io # strict blocking-I/O suite PYTHONPATH=. uv run python -m scripts.benchmark.deermem_eviction validate --dataset "$LONGMEMEVAL_ORACLE_PATH"
make test-bundled-extensions # behavior suites of bundled examples (not in make test) PYTHONPATH=. uv run python -m scripts.benchmark.deermem_eviction run-policy \
make lint --dataset "$LONGMEMEVAL_ORACLE_PATH" \
make format --output-dir /tmp/deermem-eviction-policy-run
make test-live # calls real APIs; needs DEER_FLOW_RUN_LIVE_TESTS=1 PYTHONPATH=. uv run pytest tests/test_bench_deermem_eviction_*.py -q
``` ```
Run `make test` and `make test-blocking-io` before and after a change. Every The offline test suite must not require network access, provider credentials,
feature and bug fix ships with a test in `tests/`. Live tests stay opt-in, so the or the LongMemEval dataset. Small LongMemEval-shaped fixtures must be synthetic
default suite needs no network and no API key. and generated by tests.
## Rules `scripts/benchmark/concurrency/` measures multi-process contention on the
`users` table (N separate OS processes, not asyncio tasks) for SQLite vs
Postgres -- the scenario `CONFIGURATION.md` requires Postgres for. `worker.py`
connects directly via SQLAlchemy (skipping the ~8.5s Alembic bootstrap the
orchestrator already ran once) and mirrors the app's per-connection SQLite
PRAGMAs; `run_concurrency_bench.py` seeds a disposable per-run Postgres schema,
synchronises workers on a READY/GO barrier before timing, and exits non-zero on
any crash, short op count, or `errors > 0`. Postgres runs need a throwaway
database via `--pg-url`; nothing here touches `public`. Run from `backend/`:
- **The harness never imports `app`.** `app.*` imports `deerflow.*`, and not the ```bash
reverse, because the harness is published on its own. uv run python scripts/benchmark/concurrency/run_concurrency_bench.py \
`tests/test_harness_boundary.py` enforces this in CI. --backend sqlite --workers 2,4,8,16 --ops-per-worker 50 --read-ratio 0.7
- **Start the Gateway with `make dev` or `make gateway`.** `make dev` excludes uv run pytest tests/test_bench_concurrency.py tests/test_bench_worker.py -q
`DEER_FLOW_HOME` and `backend/sandbox` from the reload watcher. A bare ```
`uvicorn --reload` restarts the Gateway during a run, because agent tasks write
Python files below `DEER_FLOW_HOME`. ## Commands
- **From the repository root, run backend tools with
`uv run --no-sync --project backend`.** `--project` keeps the caller's working Root orchestration and extension commands live in the
directory, so relative config paths still resolve. `--no-sync` keeps installed [root guide](../AGENTS.md#commands-root-vs-module); run them from the repo root.
extras such as the PostgreSQL drivers. `make detect-thread-boundaries` inventories backend executor, thread and event-loop
- **Run Python through `uv run` inside `backend/`**, without `--no-sync`, so the boundaries.
environment matches the lock.
- **Guard POSIX-only test markers with `os.name`.** Windows collects the same **Backend directory** (for backend development only):
files. ```bash
- **Tool approvals need addressable calls.** Strip missing, empty and duplicate make install # Install backend dependencies
tool-call IDs before creating a pause or a tool result. See the make dev # Gateway API, reload (port 8001)
[run interaction policy](docs/RUN_INTERACTION_POLICY.md#hitl-tool-approval). make gateway # Gateway API only (port 8001)
- **Add a schema change with `make migrate-rev MSG="..."`** and follow the make test # four offline workers; TEST_JOBS=1 for serial
migrations guide. make test-live # live tests (real APIs)
make test-blocking-io # strict Blockbuster gate on tests/blocking_io/
make test-shard SPLITS=4 GROUP=2 # one duration-aware shard
make test-shard-durations # refresh baseline
make lint # ruff lint
make format # ruff format
make migrate-rev MSG="..." # Autogenerate a new alembic revision (see Schema Migrations section)
```
The backend `make dev` target pre-creates and excludes `DEER_FLOW_HOME`
(default: `backend/.deer-flow`) and `backend/sandbox` from Uvicorn's reload
watcher. Do not replace it with a bare `uvicorn --reload`: agent tasks write
Python and other runtime files below `DEER_FLOW_HOME`, which would otherwise
restart the Gateway during an active run.
Configuration checks and operator migration scripts reuse the installed backend
environment with `uv run --no-sync --project backend` from the repository root.
`--project` preserves the caller's working directory and relative runtime/config
selectors; `--no-sync` retains installed extras such as PostgreSQL drivers.
Read the nearest scoped `AGENTS.md` for subsystem ownership.
## Architecture
Read-only knowledge: [contract](docs/KNOWLEDGE.md) covers independent retrieval
tools, scope intersection and extension-owned providers.
### Harness / App Split
The backend is split into two layers with a strict dependency direction:
- **Harness** (`packages/harness/deerflow/`): Publishable agent framework package (`deerflow-harness`). Import prefix: `deerflow.*`. Contains agent orchestration, tools, sandbox, models, MCP, skills, config — everything needed to build and run agents.
- **App** (`app/`): Unpublished application code. Import prefix: `app.*`. Contains the FastAPI Gateway API and IM channel integrations (Feishu, Slack, Telegram, DingTalk). Feishu option clarifications can render as interactive button cards behind `channels.feishu.card_interaction_enabled` (default off); see `app/channels/AGENTS.md`.
Channel credential staging (`app/channels/credential_staging.py`) is swept on worker-thread state loads, including SQL legacy import. Only owned regular files older than 24h are removed; leave anonymous temporary files alone and keep WeChat constructors IO-free.
**Dependency rule**: App imports deerflow, but deerflow never imports app. This boundary is enforced by `tests/test_harness_boundary.py` which runs in CI.
**Import conventions**:
```python
# Harness internal
from deerflow.agents import make_lead_agent
from deerflow.models import create_chat_model
# App internal
from app.gateway.app import app
from app.channels.service import start_channel_service
# App → Harness (allowed)
from deerflow.config import get_app_config
# Harness → App (FORBIDDEN — enforced by test_harness_boundary.py)
# from app.gateway.routers.uploads import ... # ← will fail CI
```
Package import hygiene: the `deerflow.agents` and `deerflow.subagents` package
roots expose heavyweight graph/executor entrypoints lazily. The
`deerflow.agents:make_lead_agent` LangGraph Server entrypoint is a concrete thin
module-level function because the server resolves graph factories directly from
the module dictionary; the wrapper keeps the lead-agent and skill-cache imports
inside the function so importing the package remains lightweight. Internal
modules that only need lightweight types, config, or registries should import
the concrete submodule instead of adding eager package-root imports that pull in
the tool graph or subagent executor during state/schema imports.
`ThreadMetaStore.search()` keeps JSON filter semantics identical across memory,
SQLite, and PostgreSQL: missing differs from null, bool differs from int, and
float filters accept integer or real JSON numbers through `json_value_matches`.
### Gateway Run-Context Trust Boundary
Gate server-owned run context on both client feeds (`body.context` and
`body.config`): `merge_run_context_overrides` admits it only for `internal=True`,
while `strip_internal_context_keys` scrubs both destinations. A validated
`context.locale` (`en-US` / `zh-CN`) is a runtime-only language preference for
replies and memory writes; it never persists into checkpoint `configurable`,
and an explicit invalid/null top-level value clears a legacy preference. Treat
`disable_clarification` like `non_interactive`. Before run/state writes,
`_normalize_input_messages` rejects canonical external system/developer roles;
only `AUTH_SOURCE_INTERNAL` run input may retain them.
`contracts/ui_locales.v1.json` pins the UI/Gateway locale allowlists in tests;
DeerMem's portable map stays separate. Scheduled/MCP notification runs omit
locale; persisted owner locale is used for scheduled IM notices only.
`start_run` assigns IDs to ID-less messages before recording input or invoking
the graph; keyed retries keep those IDs stable, while the pre-stamp input
remains the idempotency identity. The record and checkpoint share IDs so
frontend reconnect can deduplicate the user's message.
## Development Workflow
### Test-Driven Development (TDD) — MANDATORY
**Every new feature or bug fix MUST be accompanied by unit tests. No exceptions.**
- Write tests in `backend/tests/` following the existing naming convention `test_<feature>.py`
- Run both offline targets before and after your change: `make test` and `make test-blocking-io`
- Tests must pass before a feature is considered complete
- For lightweight config/utility modules, prefer pure unit tests with no external dependencies
- If a module causes circular import issues in tests, add a `sys.modules` mock in `tests/conftest.py` (see existing example for `deerflow.subagents.executor`)
Keep live tests opt-in via `DEER_FLOW_RUN_LIVE_TESTS=1`; guard POSIX-only
markers with `os.name` for Windows collection.
Jina logging tests use dummy keys; the Jina redirect regression runs offline via `httpx.MockTransport` and keeps POST across temporary redirects; InfoQuest connect/read timeout is 30s, separate from crawl timeouts (`tests/test_jina_client.py`, `tests/test_infoquest_http_timeout.py`).
### Running the Full Application
Run `make dev` from the repo root to start all services at `http://localhost:2026`.
**All startup modes:**
| | **Local Foreground** | **Local Daemon** | **Docker Dev** | **Docker Prod** |
|---|---|---|---|---|
| **Dev** | `./scripts/serve.sh --dev`<br/>`make dev` | `./scripts/serve.sh --dev --daemon`<br/>`make dev-daemon` | `./scripts/docker.sh start`<br/>`make docker-start` | — |
| **Prod** | `./scripts/serve.sh --prod`<br/>`make start` | `./scripts/serve.sh --prod --daemon`<br/>`make start-daemon` | — | `./scripts/deploy.sh`<br/>`make up` |
| Action | Local | Docker Dev | Docker Prod |
|---|---|---|---|
| **Stop** | `./scripts/serve.sh --stop`<br/>`make stop` | `./scripts/docker.sh stop`<br/>`make docker-stop` | `./scripts/deploy.sh down`<br/>`make down` |
| **Restart** | `./scripts/serve.sh --restart [flags]` | `./scripts/docker.sh restart` | — |
### Running Backend Services Separately
From the **backend** directory:
```bash
# Gateway API
make gateway
```
Direct access (without nginx):
- Gateway: `http://localhost:8001`
### Frontend Configuration
The frontend uses environment variables to connect to backend services:
- `NEXT_PUBLIC_LANGGRAPH_BASE_URL` - Defaults to `/api/langgraph` (through nginx)
- `NEXT_PUBLIC_BACKEND_BASE_URL` - Defaults to empty string (through nginx)
When using `make dev` from root, the frontend automatically connects through nginx.
## Key Features
### Web Search Recency
Optional `web_search` `time_range=day|week|month|year` mappings:
DDG → `d|w|m|y`; Brave → `pd|pw|pm|py`; Tavily/SearXNG → unchanged;
Sofya → unchanged in `freshness`; Serper → `qdr:d|w|m|y` in `tbs`.
Omission preserves request shape; Serper null also omits `tbs`. Image search is unchanged.
DDGS 9.14.1 recency uses only enabled Brave/DuckDuckGo/Yahoo engines honoring
`timelimit`: `auto`/`all` selects this set; drop incompatible engines and fall back
to this set if empty. Re-check on DDGS upgrades.
### Tavily Fetch
Title fallback: result URL, then request URL.
### File Upload
Outlines use ATX syntax (1–6 hashes, space/tab separator, ≤3 leading spaces), strip closing hashes and skip fenced code; closing markers use a linear suffix scan — never unanchored whitespace regex on unbounded headings.
- Endpoint: `POST /api/threads/{thread_id}/uploads`; PDF/PPT/Excel/Word via `markitdown`, one conversion worker per request; directories rejected, keeping uploads all-or-nothing; duplicate filenames in one request get `_N` suffixes.
- Files live in thread-isolated `users/{user_id}/threads/{thread_id}/user-data/uploads` (IM channels thread the owner via the `user_id=` kwarg — see IM Channels → Owner-scoped file storage; HTTP/embedded callers resolve `get_effective_user_id()`); per-thread `upload-companions/` holds source mtime/ctime.
- Gateway HTTP uploads stage `.upload-*.part` files hidden from upload listings, agent context, and sandbox listings/searches; publication is atomic after size validation. Startup sweep keeps lone `.part` files under 24h (possibly in flight on another replica); `st_nlink > 1` means a published alias, removed at any age so the multi-link check can still replace the file.
- Upload/list/delete handlers offload filesystem work through `deerflow.utils.file_io.run_file_io` (ContextVar-preserving); non-mounted sandbox uploads use `SandboxProvider.acquire_async()` and offload `read_bytes()` + `sandbox.update_file()` together. Mounted uploads skip acquire/sync; AIO remote/provisioner requires accurate `sandbox.thread_data_mounts: true`, omission keeps backend auto-detection.
- `UploadsMiddleware` caps outline titles at 200 characters and previews at 2000 including markers. Titles use `original_user_content`; attachment-only titles use a sanitized, bounded filename or count.
See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
### Plan Mode
`config.configurable.is_plan_mode=True` enables TodoList `write_todos` for
multi-step tasks: one `in_progress` task, real-time updates. See
[usage](docs/plan_mode_usage.md).
### Run Interaction Policy
Interaction-sensitive changes must follow [policy](docs/RUN_INTERACTION_POLICY.md).
### Context Summarization
Automatic conversation summarization when approaching token limits, configured
under the `summarization` key in `config.yaml` (triggers: tokens, messages, or
fraction of max input); recent messages are kept while older ones are summarized.
- Manual compaction uses `POST /api/threads/{id}/compact`, reuses the same `DeerFlowSummarizationMiddleware`, writes a new checkpoint with updated `messages`/`summary_text`, and bumps only those channel versions. The route uses the shared `reserve_checkpoint_write()` boundary (also used by manual state updates); its short-lived `checkpoint_write` operation shares the durable active-thread uniqueness constraint with run admission, preventing worker-local and cross-worker checkpoint-write races.
- Cache only first-candidate no-ops; never suppress primary retries with fallback results. See [reuse and telemetry](docs/summarization.md#reuse-and-telemetry) for cache and counter contracts.
See [docs/summarization.md](docs/summarization.md) for details.
### Vision Support
For models with `supports_vision: true`: `ViewImageMiddleware` processes images and adds `view_image_tool`. Images reach the model as base64 in a hidden message carrying a reserved ID prefix plus a server-owned metadata marker (Gateway strips the marker from untrusted input; the middleware requires both to recognize its own message). The injection happens inside `wrap_model_call`, so payloads never enter graph state — checkpoints keep only lightweight `viewed_images` metadata, and client-chosen IDs survive; the middleware also sweeps its own message out of every request before rebuilding it, so a payload stranded in an older checkpoint by an interrupted run stops being resent.
## Code Style
- Uses `ruff` for linting and formatting
- Line length: 240 characters
- Python 3.12+ with type hints
- Double quotes, space indentation
## Documentation
See `docs/` directory for detailed documentation:
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - Architecture details
- [API.md](docs/API.md) - API reference
- [SETUP.md](docs/SETUP.md) - Setup guide
- [FILE_UPLOAD.md](docs/FILE_UPLOAD.md) - File upload feature
- [PATH_EXAMPLES.md](docs/PATH_EXAMPLES.md) - Path types and usage
- [summarization.md](docs/summarization.md) - Context summarization
- [plan_mode_usage.md](docs/plan_mode_usage.md) - Plan mode with TodoList

View File

@ -22,7 +22,7 @@ gateway:
# Run all four duration-balanced shards locally; wait for every shard on failure. # Run all four duration-balanced shards locally; wait for every shard on failure.
TEST_JOBS ?= 4 TEST_JOBS ?= 4
TEST_SHARD_TARGETS := test-shard-1 test-shard-2 test-shard-3 test-shard-4 TEST_SHARD_TARGETS := test-shard-1 test-shard-2 test-shard-3 test-shard-4
.PHONY: test test-shard test-bundled-extensions $(TEST_SHARD_TARGETS) .PHONY: test test-shard $(TEST_SHARD_TARGETS)
test: test:
$(MAKE) --no-print-directory -k -j$(TEST_JOBS) $(TEST_SHARD_TARGETS) $(MAKE) --no-print-directory -k -j$(TEST_JOBS) $(TEST_SHARD_TARGETS)
@ -49,21 +49,13 @@ DURATIONS_FILE ?= .test_durations
test-shard: test-shard:
@test -f "$(DURATIONS_FILE)" || \ @test -f "$(DURATIONS_FILE)" || \
(echo "error: $(DURATIONS_FILE) is missing; run 'make test-shard-durations' to regenerate the duration baseline" >&2; exit 1) (echo "error: $(DURATIONS_FILE) is missing; run 'make test-shard-durations' to regenerate the duration baseline" >&2; exit 1)
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest -m "not live and not bundled_extension" --ignore=tests/blocking_io --splits $(SPLITS) --group $(GROUP) --splitting-algorithm least_duration --durations-path=$(DURATIONS_FILE) tests/ -q PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest -m "not live" --ignore=tests/blocking_io --splits $(SPLITS) --group $(GROUP) --splitting-algorithm least_duration --durations-path=$(DURATIONS_FILE) tests/ -q
# Regenerate .test_durations from the offline core suite (non-live, # Regenerate .test_durations from the full offline (non-live, non-blocking-I/O)
# non-blocking-I/O, non-bundled-extension) so duration-aware sharding reflects # suite so duration-aware sharding reflects real wall-clock cost. Run locally or
# real wall-clock cost. Run locally or in a periodic job, then commit the # in a periodic job, then commit the refreshed file.
# refreshed file.
test-shard-durations: test-shard-durations:
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest -m "not live and not bundled_extension" --ignore=tests/blocking_io --store-durations --clean-durations --durations-path=$(DURATIONS_FILE) tests/ -q PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest -m "not live" --ignore=tests/blocking_io --store-durations --clean-durations --durations-path=$(DURATIONS_FILE) tests/ -q
# Bundled extension examples own their behavior suites; the core shards exclude
# them so an optional example cannot fail an unrelated core PR. The extension CI
# workflow runs this target when an example, the extension contract or the
# bundled tests themselves change.
test-bundled-extensions:
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest -m "bundled_extension and not live" tests/ -q
lint: lint:
uv run ruff check . uv run ruff check .

View File

@ -54,10 +54,6 @@ The single LangGraph agent (`lead_agent`) is the runtime entry point, created vi
Middlewares execute in strict order, each handling a specific concern: Middlewares execute in strict order, each handling a specific concern:
Starting a new graph invocation resets tool-progress tracking. Results and model
retries still finishing from an earlier invocation cannot block its tools or
restore obsolete progress hints, including same-run goal continuations.
| # | Middleware | Purpose | | # | Middleware | Purpose |
|---|-----------|---------| |---|-----------|---------|
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) | | 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
@ -78,12 +74,6 @@ receive a transient hint on the next model request.
### Sandbox System ### Sandbox System
With lazy initialization, a fresh AIO run prepares its authorized Agent skill
view without acquiring a container until a sandbox-backed tool needs one.
Existing sandbox state still requires eager policy rebinding; upload-based
providers still acquire and synchronize skills eagerly. Explicit sandbox
prewarming remains a separate opt-in behavior.
Per-thread isolated execution with virtual path translation: Per-thread isolated execution with virtual path translation:
- **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir` - **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir`
@ -115,10 +105,6 @@ the same item or undo a pause/cancellation observed after their initial read.
### Memory System ### Memory System
If the local retrieval index is busy, read-only, or temporarily unavailable,
DeerMem falls back to substring search without deleting it. Automatic index
recreation is reserved for SQLite-confirmed corruption or a non-database file.
LLM-powered persistent context retention across conversations: LLM-powered persistent context retention across conversations:
- **Automatic extraction**: Analyzes conversations for user context, facts, and preferences - **Automatic extraction**: Analyzes conversations for user context, facts, and preferences
@ -170,8 +156,8 @@ to a boundary (SQLite) or causing the search to fail (PostgreSQL).
| `POST /api/skills/install` | Install skill from `.skill` archive | | `POST /api/skills/install` | Install skill from `.skill` archive |
| `GET /api/memory` | Retrieve memory data | | `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload | | `POST /api/memory/reload` | Force memory reload |
| `GET /api/memory/config` | Memory configuration; credential values in `backend_config` are masked | | `GET /api/memory/config` | Memory configuration |
| `GET /api/memory/status` | Combined config (masked the same way) + data | | `GET /api/memory/status` | Combined config + data |
| `GET /api/threads/{id}/runs/{run_id}/events` | Debug/audit events for one run; filter `event_types=context:memory` for effective memory identity | | `GET /api/threads/{id}/runs/{run_id}/events` | Debug/audit events for one run; filter `event_types=context:memory` for effective memory identity |
| `POST /api/threads/{id}/uploads` | Upload files (optional PDF/PPT/Excel/Word-to-Markdown conversion, rejects directory paths, auto-renames duplicate filenames in one request) | | `POST /api/threads/{id}/uploads` | Upload files (optional PDF/PPT/Excel/Word-to-Markdown conversion, rejects directory paths, auto-renames duplicate filenames in one request) |
| `GET /api/threads/{id}/uploads/list` | List uploaded files | | `GET /api/threads/{id}/uploads/list` | List uploaded files |

View File

@ -126,8 +126,8 @@ FastAPI 应用程序,为前端集成提供 REST 接口:
| `POST /api/skills/install` | 从 `.skill` 归档文件安装技能 | | `POST /api/skills/install` | 从 `.skill` 归档文件安装技能 |
| `GET /api/memory` | 获取记忆数据 | | `GET /api/memory` | 获取记忆数据 |
| `POST /api/memory/reload` | 强制重新加载记忆 | | `POST /api/memory/reload` | 强制重新加载记忆 |
| `GET /api/memory/config` | 获取记忆配置;`backend_config` 中的凭据值会被遮蔽 | | `GET /api/memory/config` | 获取记忆配置 |
| `GET /api/memory/status` | 获取组合后的配置(同样遮蔽凭据)和数据状态 | | `GET /api/memory/status` | 获取组合后的配置和数据状态 |
| `GET /api/threads/{id}/runs/{run_id}/events` | 获取某次运行的调试或审计事件;可使用 `event_types=context:memory` 筛选实际记忆标识 | | `GET /api/threads/{id}/runs/{run_id}/events` | 获取某次运行的调试或审计事件;可使用 `event_types=context:memory` 筛选实际记忆标识 |
| `POST /api/threads/{id}/uploads` | 上传文件(可选地将 PDF、PPT、Excel、Word 转换为 Markdown;拒绝目录路径;自动重命名单次请求中的重复文件名) | | `POST /api/threads/{id}/uploads` | 上传文件(可选地将 PDF、PPT、Excel、Word 转换为 Markdown;拒绝目录路径;自动重命名单次请求中的重复文件名) |
| `GET /api/threads/{id}/uploads/list` | 列出已上传的文件 | | `GET /api/threads/{id}/uploads/list` | 列出已上传的文件 |

File diff suppressed because one or more lines are too long

View File

@ -250,12 +250,7 @@ class Channel(ABC):
log_prefix: str | None = None, log_prefix: str | None = None,
operation_name: str = "send", operation_name: str = "send",
) -> T: ) -> T:
"""Run an outbound send operation with the shared channel retry policy. """Run an outbound send operation with the shared channel retry policy."""
The wait between attempts is ``_outbound_retry_delay``. The default
is ``2**attempt``; a provider that is told how long to wait overrides
that without changing the attempt bound or the other channels.
"""
prefix = log_prefix or f"[{self.name}]" prefix = log_prefix or f"[{self.name}]"
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(max_retries): for attempt in range(max_retries):
@ -264,9 +259,9 @@ class Channel(ABC):
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
if attempt < max_retries - 1: if attempt < max_retries - 1:
delay = self._outbound_retry_delay(attempt, exc) delay = 2**attempt
logger.warning( logger.warning(
"%s %s failed (attempt %d/%d), retrying in %ss: %s", "%s %s failed (attempt %d/%d), retrying in %ds: %s",
prefix, prefix,
operation_name, operation_name,
attempt + 1, attempt + 1,
@ -281,16 +276,6 @@ class Channel(ABC):
raise RuntimeError(f"{self.name} {operation_name} failed without an exception from any attempt") raise RuntimeError(f"{self.name} {operation_name} failed without an exception from any attempt")
raise last_exc raise last_exc
def _outbound_retry_delay(self, attempt: int, exc: Exception) -> float:
"""Seconds to wait before the next attempt.
``exc`` is the failure just caught. The shared policy ignores it and
sleeps ``2**attempt`` (1s, then 2s, then 4s, ...). A provider that
receives a server wait hint overrides this and may return a longer
delay; it must not shorten the local backoff.
"""
return 2**attempt
def _log_future_error(self, fut: Any, name: str, msg_id: Any) -> None: def _log_future_error(self, fut: Any, name: str, msg_id: Any) -> None:
"""Callback for concurrent futures scheduled from channel worker threads.""" """Callback for concurrent futures scheduled from channel worker threads."""
try: try:

View File

@ -10,7 +10,6 @@ import re
import stat import stat
import threading import threading
import time import time
import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote_plus from urllib.parse import quote_plus
@ -203,6 +202,7 @@ class DingTalkChannel(Channel):
self._stream_lock = threading.Lock() self._stream_lock = threading.Lock()
self._incoming_messages: dict[str, Any] = {} self._incoming_messages: dict[str, Any] = {}
self._incoming_messages_lock = threading.Lock() self._incoming_messages_lock = threading.Lock()
self._card_repliers: dict[str, Any] = {}
# Serialize inbound-file writes into the uploads directory to avoid # Serialize inbound-file writes into the uploads directory to avoid
# racing writers clobbering one another (mirrors FeishuChannel). # racing writers clobbering one another (mirrors FeishuChannel).
self._file_write_lock = threading.Lock() self._file_write_lock = threading.Lock()
@ -263,6 +263,7 @@ class DingTalkChannel(Channel):
self._dingtalk_client = None self._dingtalk_client = None
with self._incoming_messages_lock: with self._incoming_messages_lock:
self._incoming_messages.clear() self._incoming_messages.clear()
self._card_repliers.clear()
self._card_track_ids.clear() self._card_track_ids.clear()
if self._thread: if self._thread:
# The thread exits once its loop has stopped; keep the join off the # The thread exits once its loop has stopped; keep the join off the
@ -350,10 +351,12 @@ class DingTalkChannel(Channel):
logger.warning("[DingTalk] card stream failed, falling back to sampleMarkdown") logger.warning("[DingTalk] card stream failed, falling back to sampleMarkdown")
if msg.is_final: if msg.is_final:
self._card_track_ids.pop(source_key, None) self._card_track_ids.pop(source_key, None)
self._card_repliers.pop(out_track_id, None)
await self._send_markdown_fallback(robot_code, conversation_type, sender_staff_id, conversation_id, msg.text) await self._send_markdown_fallback(robot_code, conversation_type, sender_staff_id, conversation_id, msg.text)
return return
if msg.is_final: if msg.is_final:
self._card_track_ids.pop(source_key, None) self._card_track_ids.pop(source_key, None)
self._card_repliers.pop(out_track_id, None)
return return
async def send_markdown() -> None: async def send_markdown() -> None:
@ -438,24 +441,6 @@ class DingTalkChannel(Channel):
}, },
) )
response.raise_for_status() response.raise_for_status()
data = response.json()
process_key = data.get("processQueryKey") if isinstance(data, dict) else None
if not isinstance(process_key, str) or not process_key.strip():
logger.warning(
"[DingTalk] file send response missing processQueryKey for %s; treating as not delivered",
attachment.filename,
)
return False
if conversation_type != _CONVERSATION_TYPE_GROUP:
target = sender_staff_id
for field in ("invalidStaffIdList", "flowControlledStaffIdList"):
failed = data.get(field, [])
if not isinstance(failed, list):
logger.warning("[DingTalk] unexpected %s in file send response for %s", field, attachment.filename)
return False
if target in failed:
logger.warning("[DingTalk] file not delivered for %s (%s)", attachment.filename, field)
return False
logger.info("[DingTalk] file sent: %s", attachment.filename) logger.info("[DingTalk] file sent: %s", attachment.filename)
return True return True
@ -1259,42 +1244,30 @@ class DingTalkChannel(Channel):
logger.warning("[DingTalk] SDK client or chatbot_message unavailable, skipping AI card") logger.warning("[DingTalk] SDK client or chatbot_message unavailable, skipping AI card")
return None return None
out_track_id = uuid.uuid4().hex
try: try:
await self._card_request("POST", "/v1.0/card/instances/createAndDeliver", self._card_create_body(out_track_id, chatbot_message, initial_text)) from dingtalk_stream.card_replier import AICardReplier
except ImportError:
logger.warning("[DingTalk] dingtalk-stream card_replier not available")
return None
try:
token_client = _CardTokenClient(self._dingtalk_client.credential)
token_client.access_token = await self._get_access_token()
replier = AICardReplier(token_client, chatbot_message)
card_instance_id = await replier.async_create_and_deliver_card(
card_template_id=self._card_template_id,
card_data={"content": initial_text},
)
if not card_instance_id:
return None
self._card_repliers[card_instance_id] = replier
logger.info("[DingTalk] AI card created: outTrackId=%s", card_instance_id)
return card_instance_id
except Exception: except Exception:
logger.exception("[DingTalk] failed to create AI card") logger.exception("[DingTalk] failed to create AI card")
return None return None
logger.info("[DingTalk] AI card created: outTrackId=%s", out_track_id)
return out_track_id
def _card_create_body(self, out_track_id: str, chatbot_message: Any, initial_text: str) -> dict[str, Any]:
"""The ``createAndDeliver`` body dingtalk-stream's ``AICardReplier`` sends."""
body: dict[str, Any] = {
"cardTemplateId": self._card_template_id,
"outTrackId": out_track_id,
"cardData": {"cardParamMap": {"content": initial_text}},
"callbackType": "STREAM",
"imGroupOpenSpaceModel": {"supportForward": True},
"imRobotOpenSpaceModel": {"supportForward": True},
}
hosting_context = getattr(chatbot_message, "hosting_context", None)
conversation_type = str(chatbot_message.conversation_type)
if conversation_type == _CONVERSATION_TYPE_GROUP:
body["openSpaceId"] = f"dtv1.card//IM_GROUP.{chatbot_message.conversation_id}"
deliver_model: dict[str, Any] = {"robotCode": self._client_id}
body["imGroupOpenDeliverModel"] = deliver_model
elif conversation_type == _CONVERSATION_TYPE_P2P:
body["openSpaceId"] = f"dtv1.card//IM_ROBOT.{chatbot_message.sender_staff_id}"
deliver_model = {"spaceType": "IM_ROBOT"}
body["imRobotOpenDeliverModel"] = deliver_model
else:
return body
if hosting_context is not None:
deliver_model["extension"] = {"hostingRepliedContext": json.dumps({"userId": hosting_context.user_id})}
return body
async def _stream_update_card( async def _stream_update_card(
self, self,
out_track_id: str, out_track_id: str,
@ -1303,35 +1276,20 @@ class DingTalkChannel(Channel):
is_finalize: bool = False, is_finalize: bool = False,
is_error: bool = False, is_error: bool = False,
) -> None: ) -> None:
await self._card_request( replier = self._card_repliers.get(out_track_id)
"PUT", if not replier:
"/v1.0/card/streaming", raise RuntimeError(f"No AICardReplier found for track ID {out_track_id}")
{
"outTrackId": out_track_id, replier.dingtalk_client.access_token = await self._get_access_token()
"guid": str(uuid.uuid4()), await replier.async_streaming(
"key": "content", card_instance_id=out_track_id,
"content": content, content_key="content",
"isFull": True, content_value=content,
"isFinalize": is_finalize, append=False,
"isError": is_error, finished=is_finalize,
}, failed=is_error,
) )
async def _card_request(self, method: str, path: str, body: dict[str, Any]) -> None:
"""Send an AI card request with the channel token; raise if it fails.
dingtalk-stream's ``AICardReplier`` logs a failed card request and
returns as if it succeeded; its create call even returns the card ID.
Neither the running reply's text fallback nor the sampleMarkdown
fallback in ``send`` could then run, so the reply was lost.
"""
token = await self._get_access_token()
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
response = await client.request(method, f"{DINGTALK_API_BASE}{path}", headers=self._api_headers(token), json=body)
if response.is_error:
logger.warning("[DingTalk] card request %s %s returned %s: %s", method, path, response.status_code, response.text[:500])
response.raise_for_status()
# -- media upload -------------------------------------------------------- # -- media upload --------------------------------------------------------
async def _upload_media(self, file_path: str | Path, media_type: str) -> str | None: async def _upload_media(self, file_path: str | Path, media_type: str) -> str | None:
@ -1360,6 +1318,26 @@ class DingTalkChannel(Channel):
return None return None
class _CardTokenClient:
"""The client ``AICardReplier`` sees: it serves the channel's access token.
The replier's async methods call ``dingtalk_client.get_access_token()``
inline, and dingtalk-stream's version refreshes through ``requests.post``
with no timeout, so on the Gateway loop every refresh (about every two
hours) blocked the loop for the whole request, indefinitely if DingTalk
stalled. The channel sets ``access_token`` from its own bounded
``_get_access_token()`` before every replier call, so the replier never
fetches a token itself.
"""
def __init__(self, credential: Any) -> None:
self.credential = credential
self.access_token = ""
def get_access_token(self) -> str:
return self.access_token
class _DingTalkMessageHandler: class _DingTalkMessageHandler:
"""Callback handler registered with dingtalk-stream.""" """Callback handler registered with dingtalk-stream."""

View File

@ -28,7 +28,6 @@ from app.channels.message_bus import (
OutboundMessage, OutboundMessage,
ResolvedAttachment, ResolvedAttachment,
) )
from deerflow.utils.atomic_write import publish_bytes_atomically
from deerflow.utils.file_io import await_drained from deerflow.utils.file_io import await_drained
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -276,10 +275,7 @@ class DiscordChannel(Channel):
try: try:
snapshot = dict(self._active_threads) snapshot = dict(self._active_threads)
self._thread_store_path.parent.mkdir(parents=True, exist_ok=True) self._thread_store_path.parent.mkdir(parents=True, exist_ok=True)
# Publish through the shared helper: it fsyncs the staged bytes self._thread_store_path.write_text(json.dumps(snapshot, indent=2), encoding="utf-8", newline="")
# before the rename (so a machine crash cannot leave an empty
# mapping behind) and re-applies an existing file's mode.
publish_bytes_atomically(self._thread_store_path, json.dumps(snapshot, indent=2).encode("utf-8"))
except Exception: except Exception:
logger.exception("[Discord] failed to persist thread mappings") logger.exception("[Discord] failed to persist thread mappings")

View File

@ -55,9 +55,6 @@ PENDING_CLARIFICATION_TTL_SECONDS = 30 * 60
FEISHU_STOP_TIMEOUT_SECONDS = 5 FEISHU_STOP_TIMEOUT_SECONDS = 5
FEISHU_WS_CLOSE_TIMEOUT_SECONDS = 2 FEISHU_WS_CLOSE_TIMEOUT_SECONDS = 2
_PENDING_CARD_METADATA_KEY = "_feishu_pending_card_id" _PENDING_CARD_METADATA_KEY = "_feishu_pending_card_id"
# Number of attachments from a post's top-level ``files`` array.
_POST_FILES_COUNT_METADATA_KEY = "_feishu_post_files_count"
_DEFER_POST_FILE_PLACEHOLDERS_METADATA_KEY = "_feishu_defer_post_file_placeholders"
# Channel-store deadlines. ``_on_message`` runs on lark-oapi's private event loop, # Channel-store deadlines. ``_on_message`` runs on lark-oapi's private event loop,
# which also carries the SDK's websocket reads, pongs and its 120s ping loop: # which also carries the SDK's websocket reads, pongs and its 120s ping loop:
# every second the bridge blocks that loop is a second the connection cannot # every second the bridge blocks that loop is a second the connection cannot
@ -110,7 +107,6 @@ class _ParsedMessage:
chat_type: str | None chat_type: str | None
text: str text: str
files: list[dict[str, Any]] files: list[dict[str, Any]]
post_file_count: int = 0
msg_type: InboundMessageType = InboundMessageType.CHAT msg_type: InboundMessageType = InboundMessageType.CHAT
@ -148,110 +144,12 @@ class _RoutingAttempt:
FEISHU_INBOUND_BATCH_WINDOW_SECONDS = 0.75 FEISHU_INBOUND_BATCH_WINDOW_SECONDS = 0.75
FEISHU_MAX_INBOUND_FILE_BYTES = 20_000_000 FEISHU_MAX_INBOUND_FILE_BYTES = 20_000_000
SOURCE_PREVIEW_METADATA_KEY = "feishu_source_preview" SOURCE_PREVIEW_METADATA_KEY = "feishu_source_preview"
# Feishu rejects a card request body over 30 KB (230025), and style tags make
# the stored message larger than the body sent. Card text is budgeted by the
# bytes it adds to that body, leaving room for the other fields and that growth.
FEISHU_CARD_MAX_BYTES = 24 * 1024
# A long final answer continues in further cards, up to this many per reply.
FEISHU_MAX_CARDS_PER_REPLY = 10
_CARD_TRUNCATION_MARKER = "\n\n... (truncated)"
_CARD_CONTINUES_MARKER = "\n\n…"
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
# Characters JSON escapes, measured after the second escape: ``"`` is ``\\\"``.
_ESCAPED_CARD_CHAR_BYTES = {'"': 4, "\\": 4, "\b": 3, "\f": 3, "\n": 3, "\r": 3, "\t": 3}
def _card_char_bytes(char: str) -> int:
escaped = _ESCAPED_CARD_CHAR_BYTES.get(char)
if escaped is not None:
return escaped
code = ord(char)
if code < 0x20:
return 7 # \u00XX, whose backslash is escaped again
if code < 0x80:
return 1
if code < 0x800:
return 2
if code < 0x10000:
return 3 # a lone surrogate is sent as U+FFFD
return 4
def _card_text_bytes(text: str) -> int:
"""Bytes *text* adds to a card request body.
The text is escaped twice: once inside the card JSON, and again as the
request body's ``content`` string. JSON escapes each character on its own,
so the cost of a text is the sum of its characters' costs.
"""
return sum(map(_card_char_bytes, text))
def _card_text_budget() -> int:
return FEISHU_CARD_MAX_BYTES - _card_text_bytes(FeishuChannel._build_card_content(""))
def _fitting_prefix_end(text: str, start: int, budget: int) -> tuple[int, int]:
"""Return the end of the longest prefix of ``text[start:]`` within *budget*, and its last newline."""
used = 0
end = start
last_newline = -1
while end < len(text):
cost = _card_char_bytes(text[end])
if used + cost > budget:
break
used += cost
if text[end] == "\n":
last_newline = end
end += 1
return end, last_newline
def _clip_card_text(text: str, marker: str = _CARD_TRUNCATION_MARKER) -> str:
"""Clip *text* to one card, ending it with *marker* when anything was cut."""
budget = _card_text_budget()
if _card_text_bytes(text) <= budget:
return text
end, _ = _fitting_prefix_end(text, 0, budget - _card_text_bytes(marker))
return text[:end] + marker
def _split_card_text(text: str) -> list[str]:
"""Split *text* into cards, preferring line boundaries so markdown survives.
Each newline stays on the tail of its card, so the cards join back to
the original text. Past ``FEISHU_MAX_CARDS_PER_REPLY`` the last card is
clipped instead.
"""
budget = _card_text_budget()
if _card_text_bytes(text) <= budget:
return [text]
cards: list[str] = []
start = 0
while start < len(text):
if len(cards) == FEISHU_MAX_CARDS_PER_REPLY - 1:
logger.warning("[Feishu] reply of %d characters exceeds %d cards, clipping the last one", len(text), FEISHU_MAX_CARDS_PER_REPLY)
cards.append(_clip_card_text(text[start:]))
break
end, last_newline = _fitting_prefix_end(text, start, budget)
if end < len(text) and last_newline >= start:
end = last_newline + 1
end = max(end, start + 1) # always advance, even past an oversized character
cards.append(text[start:end])
start = end
return cards
def _is_feishu_command(text: str) -> bool: def _is_feishu_command(text: str) -> bool:
return is_known_channel_command(text) return is_known_channel_command(text)
def _append_post_file_placeholders(text: str, count: int) -> str:
paragraphs = [text] if text else []
paragraphs.extend(["[file]"] * count)
return "\n\n".join(paragraphs)
class FeishuChannel(Channel): class FeishuChannel(Channel):
"""Feishu/Lark IM channel using the ``lark-oapi`` WebSocket client. """Feishu/Lark IM channel using the ``lark-oapi`` WebSocket client.
@ -651,10 +549,8 @@ class FeishuChannel(Channel):
len(msg.text), len(msg.text),
) )
# Cards already delivered by an earlier attempt are not sent again.
delivered: list[str | None] = []
await self._send_with_retry( await self._send_with_retry(
lambda: self._send_card_message(msg, delivered=delivered), lambda: self._send_card_message(msg),
max_retries=_max_retries, max_retries=_max_retries,
log_prefix="[Feishu]", log_prefix="[Feishu]",
) )
@ -772,7 +668,9 @@ class FeishuChannel(Channel):
async def _upload_image(self, path) -> str: async def _upload_image(self, path) -> str:
"""Upload an image to Feishu and return the image_key.""" """Upload an image to Feishu and return the image_key."""
response = await asyncio.to_thread(self._upload_image_sync, path) response = await asyncio.to_thread(self._upload_image_sync, path)
return self._uploaded_key(response, "image") if not response.success():
raise RuntimeError(f"Feishu image upload failed: code={response.code}, msg={response.msg}")
return response.data.image_key
def _upload_file_sync(self, path, filename: str, file_type: str): def _upload_file_sync(self, path, filename: str, file_type: str):
with open(str(path), "rb") as f: with open(str(path), "rb") as f:
@ -794,17 +692,9 @@ class FeishuChannel(Channel):
file_type = "stream" file_type = "stream"
response = await asyncio.to_thread(self._upload_file_sync, path, filename, file_type) response = await asyncio.to_thread(self._upload_file_sync, path, filename, file_type)
return self._uploaded_key(response, "file")
@staticmethod
def _uploaded_key(response, kind: str) -> str:
"""Return the ``image_key``/``file_key`` of an upload response or raise with its log_id."""
if not response.success(): if not response.success():
raise RuntimeError(f"Feishu {kind} upload failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}") raise RuntimeError(f"Feishu file upload failed: code={response.code}, msg={response.msg}")
key = getattr(getattr(response, "data", None), f"{kind}_key", None) return response.data.file_key
if not isinstance(key, str) or not key:
raise RuntimeError(f"Feishu {kind} upload returned no {kind}_key: log_id={response.get_log_id()}")
return key
async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage: async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage:
"""Download a Feishu file into the thread uploads directory. """Download a Feishu file into the thread uploads directory.
@ -819,10 +709,6 @@ class FeishuChannel(Channel):
logger.warning("[Feishu] received message with no files: %s", msg) logger.warning("[Feishu] received message with no files: %s", msg)
return msg return msg
text = msg.text text = msg.text
post_file_count = msg.metadata.pop(_POST_FILES_COUNT_METADATA_KEY, 0)
defer_post_file_placeholders = msg.metadata.pop(_DEFER_POST_FILE_PLACEHOLDERS_METADATA_KEY, False)
if not isinstance(post_file_count, int) or post_file_count < 0:
post_file_count = 0
search_from = 0 search_from = 0
def replace_next_placeholder(placeholder: str, replacement: str) -> None: def replace_next_placeholder(placeholder: str, replacement: str) -> None:
@ -833,40 +719,14 @@ class FeishuChannel(Channel):
text = f"{text[:idx]}{replacement}{text[idx + len(placeholder) :]}" text = f"{text[:idx]}{replacement}{text[idx + len(placeholder) :]}"
search_from = idx + len(replacement) search_from = idx + len(replacement)
downloaded: list[tuple[str, str]] = []
for file in files: for file in files:
if file.get("image_key"): if file.get("image_key"):
virtual_path = await self._receive_single_file(msg.thread_ts, file["image_key"], "image", thread_id, user_id=user_id) virtual_path = await self._receive_single_file(msg.thread_ts, file["image_key"], "image", thread_id, user_id=user_id)
downloaded.append(("[image]", virtual_path)) replace_next_placeholder("[image]", virtual_path)
elif file.get("file_key"): elif file.get("file_key"):
virtual_path = await self._receive_single_file(msg.thread_ts, file["file_key"], "file", thread_id, user_id=user_id) virtual_path = await self._receive_single_file(msg.thread_ts, file["file_key"], "file", thread_id, user_id=user_id)
downloaded.append(("[file]", virtual_path)) replace_next_placeholder("[file]", virtual_path)
# Top-level post attachments append their ``[file]`` placeholders
# after the parsed body, so their slots are the final
# ``post_file_count`` ones. Fill them from the end first: a literal
# ``[file]`` typed by the user must not consume a top-level
# attachment's path. Inline rich-text attachments keep the historical
# left-to-right matching below; that path is deliberately unchanged.
post_file_count = min(post_file_count, len(downloaded))
if defer_post_file_placeholders is True:
# A command such as /goal has now entered the chat path. Add the
# generated slots to the chat text after its arguments were parsed.
text = _append_post_file_placeholders(text, post_file_count)
suffix_end = len(text)
for placeholder, virtual_path in reversed(downloaded[len(downloaded) - post_file_count :] if post_file_count else []):
idx = text.rfind(placeholder, 0, suffix_end)
if idx < 0:
continue
text = f"{text[:idx]}{virtual_path}{text[idx + len(placeholder) :]}"
suffix_end = idx
for placeholder, virtual_path in downloaded[: len(downloaded) - post_file_count]:
replace_next_placeholder(placeholder, virtual_path)
msg.text = text msg.text = text
# Clear the descriptors so the generic URL-based _ingest_inbound_files
# pass does not try to re-fetch files that were downloaded by key.
msg.files = []
return msg return msg
async def _receive_single_file( async def _receive_single_file(
@ -998,11 +858,9 @@ class FeishuChannel(Channel):
""" """
card = { card = {
"config": {"wide_screen_mode": True, "update_multi": True}, "config": {"wide_screen_mode": True, "update_multi": True},
# Unpaired surrogates cannot be encoded as UTF-8. "elements": [{"tag": "markdown", "content": text}],
"elements": [{"tag": "markdown", "content": _LONE_SURROGATE.sub("\ufffd", text)}],
} }
# ASCII escapes would make each CJK character 6 bytes instead of 3. return json.dumps(card)
return json.dumps(card, ensure_ascii=False)
# -- reaction helpers -------------------------------------------------- # -- reaction helpers --------------------------------------------------
@ -1082,7 +940,7 @@ class FeishuChannel(Channel):
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> str | None: ) -> str | None:
"""Create the running card and cache its message ID when available.""" """Create the running card and cache its message ID when available."""
running_card_id = await self._reply_card(source_message_id, _clip_card_text(self._compose_card_text(text, metadata), _CARD_CONTINUES_MARKER)) running_card_id = await self._reply_card(source_message_id, self._compose_card_text(text, metadata))
if running_card_id: if running_card_id:
self._running_card_ids[source_message_id] = running_card_id self._running_card_ids[source_message_id] = running_card_id
logger.info("[Feishu] running card created: source=%s card=%s", source_message_id, running_card_id) logger.info("[Feishu] running card created: source=%s card=%s", source_message_id, running_card_id)
@ -1196,14 +1054,8 @@ class FeishuChannel(Channel):
self._remember_pending_clarification(msg, card_message_id, topic_id=topic_id) self._remember_pending_clarification(msg, card_message_id, topic_id=topic_id)
await self._remember_thread_mapping(msg, msg.thread_ts, card_message_id) await self._remember_thread_mapping(msg, msg.thread_ts, card_message_id)
async def _send_card_message(self, msg: OutboundMessage, *, delivered: list[str | None] | None = None) -> None: async def _send_card_message(self, msg: OutboundMessage) -> None:
"""Send or update the Feishu card tied to the current request. """Send or update the Feishu card tied to the current request."""
A final answer over one card's budget continues in further cards replied
to the same source message; a streamed update shows only what fits.
*delivered* collects the IDs of the final answer's cards across retries.
"""
delivered = [] if delivered is None else delivered
source_message_id = msg.thread_ts source_message_id = msg.thread_ts
# Option clarifications render as an interactive button card on the # Option clarifications render as an interactive button card on the
# final outbound; everything else keeps the plain markdown card. # final outbound; everything else keeps the plain markdown card.
@ -1211,45 +1063,11 @@ class FeishuChannel(Channel):
card_topic_id = await self._bind_card_thread(msg) if card_request else None card_topic_id = await self._bind_card_thread(msg) if card_request else None
if card_topic_id is None: if card_topic_id is None:
card_request = None card_request = None
card_text = build_button_card(card_request) if card_request else self._compose_card_text(msg.text, msg.metadata)
# Button cards arrive as serialized card JSON and bypass the markdown # Button cards arrive as serialized card JSON and bypass the markdown
# wrapper; the plain path keeps its exact historical call signature. # wrapper; the plain path keeps its exact historical call signature.
raw_card_kw: dict[str, bool] = {"raw_card_content": True} if card_request else {} raw_card_kw: dict[str, bool] = {"raw_card_content": True} if card_request else {}
if source_message_id: if source_message_id:
if card_request:
cards = [build_button_card(card_request)]
elif msg.is_final:
cards = _split_card_text(self._compose_card_text(msg.text, msg.metadata))
else:
cards = [_clip_card_text(self._compose_card_text(msg.text, msg.metadata), _CARD_CONTINUES_MARKER)]
if not delivered:
await self._send_first_card(msg, cards[0], card_request=card_request, card_topic_id=card_topic_id, delivered=delivered, raw_card_kw=raw_card_kw)
if msg.is_final:
for card_text in cards[len(delivered) :]:
delivered.append(await self._reply_card(source_message_id, card_text))
if len(delivered) > 1:
# A reply to any of the cards continues this thread.
await self._remember_thread_mapping(msg, *delivered[1:])
self._running_card_ids.pop(source_message_id, None)
self._remembered_thread_aliases.pop(source_message_id, None)
await self._add_reaction(source_message_id, "DONE")
return
for card_text in _split_card_text(msg.text)[len(delivered) :]:
await self._create_card(msg.chat_id, card_text)
delivered.append(None)
async def _send_first_card(
self,
msg: OutboundMessage,
card_text: str,
*,
card_request: ClarificationCardRequest | None,
card_topic_id: str | None,
delivered: list[str | None],
raw_card_kw: dict[str, bool],
) -> None:
"""Put *card_text* on the running card, or reply with it; record it in *delivered* when final."""
source_message_id = msg.thread_ts
running_card_id = self._running_card_ids.get(source_message_id) running_card_id = self._running_card_ids.get(source_message_id)
awaited_running_card_task = False awaited_running_card_task = False
@ -1270,11 +1088,8 @@ class FeishuChannel(Channel):
running_card_id, running_card_id,
) )
fallback_card_id = await self._reply_card(source_message_id, card_text, **raw_card_kw) fallback_card_id = await self._reply_card(source_message_id, card_text, **raw_card_kw)
delivered.append(fallback_card_id)
await self._remember_card_delivery(msg, card_request, fallback_card_id, topic_id=card_topic_id) await self._remember_card_delivery(msg, card_request, fallback_card_id, topic_id=card_topic_id)
else: else:
if msg.is_final:
delivered.append(running_card_id)
await self._remember_card_delivery(msg, card_request, running_card_id, topic_id=card_topic_id) await self._remember_card_delivery(msg, card_request, running_card_id, topic_id=card_topic_id)
logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id) logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id)
elif msg.is_final: elif msg.is_final:
@ -1283,7 +1098,6 @@ class FeishuChannel(Channel):
card_text, card_text,
**raw_card_kw, **raw_card_kw,
) )
delivered.append(final_card_id)
await self._remember_card_delivery(msg, card_request, final_card_id, topic_id=card_topic_id) await self._remember_card_delivery(msg, card_request, final_card_id, topic_id=card_topic_id)
elif awaited_running_card_task: elif awaited_running_card_task:
logger.warning( logger.warning(
@ -1298,6 +1112,14 @@ class FeishuChannel(Channel):
) )
await self._remember_thread_mapping(msg, source_message_id, created_card_id) await self._remember_thread_mapping(msg, source_message_id, created_card_id)
if msg.is_final:
self._running_card_ids.pop(source_message_id, None)
self._remembered_thread_aliases.pop(source_message_id, None)
await self._add_reaction(source_message_id, "DONE")
return
await self._create_card(msg.chat_id, msg.text)
# -- internal ---------------------------------------------------------- # -- internal ----------------------------------------------------------
async def _remember_thread_mapping(self, msg: OutboundMessage, *topic_ids: str | None) -> None: async def _remember_thread_mapping(self, msg: OutboundMessage, *topic_ids: str | None) -> None:
@ -1787,10 +1609,6 @@ class FeishuChannel(Channel):
} }
if source_preview: if source_preview:
metadata[SOURCE_PREVIEW_METADATA_KEY] = source_preview metadata[SOURCE_PREVIEW_METADATA_KEY] = source_preview
if parsed.post_file_count:
metadata[_POST_FILES_COUNT_METADATA_KEY] = parsed.post_file_count
if parsed.msg_type == InboundMessageType.COMMAND:
metadata[_DEFER_POST_FILE_PLACEHOLDERS_METADATA_KEY] = True
if routing.clarification_card_id: if routing.clarification_card_id:
metadata[_PENDING_CARD_METADATA_KEY] = routing.clarification_card_id metadata[_PENDING_CARD_METADATA_KEY] = routing.clarification_card_id
@ -2129,26 +1947,6 @@ class FeishuChannel(Channel):
text = "" text = ""
text = text.strip() text = text.strip()
# Feishu desktop attaches files to a message as a post whose rich-text
# body holds the caption and whose top-level ``files`` array holds the
# attachments. The body may be empty. Keep these attachments last so
# ``receive_file`` can match their placeholders from the end. Skip
# folders and attachments already represented by a rich-text element.
attachments = content.get("files") if isinstance(content, dict) else None
if not isinstance(attachments, list):
attachments = []
post_file_count = 0
for attachment in attachments:
if not isinstance(attachment, dict) or attachment.get("is_folder"):
continue
file_key = attachment.get("file_key")
if not isinstance(file_key, str) or not file_key:
continue
if any(existing.get("file_key") == file_key for existing in files_list):
continue
files_list.append({"file_key": file_key})
post_file_count += 1
logger.info( logger.info(
"[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, parent_id=%s, thread_id=%s, chat_type=%s, sender=%s, text_len=%d", "[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, parent_id=%s, thread_id=%s, chat_type=%s, sender=%s, text_len=%d",
chat_id, chat_id,
@ -2174,7 +1972,6 @@ class FeishuChannel(Channel):
chat_type=chat_type, chat_type=chat_type,
text=text, text=text,
files=files_list, files=files_list,
post_file_count=post_file_count,
) )
def _on_message(self, event) -> None: def _on_message(self, event) -> None:
@ -2217,8 +2014,6 @@ class FeishuChannel(Channel):
if _is_feishu_command(command_text): if _is_feishu_command(command_text):
parsed.msg_type = InboundMessageType.COMMAND parsed.msg_type = InboundMessageType.COMMAND
parsed.text = command_text parsed.text = command_text
elif parsed.post_file_count:
parsed.text = _append_post_file_placeholders(text, parsed.post_file_count)
# topic_id determines which LangGraph thread the message maps to. # topic_id determines which LangGraph thread the message maps to.
# P2P chats: topic_id=None so all messages share one thread (like Telegram DMs). # P2P chats: topic_id=None so all messages share one thread (like Telegram DMs).

View File

@ -16,7 +16,6 @@ from __future__ import annotations
import math import math
from collections.abc import Mapping from collections.abc import Mapping
from app.channels.reply_locale import supported_locale
from app.scheduler.notification_text import goal_reason_text from app.scheduler.notification_text import goal_reason_text
from deerflow.persistence.scheduled_task_runs.finalization import CHECK_FAILURE_CODES from deerflow.persistence.scheduled_task_runs.finalization import CHECK_FAILURE_CODES
from deerflow.runtime.goal import is_active_goal from deerflow.runtime.goal import is_active_goal
@ -39,18 +38,6 @@ _TEXT: dict[str, dict[str, str]] = {
"fetch_failed": "Failed to fetch goal information.", "fetch_failed": "Failed to fetch goal information.",
"clear_failed": "Failed to clear goal.", "clear_failed": "Failed to clear goal.",
"set_failed": "Failed to set goal.", "set_failed": "Failed to set goal.",
"busy": "A task is still running. Wait for it to finish before changing the goal.",
"from_chat_saved": "Goal set from the conversation: {objective}",
"from_chat_saved_but_not_started": "Goal saved from the conversation, but the task did not start. Send any message to pick it up, or check /goal.",
"from_chat_run_status_unknown": "Goal saved from the conversation. I couldn't confirm the task's status, and it may still be running. Check the conversation in DeerFlow before sending it again.",
"from_chat_unclear": "I couldn't form a clear goal from this conversation. Try adding a hint, e.g. /goal from-chat focus on the vendor totals.",
"from_chat_no_context": "There is no conversation content to build a goal from yet. Discuss the task first, or set it directly with /goal <objective>.",
"from_chat_pending_clarification": "The assistant is still waiting for your answer in this conversation. Reply to it first, then retry /goal from-chat.",
"from_chat_no_thread": "No conversation yet. Send a message first, then ask /goal from-chat.",
"from_chat_changed": "The conversation changed while the goal was being composed, so nothing was saved. Retry /goal from-chat, or set it directly with /goal <objective>.",
"from_chat_conversation_switched": "This chat switched to a new conversation while the goal was being composed, so the task did not start. The goal remains saved in the previous conversation: {objective}",
"from_chat_timeout": "Timed out waiting for the goal to be composed. The save may still finish — check with /goal, and retry if there is no goal.",
"from_chat_failed": "Failed to compose the goal from the conversation. Try again, or set it directly with /goal <objective>.",
}, },
"zh-CN": { "zh-CN": {
"goal": "目标:{objective}", "goal": "目标:{objective}",
@ -69,18 +56,6 @@ _TEXT: dict[str, dict[str, str]] = {
"fetch_failed": "无法获取目标信息。", "fetch_failed": "无法获取目标信息。",
"clear_failed": "无法清除目标。", "clear_failed": "无法清除目标。",
"set_failed": "无法设置目标。", "set_failed": "无法设置目标。",
"busy": "当前任务仍在运行,请等待结束后再修改目标。",
"from_chat_saved": "已从对话设置目标:{objective}",
"from_chat_saved_but_not_started": "目标已从对话保存,但任务未能启动。发送任意消息即可继续,或用 /goal 查看状态。",
"from_chat_run_status_unknown": "目标已从对话保存,但暂时无法确认任务状态,任务可能仍在运行。请先在 DeerFlow 中查看会话,再决定是否重试。",
"from_chat_unclear": "无法从当前对话中形成清楚的目标。可以补充提示,例如 /goal from-chat 重点核对供应商合计。",
"from_chat_no_context": "当前对话还没有可用于构建目标的内容。先讨论任务,或用 /goal <目标> 直接设置。",
"from_chat_pending_clarification": "助手还在等你回答对话里的问题。先回复它,再重试 /goal from-chat。",
"from_chat_no_thread": "还没有会话。先发一条消息,再用 /goal from-chat。",
"from_chat_changed": "生成目标期间对话内容发生了变化,本次未保存。请重试 /goal from-chat,或用 /goal <目标> 直接设置。",
"from_chat_conversation_switched": "生成目标期间当前聊天切换到了新会话,任务没有启动。目标仍保存在之前的会话里:{objective}",
"from_chat_timeout": "从对话生成目标超时。保存可能仍会完成——先用 /goal 查看,如果没有再重试。",
"from_chat_failed": "从对话生成目标失败。请重试,或用 /goal <目标> 直接设置。",
}, },
} }
@ -90,7 +65,6 @@ _WAITING_KEYS = {"needs_user_input": "waiting_for_you", "external_wait": "waitin
def goal_reply_text(key: str, locale: str) -> str: def goal_reply_text(key: str, locale: str) -> str:
"""A fixed ``/goal`` reply (``none``, ``cleared``, ``*_failed``) in ``locale``.""" """A fixed ``/goal`` reply (``none``, ``cleared``, ``*_failed``) in ``locale``."""
locale = supported_locale(locale)
return _TEXT[locale][key] return _TEXT[locale][key]
@ -128,7 +102,6 @@ def goal_status_text(goal: object, outcome: object, *, running: bool, locale: st
``running`` says whether the thread has a run pending or in progress. ``running`` says whether the thread has a run pending or in progress.
""" """
locale = supported_locale(locale)
text = _TEXT[locale] text = _TEXT[locale]
if is_active_goal_state(goal): if is_active_goal_state(goal):
lines = [text["goal"].format(objective=goal["objective"])] lines = [text["goal"].format(objective=goal["objective"])]

View File

@ -24,7 +24,7 @@ from urllib.parse import quote, urlparse
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
from langgraph_sdk.errors import APIStatusError, ConflictError from langgraph_sdk.errors import ConflictError
from app.channels import buzz_run_policy as _buzz_run_policy # noqa: F401 from app.channels import buzz_run_policy as _buzz_run_policy # noqa: F401
from app.channels import feishu_run_policy as _feishu_run_policy # noqa: F401 from app.channels import feishu_run_policy as _feishu_run_policy # noqa: F401
@ -69,7 +69,6 @@ from deerflow.skills.storage.skill_storage import SkillStorage
from deerflow.trace_context import ensure_trace_context from deerflow.trace_context import ensure_trace_context
from deerflow.uploads.manager import apply_upload_sandbox_permits from deerflow.uploads.manager import apply_upload_sandbox_permits
from deerflow.utils.file_io import await_drained from deerflow.utils.file_io import await_drained
from deerflow.utils.http_policy import control_plane_trust_env
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -142,7 +141,7 @@ _LIVE_RUN_STATUSES = frozenset({"pending", "running"})
@dataclass @dataclass
class _RunAdmission: class _RunAdmission:
"""How far one inbound message's run request got, for dedupe and goal replies. """How far one inbound message's run request got, for dedupe release.
``requested`` flips right before the run request leaves the manager; ``requested`` flips right before the run request leaves the manager;
``confirmed`` once the Gateway proved it admitted the run (a run id, a ``confirmed`` once the Gateway proved it admitted the run (a run id, a
@ -151,7 +150,6 @@ class _RunAdmission:
requested: bool = False requested: bool = False
confirmed: bool = False confirmed: bool = False
error: BaseException | None = None
_RUN_ADMISSION: ContextVar[_RunAdmission | None] = ContextVar("deerflow_channel_run_admission", default=None) _RUN_ADMISSION: ContextVar[_RunAdmission | None] = ContextVar("deerflow_channel_run_admission", default=None)
@ -162,22 +160,11 @@ _RUN_ADMISSION: ContextVar[_RunAdmission | None] = ContextVar("deerflow_channel_
# status errors mean the Gateway never admitted it. # status errors mean the Gateway never admitted it.
_RUN_REQUEST_REACHED_GATEWAY_ERRORS = (httpx.ReadTimeout, httpx.ReadError, httpx.RemoteProtocolError) _RUN_REQUEST_REACHED_GATEWAY_ERRORS = (httpx.ReadTimeout, httpx.ReadError, httpx.RemoteProtocolError)
# ``/goal from-chat`` makes the Gateway run a synthesis LLM call before it
# answers, which routinely exceeds the 10s budget the other ``/goal`` calls
# need for a checkpoint read/write — the live Feishu run timed out client-side
# while the Gateway went on to save the goal. Keep connect/write/pool short so
# a dead Gateway still fails fast, and extend only the read for the synthesis.
_GOAL_FROM_CHAT_TIMEOUT = httpx.Timeout(10.0, read=120.0)
class _RunFailedFrame(RuntimeError): class _RunFailedFrame(RuntimeError):
"""The stream's ``error`` frame: the run ended, so a retry is not a duplicate.""" """The stream's ``error`` frame: the run ended, so a retry is not a duplicate."""
class _ConversationSwitched(RuntimeError):
"""The chat no longer points at the thread whose goal was saved."""
@contextmanager @contextmanager
def _run_admission_scope(*, fresh: bool = False) -> Iterator[_RunAdmission]: def _run_admission_scope(*, fresh: bool = False) -> Iterator[_RunAdmission]:
"""Install the admission record for one inbound message. """Install the admission record for one inbound message.
@ -208,11 +195,6 @@ def _note_run_confirmed() -> None:
admission.confirmed = True admission.confirmed = True
def _note_run_error(error: BaseException) -> None:
if (admission := _RUN_ADMISSION.get()) is not None:
admission.error = error
# Recovery replies must say the turn was dropped: the run was rejected before # Recovery replies must say the turn was dropped: the run was rejected before
# starting, so the triggering message never reached the agent. # starting, so the triggering message never reached the agent.
_STALE_PIN_RETRY_NOTE = "Your message was not processed — please send it again." _STALE_PIN_RETRY_NOTE = "Your message was not processed — please send it again."
@ -319,59 +301,6 @@ _WECOM_MEDIA_COS_HOST_RE = re.compile(r"^ww-aibot-img-(?P<appid>\d+)\.cos\.[a-z0
_METADATA_DROP_KEYS = frozenset({"raw_message", "ref_msg"}) _METADATA_DROP_KEYS = frozenset({"raw_message", "ref_msg"})
def _make_gateway_client(gateway_url: str) -> httpx.AsyncClient:
"""Build the short-lived client for Gateway control-plane calls.
A loopback Gateway must bypass ``HTTP_PROXY``, while an external one keeps
the environment proxy. The policy itself lives in
:mod:`deerflow.utils.http_policy` so this module never imports the sandbox
provider stack.
"""
return httpx.AsyncClient(trust_env=control_plane_trust_env(gateway_url))
# Mirrors the client ``langgraph_sdk.get_client()`` builds, so the loopback path
# below differs from it only in ``trust_env``.
_LANGGRAPH_SDK_RETRIES = 5
_LANGGRAPH_SDK_TIMEOUT = httpx.Timeout(connect=5, read=300, write=300, pool=5)
def _make_langgraph_client(base_url: str, headers: Mapping[str, str]) -> Any:
"""Build the ``langgraph_sdk`` async client used for channel run submission.
``get_client()`` always builds ``httpx.AsyncHTTPTransport(retries=5)`` on an
``httpx.AsyncClient`` that keeps ``trust_env`` at its default ``True`` and
exposes neither ``transport`` nor ``trust_env``, so a loopback Gateway would
still be sent through ``HTTP_PROXY``: control-plane calls are fixed but
``runs.wait`` keeps hitting the proxy-generated error of #7876. Loopback and
private Gateways therefore get an explicit ``trust_env=False`` client handed
to the SDK; external Gateways keep the stock ``get_client()`` path.
"""
from langgraph_sdk import __version__ as sdk_version
from langgraph_sdk import get_client
if control_plane_trust_env(base_url):
return get_client(url=base_url, headers=dict(headers))
try: # langgraph-sdk keeps the async client in a private package since 0.3
from langgraph_sdk._async import LangGraphClient
except ImportError: # older releases expose it directly on ``langgraph_sdk.client``
from langgraph_sdk.client import LangGraphClient
# ``get_client`` also folds an ``LANGGRAPH_API_KEY`` env var into the SDK
# headers; a control-plane Gateway authenticates with the internal auth
# headers above, so only those are forwarded here.
return LangGraphClient(
httpx.AsyncClient(
base_url=base_url,
transport=httpx.AsyncHTTPTransport(retries=_LANGGRAPH_SDK_RETRIES),
timeout=_LANGGRAPH_SDK_TIMEOUT,
headers={"User-Agent": f"langgraph-sdk-py/{sdk_version}", **headers},
trust_env=False,
)
)
def _slim_metadata(meta: dict[str, Any]) -> dict[str, Any]: def _slim_metadata(meta: dict[str, Any]) -> dict[str, Any]:
"""Return a shallow copy of *meta* with known-large keys removed.""" """Return a shallow copy of *meta* with known-large keys removed."""
return {k: v for k, v in meta.items() if k not in _METADATA_DROP_KEYS} return {k: v for k, v in meta.items() if k not in _METADATA_DROP_KEYS}
@ -670,7 +599,7 @@ def _is_run_status_result(result: Any) -> bool:
return isinstance(result, dict) and "messages" not in result and isinstance(result.get("status"), str) return isinstance(result, dict) and "messages" not in result and isinstance(result.get("status"), str)
def _turn_of_message(values: Any, message_id: str, *, thread_id: str | None = None) -> dict[str, Any] | None: def _turn_of_message(values: Any, message_id: str) -> dict[str, Any] | None:
"""Thread *values* cut after the turn that answered human message *message_id*. """Thread *values* cut after the turn that answered human message *message_id*.
The cut ends before the next visible human message, so the response, artifact The cut ends before the next visible human message, so the response, artifact
@ -682,7 +611,7 @@ def _turn_of_message(values: Any, message_id: str, *, thread_id: str | None = No
for index, message in enumerate(messages): for index, message in enumerate(messages):
if isinstance(message, dict) and message.get("type") == "human" and message.get("id") == message_id: if isinstance(message, dict) and message.get("type") == "human" and message.get("id") == message_id:
end = next( end = next(
(later for later in range(index + 1, len(messages)) if isinstance(messages[later], dict) and messages[later].get("type") == "human" and not _is_hidden_human_control_message(messages[later], thread_id=thread_id)), (later for later in range(index + 1, len(messages)) if isinstance(messages[later], dict) and messages[later].get("type") == "human" and not _is_hidden_human_control_message(messages[later])),
len(messages), len(messages),
) )
return {**values, "messages": messages[:end]} return {**values, "messages": messages[:end]}
@ -881,7 +810,7 @@ def _apply_explicit_model_choice(
carrier["model_name"] = model_name carrier["model_name"] = model_name
def _extract_response_text(result: dict | list, *, thread_id: str | None = None) -> str: def _extract_response_text(result: dict | list) -> str:
"""Extract the last AI message text from a LangGraph runs.wait result. """Extract the last AI message text from a LangGraph runs.wait result.
``runs.wait`` returns the final state dict which contains a ``messages`` ``runs.wait`` returns the final state dict which contains a ``messages``
@ -908,7 +837,7 @@ def _extract_response_text(result: dict | list, *, thread_id: str | None = None)
# Stop at the last human message — anything before it is a previous turn # Stop at the last human message — anything before it is a previous turn
if msg_type == "human": if msg_type == "human":
if _is_hidden_human_control_message(msg, thread_id=thread_id): if _is_hidden_human_control_message(msg):
continue continue
break break
@ -1217,7 +1146,7 @@ def _accumulate_stream_text(
return buffers[message_id], message_id return buffers[message_id], message_id
def _extract_artifacts(result: dict | list, *, thread_id: str | None = None) -> list[str]: def _extract_artifacts(result: dict | list) -> list[str]:
"""Extract artifact paths from the last AI response cycle only. """Extract artifact paths from the last AI response cycle only.
Instead of reading the full accumulated ``artifacts`` state (which contains Instead of reading the full accumulated ``artifacts`` state (which contains
@ -1238,39 +1167,27 @@ def _extract_artifacts(result: dict | list, *, thread_id: str | None = None) ->
continue continue
# Stop at the last human message — anything before it is a previous turn # Stop at the last human message — anything before it is a previous turn
if msg.get("type") == "human": if msg.get("type") == "human":
if _is_hidden_human_control_message(msg, thread_id=thread_id): if _is_hidden_human_control_message(msg):
continue continue
break break
# Look for AI messages with present_files tool calls # Look for AI messages with present_files tool calls
if msg.get("type") == "ai": if msg.get("type") == "ai":
message_paths: list[str] = []
for tc in msg.get("tool_calls", []): for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and tc.get("name") == "present_files": if isinstance(tc, dict) and tc.get("name") == "present_files":
args = tc.get("args", {}) args = tc.get("args", {})
paths = args.get("filepaths", []) paths = args.get("filepaths", [])
if isinstance(paths, list): if isinstance(paths, list):
message_paths.extend(p for p in paths if isinstance(p, str)) artifacts.extend(p for p in paths if isinstance(p, str))
# The scan runs newest-first; prepend so earlier calls stay first. return artifacts
artifacts[:0] = message_paths
# Match the ``merge_artifacts`` reducer: first-presented order, no repeats,
# so a file presented twice in one turn is uploaded once.
return list(dict.fromkeys(artifacts))
def _is_hidden_human_control_message(msg: Mapping[str, Any], *, thread_id: str | None = None) -> bool: def _is_hidden_human_control_message(msg: Mapping[str, Any]) -> bool:
"""Distinguish internal control messages from real hidden user answers.""" """Distinguish internal control messages from real hidden user answers."""
if msg.get("type") != "human": if msg.get("type") != "human":
return False return False
additional_kwargs = msg.get("additional_kwargs") additional_kwargs = msg.get("additional_kwargs")
if not isinstance(additional_kwargs, Mapping): if not isinstance(additional_kwargs, Mapping):
if additional_kwargs is not None:
logger.warning(
"Ignoring malformed message additional_kwargs for thread %s at row seq %s: expected a mapping, got %s",
thread_id or msg.get("thread_id", "<unknown>"),
msg.get("seq", "<unknown>"),
type(additional_kwargs).__name__,
)
return False return False
return additional_kwargs.get("hide_from_ui") is True and read_human_input_response(additional_kwargs) is None return additional_kwargs.get("hide_from_ui") is True and read_human_input_response(additional_kwargs) is None
@ -2178,21 +2095,14 @@ class ChannelManager:
self._maybe_spawn_followup_watcher(thread_id, result, carrier_msg) self._maybe_spawn_followup_watcher(thread_id, result, carrier_msg)
async def _publish_progress_update(self, msg: InboundMessage, thread_id: str, text: str, *, is_final: bool = False) -> None: async def _publish_progress_update(self, msg: InboundMessage, thread_id: str, text: str) -> None:
"""Publish a turn update; progress by default, final for standalone replies.
A non-final outbound joins the run's stream: streaming channels update
the same message with later chunks (and the final answer), while QQ and
DingTalk card mode drop it before a stream exists. Callers that must
stay visible on every channel publish ``is_final=True``.
"""
await self.bus.publish_outbound( await self.bus.publish_outbound(
OutboundMessage( OutboundMessage(
channel_name=msg.channel_name, channel_name=msg.channel_name,
chat_id=msg.chat_id, chat_id=msg.chat_id,
thread_id=thread_id, thread_id=thread_id,
text=text, text=text,
is_final=is_final, is_final=False,
thread_ts=msg.thread_ts, thread_ts=msg.thread_ts,
connection_id=msg.connection_id, connection_id=msg.connection_id,
owner_user_id=msg.owner_user_id, owner_user_id=msg.owner_user_id,
@ -2401,9 +2311,11 @@ class ChannelManager:
def _get_client(self): def _get_client(self):
"""Return the ``langgraph_sdk`` async client, creating it on first use.""" """Return the ``langgraph_sdk`` async client, creating it on first use."""
if self._client is None: if self._client is None:
self._client = _make_langgraph_client( from langgraph_sdk import get_client
self._langgraph_url,
{**create_internal_auth_headers(), **self._csrf_headers()}, self._client = get_client(
url=self._langgraph_url,
headers={**create_internal_auth_headers(), **self._csrf_headers()},
) )
return self._client return self._client
@ -3314,7 +3226,6 @@ class ChannelManager:
extra_context: dict[str, Any] | None = None, extra_context: dict[str, Any] | None = None,
*, *,
bound_identity_checked: bool = False, bound_identity_checked: bool = False,
expected_thread_id: str | None = None,
) -> None: ) -> None:
# Normal entry paths already run the bound-identity check in # Normal entry paths already run the bound-identity check in
# _handle_message() or _handle_command(). Keep this default False so # _handle_message() or _handle_command(). Keep this default False so
@ -3331,10 +3242,7 @@ class ChannelManager:
# first message for the chat. topic_id may be None (e.g. Telegram # first message for the chat. topic_id may be None (e.g. Telegram
# private chats) — the store handles this by using the "channel:chat_id" # private chats) — the store handles this by using the "channel:chat_id"
# key without a topic suffix. # key without a topic suffix.
if expected_thread_id is None:
thread_id, created = await self._get_or_create_thread(client, msg) thread_id, created = await self._get_or_create_thread(client, msg)
else:
thread_id, created = expected_thread_id, False
if not created: if not created:
logger.info("[Manager] reusing thread: thread_id=%s for topic_id=%s", thread_id, msg.topic_id) logger.info("[Manager] reusing thread: thread_id=%s for topic_id=%s", thread_id, msg.topic_id)
await self._update_thread_channel_metadata(client, msg, thread_id) await self._update_thread_channel_metadata(client, msg, thread_id)
@ -3374,7 +3282,6 @@ class ChannelManager:
thread_id, thread_id,
extra_context=extra_context, extra_context=extra_context,
storage_user_id=storage_user_id, storage_user_id=storage_user_id,
check_thread_binding=expected_thread_id is not None,
) )
finally: finally:
self._finish_serialized_thread_run( self._finish_serialized_thread_run(
@ -3392,7 +3299,6 @@ class ChannelManager:
*, *,
extra_context: dict[str, Any] | None = None, extra_context: dict[str, Any] | None = None,
storage_user_id: str | None = None, storage_user_id: str | None = None,
check_thread_binding: bool = False,
) -> None: ) -> None:
if storage_user_id is None: if storage_user_id is None:
storage_user_id = _channel_storage_user_id(msg) storage_user_id = _channel_storage_user_id(msg)
@ -3432,11 +3338,6 @@ class ChannelManager:
uploaded = await _ingest_inbound_files(thread_id, msg, user_id=storage_user_id) uploaded = await _ingest_inbound_files(thread_id, msg, user_id=storage_user_id)
human_message = _human_input_message_with_channel_response(msg.text, metadata=msg.metadata, original_content=original_text, files=uploaded or None, message_id=human_message_id) human_message = _human_input_message_with_channel_response(msg.text, metadata=msg.metadata, original_content=original_text, files=uploaded or None, message_id=human_message_id)
# Metadata loading, queueing and file preparation may have awaited a
# /new. Keep the saved thread pinned and check its binding at handoff.
if check_thread_binding and await self._lookup_thread_id(msg) != thread_id:
raise _ConversationSwitched
if self._channel_supports_streaming(msg.channel_name): if self._channel_supports_streaming(msg.channel_name):
await self._handle_streaming_chat( await self._handle_streaming_chat(
client, client,
@ -3487,7 +3388,6 @@ class ChannelManager:
# behaviorally identical to the previous bare ``await``. # behaviorally identical to the previous bare ``await``.
result = await client.runs.create(thread_id, assistant_id, **run_kwargs) result = await client.runs.create(thread_id, assistant_id, **run_kwargs)
except Exception as exc: except Exception as exc:
_note_run_error(exc)
if _is_admitted_run_conflict(exc): if _is_admitted_run_conflict(exc):
# An earlier delivery's run; it posts its own outcome. # An earlier delivery's run; it posts its own outcome.
_note_run_confirmed() _note_run_confirmed()
@ -3528,7 +3428,6 @@ class ChannelManager:
**run_kwargs, **run_kwargs,
) )
except Exception as exc: except Exception as exc:
_note_run_error(exc)
if _is_admitted_run_conflict(exc): if _is_admitted_run_conflict(exc):
_note_run_confirmed() _note_run_confirmed()
logger.info("[Manager] run already admitted for this message: thread_id=%s (%s)", thread_id, exc) logger.info("[Manager] run already admitted for this message: thread_id=%s (%s)", thread_id, exc)
@ -3542,7 +3441,6 @@ class ChannelManager:
await self._send_error(msg, THREAD_BUSY_MESSAGE) await self._send_error(msg, THREAD_BUSY_MESSAGE)
return return
if step_limit_text := _step_limit_text(exc): if step_limit_text := _step_limit_text(exc):
_note_run_confirmed()
logger.warning("[Manager] run stopped at its step limit: thread_id=%s", thread_id) logger.warning("[Manager] run stopped at its step limit: thread_id=%s", thread_id)
await self._send_error(msg, step_limit_text) await self._send_error(msg, step_limit_text)
# The run ended, as after a terminal error frame: release the key # The run ended, as after a terminal error frame: release the key
@ -3564,9 +3462,9 @@ class ChannelManager:
return return
result = await self._admitted_turn(client, msg, thread_id) or result result = await self._admitted_turn(client, msg, thread_id) or result
response_text = _extract_response_text(result, thread_id=thread_id) response_text = _extract_response_text(result)
pending_clarification = _has_current_turn_clarification(result) pending_clarification = _has_current_turn_clarification(result)
artifacts = _extract_artifacts(result, thread_id=thread_id) artifacts = _extract_artifacts(result)
logger.info( logger.info(
"[Manager] agent response received: thread_id=%s, response_len=%d, artifacts=%d", "[Manager] agent response received: thread_id=%s, response_len=%d, artifacts=%d",
@ -3619,7 +3517,7 @@ class ChannelManager:
except Exception: except Exception:
logger.warning("[Manager] failed to read thread state for an admitted run: thread_id=%s", thread_id, exc_info=True) logger.warning("[Manager] failed to read thread state for an admitted run: thread_id=%s", thread_id, exc_info=True)
return None return None
return _turn_of_message(state.get("values") if isinstance(state, dict) else None, message_id, thread_id=thread_id) return _turn_of_message(state.get("values") if isinstance(state, dict) else None, message_id)
async def _handle_streaming_chat( async def _handle_streaming_chat(
self, self,
@ -3696,7 +3594,7 @@ class ChannelManager:
# Clarification text is only in the values snapshot; # Clarification text is only in the values snapshot;
# publish it so the user sees the question mid-stream. # publish it so the user sees the question mid-stream.
if _has_current_turn_clarification(data): if _has_current_turn_clarification(data):
clarification_text = _extract_response_text(data, thread_id=thread_id) clarification_text = _extract_response_text(data)
if clarification_text and clarification_text != latest_text: if clarification_text and clarification_text != latest_text:
latest_text = clarification_text latest_text = clarification_text
@ -3743,14 +3641,12 @@ class ChannelManager:
pin_recovery = await self._stale_pin_recovery(msg, exc, expected_pin=expected_pin, thread_id=thread_id) pin_recovery = await self._stale_pin_recovery(msg, exc, expected_pin=expected_pin, thread_id=thread_id)
pin_recovery_text = pin_recovery.text if pin_recovery else None pin_recovery_text = pin_recovery.text if pin_recovery else None
finally: finally:
if stream_error is not None:
_note_run_error(stream_error)
if replay_gap and last_values is None and not latest_text and stream_error is None: if replay_gap and last_values is None and not latest_text and stream_error is None:
last_values = await self._admitted_turn(client, msg, thread_id) last_values = await self._admitted_turn(client, msg, thread_id)
result = last_values if last_values is not None else {"messages": [{"type": "ai", "content": latest_text}]} result = last_values if last_values is not None else {"messages": [{"type": "ai", "content": latest_text}]}
response_text = _extract_response_text(result, thread_id=thread_id) response_text = _extract_response_text(result)
pending_clarification = _has_current_turn_clarification(result) pending_clarification = _has_current_turn_clarification(result)
artifacts = _extract_artifacts(result, thread_id=thread_id) artifacts = _extract_artifacts(result)
# Reuse the storage owner resolved by _handle_chat so artifact delivery # Reuse the storage owner resolved by _handle_chat so artifact delivery
# matches the upload bucket and we avoid re-running _safe_user_id_for_run # matches the upload bucket and we avoid re-running _safe_user_id_for_run
# (and its possible filesystem touch) on the streaming-error path. # (and its possible filesystem touch) on the streaming-error path.
@ -3887,7 +3783,7 @@ class ChannelManager:
reply = ( reply = (
"Available commands:\n" "Available commands:\n"
"/bootstrap — Start a bootstrap session (enables agent setup)\n" "/bootstrap — Start a bootstrap session (enables agent setup)\n"
"/goal [condition|clear|from-chat [hint]] — Set, show, clear, or compose the goal from the conversation\n" "/goal [condition|clear] — Set, show, or clear an active goal\n"
"/new — Start a new conversation\n" "/new — Start a new conversation\n"
"/status — Show current thread info\n" "/status — Show current thread info\n"
"/models — List available models\n" "/models — List available models\n"
@ -4128,27 +4024,6 @@ class ChannelManager:
self._remember_thread_model(thread_id, name) self._remember_thread_model(thread_id, name)
return f"Model '{name}' selected. It applies to this conversation from the next message." return f"Model '{name}' selected. It applies to this conversation from the next message."
@staticmethod
def _is_goal_in_flight_error(error: Exception) -> bool:
"""Recognize only the Gateway's known goal-write conflict, not arbitrary 409s."""
if not isinstance(error, httpx.HTTPStatusError) or error.response.status_code != 409:
return False
# POST (from-chat synthesis) saves the goal like PUT, so it carries the
# same "Set the goal ..." detail; recognizing it lets the IM reply reuse
# the busy wording for both the pre-check and the save-time race.
detail = {
"PUT": "Thread has a run in flight. Set the goal after the run finishes.",
"POST": "Thread has a run in flight. Set the goal after the run finishes.",
"DELETE": "Thread has a run in flight. Clear the goal after the run finishes.",
}.get(error.request.method)
if detail is None:
return False
try:
body = error.response.json()
except ValueError:
return False
return isinstance(body, Mapping) and body.get("detail") == detail
async def _goal_request( async def _goal_request(
self, self,
method: str, method: str,
@ -4156,15 +4031,13 @@ class ChannelManager:
*, *,
headers: dict[str, str], headers: dict[str, str],
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
suffix: str = "",
timeout: float | httpx.Timeout = 10,
) -> dict[str, Any]: ) -> dict[str, Any]:
async with _make_gateway_client(self._gateway_url) as http: async with httpx.AsyncClient() as http:
request = getattr(http, method.lower()) request = getattr(http, method.lower())
kwargs: dict[str, Any] = {"timeout": timeout, "headers": {**headers, **self._csrf_headers()}} kwargs: dict[str, Any] = {"timeout": 10, "headers": {**headers, **self._csrf_headers()}}
if json is not None: if json is not None:
kwargs["json"] = json kwargs["json"] = json
response = await request(f"{self._gateway_url}/api/threads/{quote(thread_id, safe='')}/goal{suffix}", **kwargs) response = await request(f"{self._gateway_url}/api/threads/{quote(thread_id, safe='')}/goal", **kwargs)
response.raise_for_status() response.raise_for_status()
return response.json() or {} return response.json() or {}
@ -4224,24 +4097,17 @@ class ChannelManager:
return await self._goal_reply(msg, "cleared") return await self._goal_reply(msg, "cleared")
try: try:
await self._goal_request("delete", thread_id, headers=headers) await self._goal_request("delete", thread_id, headers=headers)
except Exception as exc: except Exception:
if self._is_goal_in_flight_error(exc):
return await self._goal_reply(msg, "busy")
logger.exception("Failed to clear goal through gateway") logger.exception("Failed to clear goal through gateway")
return await self._goal_reply(msg, "clear_failed") return await self._goal_reply(msg, "clear_failed")
return await self._goal_reply(msg, "cleared") return await self._goal_reply(msg, "cleared")
if command.kind == "from_chat":
return await self._handle_goal_from_chat(msg, thread_id, guidance=command.objective, headers=headers)
if not thread_id: if not thread_id:
thread_id, _ = await self._create_first_thread(self._get_client(), msg) thread_id, _ = await self._create_first_thread(self._get_client(), msg)
try: try:
await self._goal_request("put", thread_id, headers=headers, json={"objective": command.objective}) await self._goal_request("put", thread_id, headers=headers, json={"objective": command.objective})
except Exception as exc: except Exception:
if self._is_goal_in_flight_error(exc):
return await self._goal_reply(msg, "busy")
logger.exception("Failed to set goal through gateway") logger.exception("Failed to set goal through gateway")
return await self._goal_reply(msg, "set_failed") return await self._goal_reply(msg, "set_failed")
@ -4251,108 +4117,6 @@ class ChannelManager:
await self._handle_chat(chat_msg, bound_identity_checked=True) await self._handle_chat(chat_msg, bound_identity_checked=True)
return None return None
async def _handle_goal_from_chat(self, msg: InboundMessage, thread_id: str | None, *, guidance: str, headers: dict[str, str]) -> str | None:
"""``/goal from-chat``: synthesize an objective from the conversation, then start one run.
Unlike the literal ``set`` branch — which starts the run silently because
the user typed the objective — the objective here is model-synthesized,
so the user is shown what was saved before the run streams its output.
Saving and the busy/409 guard stay on the Gateway route; this only
reports the outcome and hands the saved objective to ``_handle_chat``.
Run handoff keeps this thread pinned and re-checks the binding after
confirmation delivery and preparation. Admission evidence distinguishes
rejected starts from requests whose response was lost.
"""
if not thread_id:
return await self._goal_reply(msg, "from_chat_no_thread")
try:
result = await self._goal_request("post", thread_id, headers=headers, json={"guidance": guidance or None}, suffix="/from-chat", timeout=_GOAL_FROM_CHAT_TIMEOUT)
except Exception as exc:
return await self._goal_from_chat_error_reply(msg, exc)
goal = result.get("goal") if isinstance(result, Mapping) else None
objective = goal.get("objective") if isinstance(goal, Mapping) else None
if not isinstance(objective, str) or not objective.strip():
logger.warning("Goal from-chat returned no usable objective")
return await self._goal_reply(msg, "from_chat_failed")
from dataclasses import replace as _dc_replace
chat_msg = _dc_replace(msg, text=objective, msg_type=InboundMessageType.CHAT)
reply = None
may_be_running = False
with _run_admission_scope() as admission:
try:
if await self._lookup_thread_id(msg) != thread_id:
raise _ConversationSwitched
locale = await self._reply_locale.locale_for(_effective_owner_user_id(msg))
try:
# A separate final outbound keeps the saved objective visible
# alongside the run's stream on every IM provider.
await self._publish_progress_update(msg, thread_id, goal_reply_text("from_chat_saved", locale).format(objective=objective), is_final=True)
except Exception:
logger.warning("Goal from-chat saved but the confirmation card failed", exc_info=True)
await self._handle_chat(chat_msg, bound_identity_checked=True, expected_thread_id=thread_id)
except _ConversationSwitched:
reply = (await self._goal_reply(msg, "from_chat_conversation_switched")).format(objective=objective)
except Exception as exc:
_note_run_error(exc)
logger.warning("Goal from-chat run handoff failed", exc_info=True)
if reply is None and admission.error is not None and not admission.confirmed:
error = admission.error
rejected = isinstance(error, (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout))
if isinstance(error, (httpx.HTTPStatusError, APIStatusError)):
rejected = 400 <= error.response.status_code < 500
may_be_running = admission.requested and not rejected
reply = await self._goal_reply(msg, "from_chat_run_status_unknown" if may_be_running else "from_chat_saved_but_not_started")
if reply is not None:
# Use the saved thread even when its conversation binding cannot
# be read. Deliver the recovery advice before releasing dedupe.
await self._publish_progress_update(msg, thread_id, reply, is_final=True)
if admission.error is not None and not may_be_running:
await self._release_inbound_dedupe_key(msg)
return None
async def _goal_from_chat_error_reply(self, msg: InboundMessage, exc: Exception) -> str:
"""Map a from-chat Gateway error to the owner-language reply.
Only the known in-flight conflict, a client-side timeout, and the
structured synthesis outcomes (``unclear``, ``no_context``,
``pending_clarification``, ``goal_changed``) get their own wording;
anything else degrades to the generic retry reply. The model's ``reason``
is never read here, and the Gateway does not log it verbatim either.
"""
if self._is_goal_in_flight_error(exc):
return await self._goal_reply(msg, "busy")
if isinstance(exc, httpx.TimeoutException):
# The Gateway may finish synthesizing and save the goal after this
# client stops waiting, so the reply must not claim it failed.
logger.warning("Goal from-chat request timed out; the goal may still be saved", exc_info=True)
return await self._goal_reply(msg, "from_chat_timeout")
code = None
if isinstance(exc, httpx.HTTPStatusError):
try:
body = exc.response.json()
except ValueError:
body = None
detail = body.get("detail") if isinstance(body, Mapping) else None
if isinstance(detail, Mapping):
code = detail.get("code")
if code == "unclear":
return await self._goal_reply(msg, "from_chat_unclear")
if code == "no_context":
return await self._goal_reply(msg, "from_chat_no_context")
if code == "pending_clarification":
return await self._goal_reply(msg, "from_chat_pending_clarification")
if code == "goal_changed":
# The save's compare-and-swap found a newer checkpoint: another
# goal (or write) landed during synthesis, so nothing was saved.
return await self._goal_reply(msg, "from_chat_changed")
logger.warning("Goal from-chat failed through gateway", exc_info=True)
return await self._goal_reply(msg, "from_chat_failed")
async def _fetch_gateway_json(self, path: str, *, msg: InboundMessage | None = None) -> dict[str, Any]: async def _fetch_gateway_json(self, path: str, *, msg: InboundMessage | None = None) -> dict[str, Any]:
"""GET a Gateway API path and return the parsed JSON body. """GET a Gateway API path and return the parsed JSON body.
@ -4360,7 +4124,7 @@ class ChannelManager:
replies should catch and degrade (see ``_fetch_gateway``). replies should catch and degrade (see ``_fetch_gateway``).
""" """
headers = _owner_headers(msg) if msg is not None else None headers = _owner_headers(msg) if msg is not None else None
async with _make_gateway_client(self._gateway_url) as http: async with httpx.AsyncClient() as http:
resp = await http.get( resp = await http.get(
f"{self._gateway_url}{path}", f"{self._gateway_url}{path}",
timeout=10, timeout=10,

View File

@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import math
import re import re
import threading import threading
import time import time
@ -35,9 +34,6 @@ TELEGRAM_MAX_INBOUND_FILE_BYTES = 20_000_000
# Keep Telegram cleanup inside the Gateway's five-second shutdown-hook budget. # Keep Telegram cleanup inside the Gateway's five-second shutdown-hook budget.
TELEGRAM_SHUTDOWN_TIMEOUT_SECONDS = 4.0 TELEGRAM_SHUTDOWN_TIMEOUT_SECONDS = 4.0
TELEGRAM_BRIDGE_DRAIN_TIMEOUT_SECONDS = 1.0 TELEGRAM_BRIDGE_DRAIN_TIMEOUT_SECONDS = 1.0
# Outbound listeners await sends inline. Bound each flood wait while still
# allowing a full group rate-limit window.
TELEGRAM_MAX_OUTBOUND_RETRY_DELAY_SECONDS = 60.0
STREAM_EDIT_MIN_INTERVAL_SECONDS = 1.0 STREAM_EDIT_MIN_INTERVAL_SECONDS = 1.0
# Groups (negative chat_id) are capped at 20 messages/minute by Telegram, # Groups (negative chat_id) are capped at 20 messages/minute by Telegram,
# so stream edits there must pace well below the private-chat 1 msg/s guideline. # so stream edits there must pace well below the private-chat 1 msg/s guideline.
@ -327,20 +323,14 @@ class TelegramChannel(Channel):
if not msg.is_final: if not msg.is_final:
await self._send_stream_update(chat_id, key, msg.text, reply_to=self._parse_message_id(msg.thread_ts)) await self._send_stream_update(chat_id, key, msg.text, reply_to=self._parse_message_id(msg.thread_ts))
state = self._stream_messages.get(key)
if state is not None:
binding = (msg.thread_id, msg.connection_id, msg.owner_user_id, msg.thread_ts, state["message_id"])
if state.get("thread_binding") != binding and await self._remember_reply_thread(msg, state["message_id"], stream_state=state):
state["thread_binding"] = binding
return return
state = self._stream_messages.pop(key, None) state = self._stream_messages.pop(key, None)
if state is not None: if state is not None:
await self._remember_reply_thread(msg, state["message_id"], stream_state=state)
if self._can_send_rich(msg.text) and await self._edit_rich_message(chat_id, state["message_id"], msg.text): if self._can_send_rich(msg.text) and await self._edit_rich_message(chat_id, state["message_id"], msg.text):
self._last_bot_message[msg.chat_id] = state["message_id"] self._last_bot_message[msg.chat_id] = state["message_id"]
return return
await self._finalize_stream_message(chat_id, msg.chat_id, state, msg.text, msg) await self._finalize_stream_message(chat_id, msg.chat_id, state, msg.text)
return return
if self._can_send_rich(msg.text): if self._can_send_rich(msg.text):
@ -350,51 +340,10 @@ class TelegramChannel(Channel):
logger.warning("[Telegram] Rich Message send failed in chat=%s; falling back to plain text: %s", chat_id, exc) logger.warning("[Telegram] Rich Message send failed in chat=%s; falling back to plain text: %s", chat_id, exc)
message_id = None message_id = None
if message_id is not None: if message_id is not None:
await self._remember_reply_thread(msg, message_id)
return return
for chunk in self._split_message(msg.text): for chunk in self._split_message(msg.text):
message_id = await self._send_new_message(chat_id, msg.chat_id, chunk, _max_retries=_max_retries) await self._send_new_message(chat_id, msg.chat_id, chunk, _max_retries=_max_retries)
await self._remember_reply_thread(msg, message_id)
async def _remember_reply_thread(self, msg: OutboundMessage, message_id: int | None, *, stream_state: dict[str, Any] | None = None) -> bool:
"""Keep group replies on their source thread, without undoing a later /new."""
if not msg.thread_id or message_id is None or int(msg.chat_id) >= 0:
return True
store = self.config.get("channel_store")
if msg.connection_id:
# Bound conversations must never fall back to the shared legacy store.
if self._connection_repo is None or not msg.owner_user_id:
return False
elif store is None:
return False
topic_ids = {str(message_id)}
if msg.thread_ts:
topic_ids.add(msg.thread_ts)
try:
for topic_id in topic_ids:
if msg.connection_id:
await self._connection_repo.claim_thread_id(
connection_id=msg.connection_id,
owner_user_id=msg.owner_user_id,
provider=self.name,
external_conversation_id=msg.chat_id,
external_topic_id=topic_id,
thread_id=msg.thread_id,
)
else:
await store.claim_thread_id(self.name, msg.chat_id, msg.thread_id, topic_id=topic_id)
except Exception:
# Delivery has already succeeded. Retry the binding on the next update,
# not the Telegram send, which could duplicate a visible reply.
if stream_state is not None and stream_state.get("thread_binding_error_logged"):
logger.debug("[Telegram] reply thread mapping retry failed in chat=%s", msg.chat_id)
else:
logger.exception("[Telegram] failed to remember reply thread mapping in chat=%s", msg.chat_id)
if stream_state is not None:
stream_state["thread_binding_error_logged"] = True
return False
return True
async def _send_stream_update(self, chat_id: int, key: str, text: str, reply_to: int | None = None) -> None: async def _send_stream_update(self, chat_id: int, key: str, text: str, reply_to: int | None = None) -> None:
"""Edit the in-flight streamed message with accumulated text. """Edit the in-flight streamed message with accumulated text.
@ -460,7 +409,7 @@ class TelegramChannel(Channel):
state["last_edit_at"] = _monotonic() state["last_edit_at"] = _monotonic()
state["last_text"] = display state["last_text"] = display
async def _finalize_stream_message(self, chat_id: int, chat_key: str, state: dict[str, Any], text: str, msg: OutboundMessage) -> None: async def _finalize_stream_message(self, chat_id: int, chat_key: str, state: dict[str, Any], text: str) -> None:
"""Apply the final text: edit the streamed message, splitting overflow into follow-ups.""" """Apply the final text: edit the streamed message, splitting overflow into follow-ups."""
bot = self._application.bot bot = self._application.bot
chunks = self._split_message(text or "") chunks = self._split_message(text or "")
@ -474,12 +423,10 @@ class TelegramChannel(Channel):
else: else:
# Edit could not be applied (e.g. message deleted) — deliver the # Edit could not be applied (e.g. message deleted) — deliver the
# first chunk as a fresh message with the standard retry policy. # first chunk as a fresh message with the standard retry policy.
message_id = await self._send_new_message(chat_id, chat_key, chunks[0]) await self._send_new_message(chat_id, chat_key, chunks[0])
await self._remember_reply_thread(msg, message_id, stream_state=state)
for chunk in chunks[1:]: for chunk in chunks[1:]:
message_id = await self._send_new_message(chat_id, chat_key, chunk) await self._send_new_message(chat_id, chat_key, chunk)
await self._remember_reply_thread(msg, message_id, stream_state=state)
async def _edit_final_chunk(self, bot, chat_id: int, message_id: int, text: str) -> bool: async def _edit_final_chunk(self, bot, chat_id: int, message_id: int, text: str) -> bool:
"""Edit with one rate-limit retry. Returns False if the edit could not be applied.""" """Edit with one rate-limit retry. Returns False if the edit could not be applied."""
@ -598,24 +545,13 @@ class TelegramChannel(Channel):
try: try:
input_file = await asyncio.to_thread(_load_telegram_input_file, attachment.actual_path, attachment.filename) input_file = await asyncio.to_thread(_load_telegram_input_file, attachment.actual_path, attachment.filename)
from telegram.error import BadRequest
if attachment.is_image and attachment.size <= 10 * 1024 * 1024: if attachment.is_image and attachment.size <= 10 * 1024 * 1024:
kwargs: dict[str, Any] = {"chat_id": chat_id, "photo": input_file} kwargs: dict[str, Any] = {"chat_id": chat_id, "photo": input_file}
if reply_to: if reply_to:
kwargs["reply_to_message_id"] = reply_to kwargs["reply_to_message_id"] = reply_to
if chat_id > 0: if chat_id > 0:
kwargs["allow_sending_without_reply"] = True kwargs["allow_sending_without_reply"] = True
try:
sent = await bot.send_photo(**kwargs) sent = await bot.send_photo(**kwargs)
except BadRequest as exc:
if str(exc).casefold() not in {"photo_invalid_dimensions", "image_process_failed"}:
raise
logger.info("[Telegram] photo rejected (%s); delivering as document: %s", exc, attachment.filename)
# A rejected photo can still be delivered unchanged as a file.
# Keep the reply target and reuse the already loaded bytes.
kwargs["document"] = kwargs.pop("photo")
sent = await bot.send_document(**kwargs)
else: else:
kwargs = {"chat_id": chat_id, "document": input_file} kwargs = {"chat_id": chat_id, "document": input_file}
if reply_to: if reply_to:
@ -625,7 +561,6 @@ class TelegramChannel(Channel):
sent = await bot.send_document(**kwargs) sent = await bot.send_document(**kwargs)
self._last_bot_message[msg.chat_id] = sent.message_id self._last_bot_message[msg.chat_id] = sent.message_id
await self._remember_reply_thread(msg, sent.message_id)
logger.info("[Telegram] file sent: %s to chat=%s", attachment.filename, msg.chat_id) logger.info("[Telegram] file sent: %s to chat=%s", attachment.filename, msg.chat_id)
return True return True
except Exception: except Exception:
@ -982,24 +917,6 @@ class TelegramChannel(Channel):
"last_text": last_text, "last_text": last_text,
} }
def _outbound_retry_delay(self, attempt: int, exc: Exception) -> float:
"""Extend the shared backoff for new plain or Rich final-reply sends.
Only sends through ``_send_with_retry`` use this policy. The running
acknowledgment and intermediate stream updates are best effort.
Final edits have their own retry path.
"""
delay = super()._outbound_retry_delay(attempt, exc)
if not self._is_retry_after(exc):
return delay
try:
hint = self._retry_after_seconds(exc)
except (TypeError, ValueError, OverflowError):
return delay
if not math.isfinite(hint):
return delay
return max(delay, min(hint, TELEGRAM_MAX_OUTBOUND_RETRY_DELAY_SECONDS))
@staticmethod @staticmethod
def _is_retry_after(exc: Exception) -> bool: def _is_retry_after(exc: Exception) -> bool:
return getattr(exc, "retry_after", None) is not None return getattr(exc, "retry_after", None) is not None
@ -1041,7 +958,7 @@ class TelegramChannel(Channel):
return chunks return chunks
async def _send_running_reply(self, chat_id: str, reply_to_message_id: int) -> None: async def _send_running_reply(self, chat_id: str, reply_to_message_id: int) -> None:
"""Send a best-effort acknowledgment before dispatching inbound work.""" """Send a 'Working on it...' reply and register it as the stream target."""
if not self._application: if not self._application:
return return
try: try:

View File

@ -32,7 +32,6 @@ from app.channels.commands import is_known_channel_command
from app.channels.connection_identity import attach_connection_identity from app.channels.connection_identity import attach_connection_identity
from app.channels.credential_staging import cleanup_stale_credential_staging, credential_staging_prefix from app.channels.credential_staging import cleanup_stale_credential_staging, credential_staging_prefix
from app.channels.message_bus import INBOUND_FILE_CLEANUP_KEY, InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment, cleanup_inbound_files from app.channels.message_bus import INBOUND_FILE_CLEANUP_KEY, InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment, cleanup_inbound_files
from deerflow.utils.atomic_write import publish_bytes_atomically
from deerflow.utils.file_io import await_drained from deerflow.utils.file_io import await_drained
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1084,8 +1083,15 @@ class WechatChannel(Channel):
def _update_longpoll_timeout(self, data: Mapping[str, Any]) -> None: def _update_longpoll_timeout(self, data: Mapping[str, Any]) -> None:
if not self._respect_server_longpoll_timeout: if not self._respect_server_longpoll_timeout:
return return
timeout_ms = self._coerce_float(data.get("longpolling_timeout_ms"), 0.0) raw_timeout = data.get("longpolling_timeout_ms")
if 1_000 <= timeout_ms <= 300_000: if raw_timeout is None:
return
try:
timeout_ms = float(raw_timeout)
except (TypeError, ValueError):
return
if timeout_ms <= 0:
return
self._server_longpoll_timeout_seconds = timeout_ms / 1000.0 self._server_longpoll_timeout_seconds = timeout_ms / 1000.0
def _base_info(self) -> dict[str, str]: def _base_info(self) -> dict[str, str]:
@ -1701,10 +1707,7 @@ class WechatChannel(Channel):
return return
try: try:
self._cursor_path.parent.mkdir(parents=True, exist_ok=True) self._cursor_path.parent.mkdir(parents=True, exist_ok=True)
# Publish through the shared helper: it fsyncs the staged bytes self._cursor_path.write_text(json.dumps({"get_updates_buf": self._get_updates_buf}, ensure_ascii=False, indent=2), encoding="utf-8", newline="")
# before the rename (so a machine crash cannot leave a truncated
# cursor behind) and re-applies an existing file's mode.
publish_bytes_atomically(self._cursor_path, json.dumps({"get_updates_buf": self._get_updates_buf}, ensure_ascii=False, indent=2).encode("utf-8"))
except OSError: except OSError:
logger.warning("[WeChat] failed to persist cursor state to %s", self._cursor_path) logger.warning("[WeChat] failed to persist cursor state to %s", self._cursor_path)

View File

@ -9,7 +9,7 @@ from collections.abc import Awaitable, Callable, Mapping
from typing import Any, cast from typing import Any, cast
from app.channels.base import Channel, ChannelUnavailable, attachment_delivery_text from app.channels.base import Channel, ChannelUnavailable, attachment_delivery_text
from app.channels.commands import is_known_channel_command, strip_leading_mentions from app.channels.commands import is_known_channel_command
from app.channels.connection_identity import attach_connection_identity from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import ( from app.channels.message_bus import (
InboundMessage, InboundMessage,
@ -40,10 +40,6 @@ else:
_WECOM_ACK_REJECTION_PREFIX = "Reply ack error" _WECOM_ACK_REJECTION_PREFIX = "Reply ack error"
def _group_workspace_id(aibotid: str | None, chatid: str) -> str:
return f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}"
def _file_md5(path: str) -> str: def _file_md5(path: str) -> str:
md5_hasher = hashlib.md5() md5_hasher = hashlib.md5()
with open(path, "rb") as file_obj: with open(path, "rb") as file_obj:
@ -438,6 +434,8 @@ class WeComChannel(Channel):
logger.exception("[WeCom] failed to send attachment delivery feedback") logger.exception("[WeCom] failed to send attachment delivery feedback")
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
if not msg.is_final:
return True
if not self._ws_client: if not self._ws_client:
return False return False
if not msg.thread_ts: if not msg.thread_ts:
@ -447,11 +445,7 @@ class WeComChannel(Channel):
return False return False
media_type = "image" if attachment.is_image else "file" media_type = "image" if attachment.is_image else "file"
# aibot media upload limits: image 10 MB, voice 2 MB, video 10 MB, size_limit = 2 * 1024 * 1024 if attachment.is_image else 20 * 1024 * 1024
# file 20 MB (https://developer.work.weixin.qq.com/document/path/101917).
# The image cap used to be 2 MB — the voice limit — which dropped every
# 2-10 MB image before it was uploaded.
size_limit = 10 * 1024 * 1024 if attachment.is_image else 20 * 1024 * 1024
if attachment.size > size_limit: if attachment.size > size_limit:
logger.warning( logger.warning(
"[WeCom] %s too large (%d bytes), skipping: %s", "[WeCom] %s too large (%d bytes), skipping: %s",
@ -587,22 +581,7 @@ class WeComChannel(Channel):
if handled: if handled:
return return
# WeCom group chats deliver "@bot /new" with the mention left in the text inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
# (Slack/Discord strip their own bot mention upstream). Skip a leading
# mention only for the command path so ordinary chat keeps any @mentions
# intact for the agent; the stripped form also flows into the inbound so
# ChannelManager._handle_command parses the bare command. Mirrors
# DingTalkChannel / FeishuChannel.
#
# This drops *any* leading mention, not only the bot's: WeCom text
# mentions carry display names, so the frame's `aibotid` cannot be
# matched against them and "@alice /status" is indistinguishable from
# "@bot /status". Feishu and DingTalk accept the same tradeoff;
# test_member_mention_before_a_command_also_classifies pins it.
command_text = strip_leading_mentions(text)
inbound_type = InboundMessageType.COMMAND if is_known_channel_command(command_text) else InboundMessageType.CHAT
if inbound_type is InboundMessageType.COMMAND:
text = command_text
inbound_metadata: dict[str, Any] = { inbound_metadata: dict[str, Any] = {
"aibotid": body.get("aibotid"), "aibotid": body.get("aibotid"),
"chattype": body.get("chattype"), "chattype": body.get("chattype"),
@ -610,12 +589,8 @@ class WeComChannel(Channel):
} }
if body.get("chatid") is not None: if body.get("chatid") is not None:
inbound_metadata["chatid"] = body.get("chatid") inbound_metadata["chatid"] = body.get("chatid")
# A group's conversation is the group, a private chat's the sender.
# Keyed by sender alone, a member's private chat and every group they
# wrote in shared one thread wherever no group connection kept them apart.
group_chatid = str(body.get("chatid") or "").strip() if body.get("chattype") == "group" else ""
inbound = self._make_inbound( inbound = self._make_inbound(
chat_id=group_chatid or user_id, chat_id=user_id, # keep user's conversation in memory
user_id=user_id, user_id=user_id,
text=text, text=text,
msg_type=inbound_type, msg_type=inbound_type,
@ -623,7 +598,7 @@ class WeComChannel(Channel):
files=files or [], files=files or [],
metadata=inbound_metadata, metadata=inbound_metadata,
) )
inbound.topic_id = user_id # one thread per member in each conversation inbound.topic_id = user_id # keep the same thread
stream_id = generate_req_id("stream") stream_id = generate_req_id("stream")
self._ws_frames[msg_id] = frame self._ws_frames[msg_id] = frame
@ -641,8 +616,6 @@ class WeComChannel(Channel):
pass pass
inbound = await self._attach_connection_identity(inbound) inbound = await self._attach_connection_identity(inbound)
if group_chatid:
await self._carry_over_sender_keyed_group_thread(inbound, group_chatid)
self._commit_reserved_inbound(reservation, inbound) self._commit_reserved_inbound(reservation, inbound)
finally: finally:
reservation.release() reservation.release()
@ -654,7 +627,7 @@ class WeComChannel(Channel):
if chattype == "group": if chattype == "group":
if not chatid or self._connection_repo is None: if not chatid or self._connection_repo is None:
return inbound return inbound
workspace_id = _group_workspace_id(aibotid, chatid) workspace_id = f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}"
inbound = await attach_connection_identity(inbound, repo=self._connection_repo, provider="wecom", workspace_id=workspace_id, fallback_without_workspace=False) inbound = await attach_connection_identity(inbound, repo=self._connection_repo, provider="wecom", workspace_id=workspace_id, fallback_without_workspace=False)
if inbound.connection_id: if inbound.connection_id:
return inbound return inbound
@ -676,35 +649,6 @@ class WeComChannel(Channel):
fallback_without_workspace=True, fallback_without_workspace=True,
) )
async def _carry_over_sender_keyed_group_thread(self, inbound: InboundMessage, chatid: str) -> None:
"""Rebind a group connection's thread from the sender key it used to have.
Group messages were once keyed like private ones (``userid``, ``userid``).
A group connection belongs to one group, so its thread under that key is
this group's and moves to the group key once. A legacy group binding
spans every group of the bot; its old thread is left behind rather than
continued in whichever group writes first.
"""
repo = self._connection_repo
if repo is None or not inbound.connection_id or not inbound.owner_user_id:
return
aibotid = str(inbound.metadata.get("aibotid") or "").strip() or None
if inbound.workspace_id != _group_workspace_id(aibotid, chatid):
return
if await repo.get_thread_id(inbound.connection_id, inbound.chat_id, inbound.topic_id) is not None:
return
previous = await repo.get_thread_id(inbound.connection_id, inbound.user_id, inbound.user_id)
if previous is None:
return
await repo.claim_thread_id(
connection_id=inbound.connection_id,
owner_user_id=inbound.owner_user_id,
provider=self.name,
external_conversation_id=inbound.chat_id,
external_topic_id=inbound.topic_id,
thread_id=previous,
)
async def _bind_connection_from_connect_code(self, *, frame: dict[str, Any], user_id: str, code: str) -> bool: async def _bind_connection_from_connect_code(self, *, frame: dict[str, Any], user_id: str, code: str) -> bool:
if self._connection_repo is None or not code: if self._connection_repo is None or not code:
return False return False
@ -727,7 +671,7 @@ class WeComChannel(Channel):
if not chatid: if not chatid:
await self._send_connection_reply(frame, await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"])) await self._send_connection_reply(frame, await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"]))
return True return True
workspace_id = _group_workspace_id(aibotid, chatid) workspace_id = f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}"
raw_group_name = str(body.get("chatname") or body.get("group_name") or "").strip() raw_group_name = str(body.get("chatname") or body.get("group_name") or "").strip()
workspace_name = notification_group_display_name(state.get("metadata")) or raw_group_name[:256] or None workspace_name = notification_group_display_name(state.get("metadata")) or raw_group_name[:256] or None
metadata: dict[str, Any] = { metadata: dict[str, Any] = {

View File

@ -1,102 +1,264 @@
# Gateway API (`app/gateway/`) ### Gateway API (`app/gateway/`)
The FastAPI application on port 8001. It serves the REST API, embeds the Reject external `sandbox`, `thread_data`, `viewed_images`, `goal_outcome`. Non-null goals: valid text, int counters/limits >=0, no Overwrite; keep sparse data.
LangGraph-compatible agent runtime, and owns auth, CSRF, run admission and thread
lifecycle. Route-level rules are in [routers/AGENTS.md](routers/AGENTS.md). The
endpoint list is the OpenAPI schema at `/docs`; [API.md](../../docs/API.md)
describes it for humans.
## Verify Studio retains sanitized creation metadata.
```bash Capability Center's `business` adapter validates bundled credentials and creates
cd backend an MCP connection. The MCP API permits only its isolated launcher and credential
python -m pytest tests -q -k "gateway or router or auth or run_manager" environment keys; reject arbitrary commands and manifest-policy bypasses. Keep
make test-blocking-io # request handlers share one event loop credentials out of tool schemas and preserve admin checks, masked atomic edits,
``` and MCP cache reloads.
Read these contracts before you change the matching area: Memory shutdown resolves hot-reloaded config/backend, flushes and closes in one
[thread lifecycle](../../docs/THREAD_LIFECYCLE.md) (branch, regenerate, edit `await_drained` operation. Resolve config off-loop inside the best-effort handler
replay, archive), [run event stream](../../docs/RUN_EVENT_STREAM.md), so malformed edits cannot abort teardown. Cancellation waits for owned workers.
[run interaction policy](../../docs/RUN_INTERACTION_POLICY.md), The host sets no close timeout: `close()` must be quick or internally bounded;
[auth design](../../docs/AUTH_DESIGN.md), [knowledge](../../docs/KNOWLEDGE.md). budget it, flush timeout and other hooks within pod grace. Drain extension
`stop_services()` across cancellation before later resources unwind; each
service `stop()` has a 30-second `asyncio.timeout`.
## Trust boundary Routers with persistent writes drain through
`app.gateway.persistent_writes.run_drained_write`.
- **Share tokens read only frozen snapshots.** See [sharing](../../../docs/guide/sharing.md). `lifespan` first holds `hold_sandbox_shutdown_ownership()`. After
- **Server-owned run context.** A client can send run context in `body.context` `langgraph_runtime` drains runs and extensions, close MCP (bounded, isolated),
and in `body.config`. Admit server-owned keys only from internally authenticated then drain `shutdown_sandbox_provider()` off-loop in `finally`, even on
callers and strip them from both places for everyone else. When you add a cancel. Never allocate for cleanup; log failures, stay retryable.
server-owned key, add it to that strip list in the same change.
- **Server-owned state.** Reject thread state fields that only the server may write
(sandbox, thread data, viewed images, goal outcome) and reserved metadata keys
when they come from a client. External run input cannot carry system or developer
messages.
- **Owner scoping.** Run-history helpers default to the request user and fail
closed when there is no user context. Pass `user_id=None` only in migration or
admin code that needs an unscoped read, and say why at the call site.
- **Thread IDs become paths.** Validate every caller-provided thread ID with
`deerflow.utils.thread_id` before it reaches persistence or the filesystem.
Legacy IDs stay readable and deletable, but they are never interpolated into a
host path.
- **Shared decisions.** Route permissions, model use and skill visibility are each
decided in one helper in `authz.py`. HTTP routes, decorator-only auth and the
Live Browser WebSocket all call it. A new entry point calls the same helper so
that it cannot drift from the others.
- **Tokens and cookies.** A request with an invalid Bearer token gets 401 and never
falls back to the session cookie; the CSRF middleware skips Bearer requests and
relies on this. Personal access tokens work only on allowlisted routes.
- **Proxy headers.** "Is this localhost" decisions use the direct `Host` header.
Forwarded headers are client-controlled unless the outer proxy replaces them.
- **Untrusted remote text.** MCP notifications and other remote events reach the
model framed as untrusted data, outside the trusted instruction.
## Runs and threads Retention holds durable thread admission and the checkpoint lock before
checkpoint reads. Inspect root head/parent and leaf writes only; protect selectors,
require equal channel versions and invalidate caches before deletion. No scans,
stats or blob GC; failures stay non-fatal.
- **One writer per thread.** A run, a checkpoint write, an artifact write, a branch `conversation_access.py` grants the opt-in read-only tool only for explicit
and a delete are all thread operations that share one durable uniqueness `conversation_references` with effective `runs:read`; never infer grants from
constraint. Reserve through `RunManager.reserve_thread_operation()` or messages or checkpoints. Pass the callback through `RunContext`, not serialized
`services.reserve_checkpoint_write()`. A separate lock would let two workers config, and let the worker inject and clear it. `conversation_reader.py` shares
write the same thread. Outside run execution, checkpoint mutations — manual HTTP transcript visibility/pagination without a Request; callers own authorization.
compaction, `POST /threads/{id}/state`, both goal routes (`PUT` / `DELETE Exclude non-text/internal content. `RunCreateRequest` lifts SDK-compatible
/threads/{id}/goal`), and the `/goal from-chat` save (`POST `context.conversation_references` into its top-level field before validation,
/threads/{id}/goal/from-chat`) — use `services.reserve_checkpoint_write()`. then removes it from context; supplying both is 422. This shares bounds/error
`PUT /goal` creates a missing checkpoint; from-chat does not. From-chat locations and keeps `merge_run_context_overrides` clear. Admission and
saves with `expected_checkpoint_id` pinned to the head its synthesis read: `/api/features` use `conversation_references_enabled()` as the sole configured
a write that landed during the model call rejects the stale objective predicate.
instead of clobbering the newer state, and the model call itself passes the
same `authorize_model_use` admission as the suggestions route.
- **Several workers.** In-memory run records exist only on the worker that owns the
run. Cancel, takeover and orphan recovery are compare-and-set operations on the
run store; use the store's claim and cancel primitives so that a lease renewal
between your read and your write is not overwritten. Multi-worker deployments
need `run_events.backend: db`.
- **Idempotency keys.** Scope an external `Idempotency-Key` to its owner and thread
before it reaches `RunManager`; the manager's index is process-wide.
- **`/wait` on a reused run** returns the durable run status and error. The latest
thread checkpoint can belong to a different run.
- **Stream gaps.** Stream bridges keep a bounded buffer. A client that asks for
events older than the buffer receives a `gap` event and the run continues.
- **`Content-Location` must stay CORS-exposed.** Run-creating routes return the run
ID in that header and the LangGraph SDK reads it from there only.
- **Run event contract.** Producer code, `deerflow/constants.py`, the event
catalog, `contracts/run_event_stream_contract.json` and
`docs/RUN_EVENT_STREAM.md` change together.
- **Thread membership in a project** is written by thread creation, branching and
the explicit move route. Run admission reads it and never changes it.
## Lifecycle FastAPI listens on 8001. `GET /health` is liveness; `GET /health/ready` concurrently
probes startup snapshots of ORM, checkpointer/Store and stream bridge
(`StreamBridge.ping()`) under one deadline and per-kind gates. Unreachable or
unresolved gating backends return 503; `memory` reports `not_configured`;
provisioner `/health` is report-only. See `health.py`. `GATEWAY_ENABLE_DOCS=false`
disables `/docs`, `/redoc` and `/openapi.json`.
- **Probe file-backed managed subagent storage once during startup, off the `build_run_config()` resolves the default LangGraph super-step budget from the
event loop.** Probe failures warn operators before their first create request; hot-reloaded top-level `recursion_limit` setting. A valid request-level value
atomic create stays fail-closed rather than falling back to an overwrite. takes precedence; invalid values fall back to that default, and
- **Shutdown order matters.** `lifespan` drains runs and extensions first, then `max_recursion_limit` caps both sources.
closes MCP, then shuts the sandbox provider down off the event loop. Cleanup code
keeps running after cancellation, allocates nothing new, and logs failures Durable MCP notifications use internal Agent runs. Keep their trusted delivery instruction outside the user-input boundary, and frame serialized remote events as untrusted before model invocation. Strict thread existence/ownership admission dead-letters events whose task outlives its deleted chat instead of recreating the thread. `start_run` marks run-manager admission conflicts with `BusyThreadConflict`; the notification launcher retries only that typed 409 and treats other 409 responses as permanent.
instead of raising.
- **Persistent writes in routers** go through CORS defaults to same-origin behind nginx on 2026. Split-origin and port-forwarded
`app.gateway.persistent_writes.run_drained_write` so that a client disconnect browser clients must set exact `GATEWAY_CORS_ORIGINS`, shared by `CORSMiddleware`
cannot leave a half-written file. and `CSRFMiddleware`. Expose `Content-Location` through `CORS_EXPOSED_HEADERS`
- **LangGraph Studio.** `langgraph_studio.py` is loaded by LangGraph's file loader (`csrf_middleware.py`): run-creating routes put the run ID there, and the LangGraph
before the runtime starts and without a `sys.modules` entry. Keep its SDK uses only that header for run metadata. Without exposure, JS cannot read it,
annotations eager, and identify Studio callers by the upstream `StudioUser` breaking `useStream.onCreated` and thread-gated actions.
type, because the identity string can be reused by any client.
Browser sessions belong to `app.gateway.auth.session_cookie`. Login's `remember_me`
flag never stores passwords. `SessionCookiePolicy` persists `HttpOnly access_token`
only for HTTPS/trusted-forwarded HTTPS, direct-host localhost HTTP, or explicit
operator opt-in; public HTTP otherwise uses session cookies. Session creators set
final `max_age` on `request.state`; CSRF mirrors it across login, password changes
and OIDC callbacks. A small `HttpOnly` preference cookie preserves the choice.
Logout clears auth cookies and suppresses CSRF re-issue.
OIDC redirects retain existing endpoint query bytes (RFC 6749 section 3.1).
Malformed IdP bodies raise `OIDCError` (502/`sso_failed`).
`tests/test_oidc_auth.py` pins query, state-cookie, nonce and PKCE agreement.
Personal Access Tokens (`app.gateway.auth.pat`, `Authorization: Bearer dfp_...`) run as their owning user: an invalid Bearer is a hard 401 with no cookie fallback, which keeps `CSRFMiddleware`'s Bearer skip safe (origin checks still run). Scopes narrow within the allowlisted threads/runs/projects routes (including `POST /api/threads/{id}/move`); every other authenticated route 403s PAT callers (admin included). PAT management and `/change-password` require session auth; only SHA-256 digests are stored (`0017`).
Thread→project membership is written by thread creation (`POST /api/threads` with
a validated `project_id`), branch creation (the new row inherits the source
thread's project; an archived/deleted project degrades the branch to unassigned
instead of failing), and explicit moves (`POST /api/threads/{id}/move`); run
admission never modifies membership; it only pins the resolved project context
read-only into the run context. The server-reserved `deerflow_project_id`
metadata key is a read-only exposure of the `threads_meta.project_id` column and
is stripped from client writes.
Localhost persistence deliberately reads the direct request `Host` and ignores `Forwarded` / `X-Forwarded-Host`. Scheme and auth-origin reconstruction still consume forwarding headers. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header, so the outer trusted proxy must replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
Standalone local LangGraph Studio is recognized only through the upstream
`Auth.types.StudioUser` principal type, never by its reusable identity string.
The type is resolved once at import; an older SDK without it degrades to normal
owner scoping.
For that principal's assistant reads/searches, `langgraph_auth.add_owner_filter`
selects genuine server-registered assistants plus assistants owned by Studio;
all other resources remain owner-scoped. Assistant create/update handlers make
both `user_id` and `created_by=user` server-owned, because LangGraph gives
`created_by=system` privileged ownership semantics during run creation. The
custom application module in `langgraph_studio.py` is imported before the
locked in-memory runtime lifespan. At that pre-runtime boundary it derives
genuine system assistant IDs from the CLI-provided graph registry, removes
their persisted active/version rows so graph registration recreates them, and
demotes every other legacy `created_by=system` marker in both active assistants
and version history. This must happen before runtime 0.30.0 loads and purges
system-marked rows; a user application lifespan is too late. LangGraph executes
this file-backed custom app without first registering its module in
`sys.modules`; keep its annotations eager so dataclass processing remains
compatible with that loader, and preserve the direct file-loader regression
test. An empty graph registry or absent persistence file is a no-op, while
persistence parse/write errors fail startup closed. The harness requires
in-memory runtime 0.30.0 or newer, and a persisted store containing no
expected registered assistant row emits a drift warning so changes to
LangGraph's internal persistence contract are observable. With current
create/update writes and all legacy versions sanitized, ordinary
owner-scoped assistant version selection remains enabled.
**Routers**:
| Router | Endpoints |
|--------|-----------|
| **Managed Models** (`/api/managed-models`) | Admin-only GET/PUT with revisions; streaming POST `/test` (DeepSeek thinking off). Clients/storage off-loop; YAML names reserved; keys private. |
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **Features** (`/api/features`) | `GET /` - UI capabilities: hot-reloaded agents, guarded browser, startup MCP tasks, separate batch repository/worker states so history stays readable without a worker, `conversation_references` (`read_conversation` configured, per-run reference cap), knowledge scope selection, `scheduled_tasks`, and `thread_activity` |
| **Thread Activity** (`/api/thread-activity`) | `GET /?cursor=` - per-user feed over `runs.change_seq`: no cursor seeds; lists threads of the caller's server-originated runs, passes interactive rows; `read_version`. `POST /api/threads/{id}/read` and search `unread` use `thread_read_markers`. SQL only |
| **Knowledge** (`/api/knowledge`) | Authenticated, capability-driven read-only retrieval catalog and per-assistant `/capabilities`; provider adapters own resource authorization and mapping. Unsupported filters fail admission/execution without widening scope; management stays outside core |
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user: `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly (no new `RunStore` methods); SQL only (503 on `database.backend: memory`). Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). Mixed currencies null cost/currency fields. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
| **MCP** (`/api/mcp`) | GET /config - raw/masked; PUT /config - bulk; PATCH /config - toggle; POST /config/servers - add; PUT /config/server - replace; DELETE /config/servers/{server_name:path} - bodyless. POST /cache/reset. Validate expanded, save raw; reload/reset; invalid -> 400. |
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
| **Skills** (`/api/skills`) | `GET /` - list; `GET /{name}` - inspect; `PUT /{name}` - toggle; `POST /install` - install a thread-local .skill archive; `POST /install/upload` - admin-only multipart, authorized before parsing and capped at a 100 MiB file plus 1 MiB framing; `POST /reload` - refresh, then publish the shared skills reset marker |
| **Subagents** (`/api/subagents`) | Admin managed-worker CRUD and listing. |
| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_probed` / `sandbox_runtime_ready` (whether `lark-cli` will be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/config/credentials` - atomically switch the caller's per-user Lark app after validating the new `app_id`/`app_secret` through the official CLI's live tenant-token probe, revoke/remove the previous OAuth tokens, and restore the prior credential tree if the switch fails; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants. Config and auth flows carry a server-issued, per-user generation persisted under the credential lock; a rejected direct switch leaves the current generation unchanged, stale completions return 409, and browser re-registration uses the same token-clearing/revocation transaction as direct credential switches. |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload/convert PDF/PPT/Excel/Word; upload/shelf names share `normalize_filename` before staging; non-mounted sync lease ends when its last holder exits; `GET /list`; `DELETE /{filename}` - regular files, symlinks 404, keeps converted `.md` (#5672) |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - branch a completed assistant turn with a replay checkpoint; inherited titles take next-free displayed sibling suffixes, including explicit/renamed ones, while explicit titles stay unchanged. Durable `branch` admission rejects races. Workspace files are not checkpointed: only a branch from the **latest** turn best-effort copies the workspace (`workspace_clone_mode="current_thread_best_effort"`); older turns skip it (`"skipped_historical_turn"`), so no later-timeline file leaks in. `sandbox`/`thread_data` channels are not copied (parent `sandbox_id` binds paths/release); the branch lazily acquires its own sandbox. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows get one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn at every persisted human message, including an allowlisted hidden `ask_clarification` reply): `run_id` is a turn identity, and regenerating an inherited answer supersedes its whole `run_id` in `GET /messages/page`, so one shared seed id deleted all inherited history (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - summarize older active context, deriving memory policy and bucket from the state-producing checkpoint rather than request `agent_name`, and block while a run is in flight; 409 unless idle; unexpected failures return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | GET streams byte ranges; HTML/XML/XSL/`+xml` (also `.skill` members) and `?download=true` force attachments. PUT atomically replaces existing UTF-8 outputs on matching SHA-256; active runs conflict. Non-mounted sync uses a request lease and drains remote/local commit-or-rollback across cancellation before releasing reservations/leases. `resolve_outputs_confined_path` normalizes `..` and confines host paths against traversal/symlinks. Preserve POSIX ownership/mode where supported; Windows keeps temp-file permissions. Digest cache: path/dev/ino/ctime_ns/mtime_ns/size; identity invalidates same-size/mtime replacements. Large regular files use stat-prefixed ETags; If-Range requires matching ETags. Windows ctime is creation time. Keep cache reuse, size limits and worker hashing. |
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `<think>` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) |
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, seed an empty feed from a checkpoint so legacy checkpoint-only history keeps its order and visibility; skip absent checkpoints or populated feeds. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, treating a protocol-valid hidden `human_input_response` as confirmed replay input while rejecting other hidden/control humans, preserving that response's hidden/correlation metadata, and carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - newest 100 runs as an array; `GET /page` - keyset history page `{data, has_more, next_before_created_at, next_before_run_id}`; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/stream` hides action/wait; GET action 405 pre-owner; POST needs `runs:cancel`; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET/POST /{rid}/artifacts/archive` - receipt manifest / bounded ZIP; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its `context_window`. |
| **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`, `/wait` - stateless runs requiring `runs:create`; optional body `thread_id` is owner-checked. Scheduled-task create/update/resume/trigger also require `threads:write` plus `runs:create`. `GET /{rid}/messages`, `/feedback` - run messages/feedback |
| **GitHub Webhooks** (`/api/webhooks/github`) | `POST /` - receive GitHub App / repo webhook deliveries. Verifies `X-Hub-Signature-256` against `GITHUB_WEBHOOK_SECRET`; exempt from auth + CSRF because authenticity is enforced by HMAC. The route is fail-closed: mounted only when `GITHUB_WEBHOOK_SECRET` is set, or when explicit dev opt-in `DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1` is set. Recognized events include `ping`, `issues`, `issue_comment`, `pull_request`, `pull_request_review`, and `pull_request_review_comment`; unknown events return 200 with `handled=false`. Fan-out runtime failures return 503, keeping the delivery recorded as failed for manual/API/scripted redelivery (GitHub does not automatically retry any failed delivery, 5xx included); permanent/non-retryable conditions such as `channels.github.enabled: false`, unknown events, malformed payloads, or unavailable channel service return 200 with a skipped/handled response. |
| **GitHub Event-Driven Agents** | Custom agents can declare a `github:` block in their `config.yaml` to bind to repos and event triggers. Webhook fan-out publishes one `InboundMessage` per matching binding to the channel bus; `GitHubChannel` routes those messages through `ChannelManager`. The response `dispatch` summarizes matched/fired/skipped agents. |
Edit replay and messages-input clarification may replace knowledge scope with a
validated client snapshot. Regenerate, null-input and `Command` resumes recover
scope from the genuine source user turn, skip hidden server context, and ignore
client scope. Treat `input.messages: null` as empty to allow promptless
conversation-reference runs.
Thread identifiers use the shared `deerflow.utils.thread_id` contract
`^[A-Za-z0-9_-]{1,64}$`. Caller-provided opaque IDs remain supported; UUIDs
are generated only for `None`, while explicit empty strings fail validation.
Gateway creation and state-producing request boundaries, embedded-client
entry points, filesystem/upload/event-store consumers, scheduled launches,
and the standalone Provisioner enforce the same contract before persistence
or workspace initialization. Route-addressable legacy IDs remain accepted by
pure reads and cleanup/control endpoints; deleting one best-effort removes
metadata and checkpoints but skips local filesystem cleanup, so the raw value
is never interpolated into a host path. New runs, workspace/sandbox
operations, and other state-producing mutations remain blocked.
**Message feed seq** (#4666): streaming `values` frames, `GET
/threads/{id}/state`, and `POST /threads/{id}/history` stamp serialized
messages with `additional_kwargs.deerflow_seq` so clients can place
checkpoint-kept messages against the paged feed; the REST reads resolve the
store via `threads.py::_optional_run_event_store` (a feed-less deployment
still reads threads), and `services.py::normalize_input` strips the
server-owned key from client input (#4380). Mechanism and identity rule:
`packages/harness/deerflow/runtime/AGENTS.md`.
**Workspace change review**: `packages/harness/deerflow/workspace_changes/`
captures a pre-run and post-run snapshot of the thread-owned `workspace` and
`outputs` directories. `runtime/runs/worker.py` performs the filesystem scan via
`asyncio.to_thread` and writes a `workspace_changes` event with category
`workspace` when changes exist. Uploads are intentionally excluded. Text diffs
are size-limited; binary, large, and sensitive-looking paths are persisted as
metadata only. Internal process-feedback directories never count as changes:
the scanner's `EXCLUDED_DIR_NAMES` drops `BROWSER_FRAMES_DIRNAME` (transient
browser screenshots) and `TOOL_RESULTS_DIRNAME` (the tool-output budget
middleware's default externalization subdir, `constants.py` is the shared
source of truth for both writers and the scanner), and the worker threads the
configured `tool_output.storage_subdir` through the snapshot capture as an
extra excluded dir name so custom storage locations stay excluded too.
**Run delivery receipts**: the worker derives delivery requirements from the
run's workspace snapshots rather than a client request option (files
created/modified under `/mnt/user-data/outputs`, minus internal
process-feedback exclusions) and idempotently persists a run-scoped
`run.delivery` receipt before the terminal run status; missing or
unverifiable `present_files` coverage downgrades the run to error, while runs
without changed outputs keep ordinary chat behavior. Journal mechanics
(callback attribution, receipt idempotency and retries, orphan recovery):
`packages/harness/deerflow/runtime/AGENTS.md`. Multi-worker deployments
require `run_events.backend: db` for shared, ordered delivery events; the
startup gate rejects memory/JSONL event stores when `GATEWAY_WORKERS > 1`
or `deployment.multi_instance: true`.
**RunManager / RunStore contract**:
- LangGraph-compatible run requests validate their supported subset before creating a run. `runtime/stream_modes.py` is the shared backend contract for public stream modes and the worker's `graph.astream` mapping; the public `messages-tuple` mode maps to LangGraph's internal `messages` mode, while public `messages`, `events`, and other unsupported modes are rejected instead of being dropped or replaced with `values`. `app/gateway/run_models.py::RunCreateRequest` is shared by HTTP and internal scheduled launch paths, retains only truthful compatibility defaults for unimplemented options (`if_not_exists="create"` plus `None` placeholders), returns 422 for unsupported values including `on_completion="complete"`, `on_completion="continue"`, and `multitask_strategy="enqueue"`, and forbids undeclared SDK options so fields such as `checkpoint_during` and `durability` cannot be silently discarded. A placeholder must still accept the stock SDK's own default: `langgraph_sdk` drops only `None` from its run payload, so `stream_resumable=False` reaches every request and means "non-resumable", which is what DeerFlow serves — rejecting it 422'd every IM channel run (#4466). `test_run_request_validation.py::test_gateway_accepts_langgraph_sdk_default_payload` pins the real SDK payload; channel tests mock the SDK and miss this drift.
- `RunManager.get()` is async; direct callers must `await` it.
- The history batch helpers `list_successful_regenerate_sources()`, `list_edit_regenerate_runs()`, and `get_many_by_thread()` default to `user_id=AUTO`: they resolve the request user and fail closed when no user context exists. Migration/admin callers that intentionally need an unscoped read must pass `user_id=None` explicitly.
- Edit-and-rerun visibility is derived from edit replay runs (`metadata.replay_kind="edit"` plus `regenerate_from_run_id`) by `RunManager.list_edit_replay_visibility()`: the newest attempt for each source run is authoritative. Pending/running/success attempts hide the original source run; failed, timed-out, or interrupted attempts hide only the failed attempt so the original conversation reappears.
- When a persistent `RunStore` is configured, `get()` and `list_by_thread()` hydrate historical runs from the store. In-memory records win for the same `run_id` so task, abort, and stream-control state stays attached to active local runs.
- Thread metadata status switches to `running` only after `RunManager.try_start()` succeeds. Pending-cancelled runs therefore skip the old `running` projection, while clients may observe the prior thread status during the short worker-startup window.
- `cancel()` returns a :class:`~deerflow.runtime.CancelOutcome` enum: `cancelled` (local cancel), `requested` (the non-owning worker durably recorded the first cancellation action for the live owner), `taken_over` (non-owning worker claimed the run because the owner's lease expired — marks it as `error`), `lease_valid_elsewhere` (legacy/custom store lacks the durable request primitive — caller retains the safe 409 + `Retry-After` fallback), `not_active_locally` (heartbeat disabled, preserving the old 409 path), `not_cancellable` (terminal state), or `unknown` (not found in memory or store). `create_or_reject(..., multitask_strategy="interrupt"|"rollback")` persists interrupted status through `RunStore.update_status()`, matching normal `set_status()` transitions.
- Interrupt/rollback admission registers the replacement before its best-effort persistence of locally interrupted predecessors. If the admitting caller is cancelled during that post-registration await, `RunManager` drains a shielded replacement cleanup before propagating `CancelledError`, including across repeated cancellation. The cleanup normally persists `interrupted`; if that best-effort transition fails, it retries the active-to-`interrupted` store transition strictly and verifies the result with the replacement's captured owner identity. A concurrent peer terminal transition wins and is synchronized back into the local record rather than being overwritten or deleted.
- Store-only hydrated runs are readable history. In multi-worker mode with heartbeat enabled, cancel on a store-only run records `runs.cancel_action` / `cancel_requested_at` while the owner's lease is live; the first action wins even if a retry later lands on the owner. `RunStore.request_cancel()` and owner completion through `finalize_if_not_cancelled()` are competing active-row CAS operations, so an accepted cancel cannot be overwritten by a later success. `RunStore.renew_lease()` renews and observes the request atomically in the SQL implementation. The owner then executes the normal process-local interrupt/rollback and terminal stream path without transferring the lease. An expired owner is taken over with the `orphan_recovered` stop reason so cross-worker consumers can synthesize END. `wait=true` and cancel-then-stream use the shared bridge to observe owner finalization; a non-standard process-local bridge returns accepted 202 instead of subscribing to an unreachable stream. In single-worker mode (heartbeat off), store-only runs still return 409.
- A local worker's `RunRecord.lease_expires_at` is the last durably confirmed ownership deadline. `_renew_leases()` bounds each renewal attempt by that deadline: transient store exceptions remain retryable while it is valid, but an exception or blocked call that reaches expiry sets the process-local `ownership_lost` fence, raises `abort_event`, and cancels the run task. Successful renewals collect durable cancellation actions; after all local renewals have been attempted, heartbeat only signals the corresponding process-local tasks, leaving status writes and rollback cleanup to the worker finalization path. Fenced workers do not perform subsequent journal/delivery-receipt, progress/completion/status, checkpoint/thread-metadata, or `on_run_completed` writes; the peer recovery path owns the terminal receipt. `RunStore.update_run_completion()` also refuses to replace a different terminal status, closing the peer-takeover/late-finalization race. `grace_seconds` delays peer reclamation for clock skew but is not extra execution time for an owner that can no longer confirm its lease. Already-committed remote tool side effects remain outside this local cancellation boundary.
- Startup/orphan reconciliation must claim stale active rows with `RunStore.claim_for_takeover()`, not a plain `update_status()`. The final claim re-checks `status` and lease expiry atomically, so a heartbeat renewal between the candidate scan and the recovery write keeps the run active.
- Run admission and independent writes are first-class thread operations. `runs.operation_kind` distinguishes `run` from `checkpoint_write`, `artifact_write`, `artifact_archive`, `branch`, and `delete`; every active kind shares the durable active-thread uniqueness constraint. New operation kinds must go through `RunStore.create_thread_operation_atomic()` and `RunManager.reserve_thread_operation()` rather than adding another lock or metadata marker. Live and lease-less reservations are non-interruptible; an expired leased reservation can be reclaimed immediately by interrupt/rollback admission without waiting for orphan reconciliation. Lease-less rows stay fail-closed because the store cannot distinguish a stale row from a live writer in another heartbeat-disabled worker; a rare failed delete therefore requires startup reconciliation, and heartbeat-disabled multi-worker deployment remains unsupported. Reservation bodies are attached to their caller task so loss detected by lease renewal cancels the writer before it can continue after takeover; the context manager translates that lease-loss cancellation to `ConflictError` after cleanup so Gateway mutation routes return a retryable 409 instead of dropping the HTTP request. The cleanup scope begins immediately after durable admission, including the await that attaches the caller task, so cancellation cannot strand a locally renewed pending reservation. A failed renewal is revalidated under the manager lock before cancellation; if the reservation completed and unregistered while the store update was in flight, its request task must not be cancelled after the write. Reservations are excluded from run history/reporting and from run-only helpers such as `list_by_thread()` and `has_inflight()`, release uses the captured owner rather than ambient user context, and local cleanup still runs when the best-effort store delete fails. `RunStore.create_run_atomic()` remains a deprecated compatibility shim for external stores that only admit normal runs; new stores must implement `create_thread_operation_atomic()` to support internal operation kinds.
- Gateway checkpoint mutations outside run execution must use `services.reserve_checkpoint_write()`, which composes the process-local thread lock with the durable `checkpoint_write` reservation. Manual compaction, `POST /threads/{id}/state`, and both goal mutation routes (`PUT` / `DELETE /threads/{id}/goal`, including creation of a missing goal checkpoint) use this boundary, so an existing run blocks the write and the reservation blocks new reject/interrupt/rollback runs across workers.
- Branch/state-update checkpoints copy only the source checkpoint's persisted `deerflow_agent_name`, never request metadata; missing or malformed bindings stay unbound so compaction fails closed.
- `DELETE /api/threads/{id}` holds a durable `delete` reservation for the whole cleanup and removes thread filesystem data (off-loop, drained on cancel), checkpoints, owner-scoped historical runs, run events, feedback and thread metadata best-effort. Historical-run cleanup removes only `operation_kind="run"` rows, so internal thread-operation rows — including the reservation protecting that request — stay durable until `RunManager.reserve_thread_operation()` exits. This cleans persisted rows; preventing an already-admitted write from re-creating state for a deleted thread is a separate thread-incarnation contract, not part of this cleanup.
- Creator SSE and `/wait` apply `on_disconnect` only to confirmed request disconnect or cancellation; bridge failures propagate without cancelling the run. `/wait` consumes bridge END; errors return SDK `__error__`, never stale state.
- Memory and Redis `StreamBridge` implementations retain only `stream_bridge.queue_maxsize` data events. A syntactically valid `Last-Event-ID` older than the retained watermark, or a live subscriber that falls behind it, yields `StreamGap` before any partial replay. `sse_consumer` maps that control item to an id-less SSE `gap` payload (`stream_replay_gap`) and intentionally leaves the run active; internal `/wait` consumers resume from its latest retained ID because they only need terminal completion. Redis checks bounds plus the non-blocking read in one transaction, using blocking `XREAD` only as a wake-up before repeating the atomic snapshot. For a no-cursor subscriber that established a wait on an empty stream, the first wake response remains provisional until that next snapshot verifies its tail is still retained; this closes the pre-first-delivery trimming window without changing malformed-cursor live tailing. The correctness tradeoff is one three-command snapshot pipeline per poll plus the blocking wake round trip while idle. Malformed cursor behavior remains backend-specific. Memory treats a syntactically numeric cursor below its watermark conservatively as a gap even when the evicted timestamp can no longer be verified; unknown ids at or above the watermark retain the legacy replay-from-earliest policy.
- Redis `StreamBridge` keys use a rolling retained-buffer TTL (`stream_bridge.stream_ttl_seconds`, refreshed on data and first-END writes) as a leak safety net, not as a run timeout. Startup and lease-driven periodic orphan recovery share one Gateway stream-terminalization path: after `RunManager` durably marks a run `error` with `stop_reason=orphan_recovered`, Gateway publishes `END_SENTINEL` and schedules stream cleanup. The periodic store scan, per-row status writes, and Gateway callback run as one supervised single-flight task, so a slow pass is skipped at the next interval instead of piling up or pausing the sole lease-renewal loop. Store retries have bounded attempts/backoff; an individual operation still relies on the database driver/pool timeout. `RunManager.shutdown()` gives active user runs priority within its shared deadline, then drains or cancels orphan recovery. Gateway tracks delayed recovered-stream cleanups and converts unfinished delays to immediate deletes before closing the bridge; the Redis TTL remains the outage safety net. Only startup recovery, before the runtime yields to requests, projects the latest affected thread to `error`; periodic recovery deliberately avoids that non-atomic projection because `ThreadMetaStore` has no `latest_run_id` conditional-update contract. Store-only SSE and `/wait` consumers wait for the bridge's real END marker after an ordinary durable terminal status, because status persistence can precede tail events. Terminal `orphan_recovered` and `scheduled_task_orphan_recovered` rows permit heartbeat fallback because their producer is gone; ordinary terminal rows still await END. Malformed `Last-Event-ID` reconnect values live-tail new Redis events rather than replaying the retained buffer. Keep cross-component recovery orchestration in Gateway through the generic `RunManager.on_orphans_recovered` callback; do not introduce a harness-to-app dependency. Callback failure warnings include every recovered `run_id` so operators can identify rows whose Gateway-side terminalization needs inspection.
- Thread-scoped create, stream and wait share an optional owner/thread-scoped `Idempotency-Key` (internal callers: owner-only, cross-thread reuse 409); never pass the raw external key to `RunManager`'s process-wide index. Stores resolve an existing key before `interrupt`/`rollback`, so a retry reuses rather than interrupts its run. Reuse requires matching input, assistant and conversation references; keyed resume stores a versioned strict typed-JSON SHA-256 (`true`, `1` and `1.0` differ) in the private `runs.idempotency_request_json` column, never public `kwargs`; ordinary input needs no extra identity. New readers accept the exact old-writer resume identity shape from `kwargs`, every response strips it; identity-less legacy resume fails closed. Validate strict resume JSON with or without a key; persist the identity only for keyed resume admission. Missing headers stay non-idempotent; stateless `/api/runs/*` is outside this contract (it creates a thread before admission).
- For a rolling upgrade, route every keyed resume submission/retry on create/stream/wait only to upgraded workers; otherwise pause that traffic or replace old workers first. Old workers ignore the private identity and can reuse `approve` for `deny` with null input. Old-reader schema compatibility does not protect admission. Upgraded workers reject identity-less legacy resumes with 409. Rollout details: `README.md`, `backend/docs/API.md`.
- Reused `/wait` returns durable status/error, never the latest thread checkpoint; `task is None` is not completion, and store-backed fields must refresh after waiting. Capture reuse before awaiting because the cached record's `idempotency_reused` flag is sticky. A creating `/runs/stream` retry whose terminal stream is gone opts into `gap`/`stream_replay_gap` via `emit_gap_on_missing_stream`, while observer joins and default/stateless consumers emit `end`; never infer this from disconnect policy or the sticky flag. A reused running `store_only` record on a process-local bridge returns 409 without `Retry-After`.
- Thread-scoped run creation accepts `checkpoint` / `checkpoint_id`; Gateway validates the checkpoint belongs to the request thread before writing `checkpoint_id` / `checkpoint_ns` into `config.configurable` for LangGraph branching. In `delta` checkpoint mode the worker rewrites that fork into a linear head write before the graph starts (see "A delta-mode run cannot fork" under Checkpoint Channel Modes), because delta state for a fork replays the abandoned sibling's writes.
- Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. The evaluator runs after the graph root's tracing scope has already closed, so `create_goal_evaluator_model`/`evaluate_goal_completion` attach their own model-level tracing callbacks (`attach_tracing=True`) and inject Langfuse trace metadata (`thread_id`/`user_id`/`deerflow_trace_id`) directly onto the `ainvoke` call — the same standalone-caller pattern as `oneshot_llm.run_oneshot_llm` and `MemoryUpdater` (see Tracing System below). Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when the turn did not end on an unanswered Human Input Card (`ask_clarification` or the sandbox network prompt; the question is still open, so the goal stands down with `blocker: "needs_user_input"` without calling the evaluator), a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the run has not hit its token-budget hard stop (`stop_reason == "token_capped"`; the goal stands down with `stand_down_reason: "token_capped"`), the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0`–`8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior.
- Run event stream changes must keep producer code, `deerflow/constants.py`, `runtime/events/catalog.py`, `contracts/run_event_stream_contract.json`, `backend/docs/RUN_EVENT_STREAM.md`, and `tests/test_run_event_stream_contract.py` in sync. The dependency-free constants module owns the persisted envelope limits (`event_type` 32 characters, `category` 16) and cross-layer workspace event identity; the catalog owns validated runtime definitions and categories. Dynamic middleware tags are limited to 21 characters after the `middleware:` prefix. The JSON contract owns payload schemas, backend-specific storage semantics, legacy aliases, and compatibility rules; conformance tests require both views and all producer groups to agree. `run.end.content` remains opaque and may retain nested Python values in memory while JSONL/database stores stringify non-JSON nested values, so consumers must not assume backend-identical nested output representations.
Scheduled-task PATCH: omitted `assistant_id` preserves the pin; explicit null resets to `lead_agent`.
Two origin keys; never read one for the other. `deerflow_scheduled_origin` marks the scheduled prompt message (`scheduled_goal_notes_contract.json`); `deerflow_origin` says who started a run/created a thread (`thread_origin_contract.json`), set only via `request.state.run_origin` or internal `body.metadata`.
**Thread lifecycle**: Before changing branching, regeneration, edit replay, or
archive/search behavior, read [Thread lifecycle invariants](../../docs/THREAD_LIFECYCLE.md).
It owns lineage and settled-checkpoint rules, legacy fallback boundaries, archive
filtering before pagination, owner isolation, and activity-time preservation.
MCP create/replace/state writes require unique installation IDs. DELETE validates
schema but permits residual legacy ID collisions. Explicit selections exclude
ambiguous IDs until repaired.
Capability skill discovery (`/api/capabilities/installations/skills`) reuses
`routers/skills.py::_filter_visible_skills` after its off-thread storage read.
It must preserve the ordinary skill listing's caller visibility and provider
failure policy rather than exposing the unfiltered user-scoped catalog.
Cap edit reads at `MAX_EDITABLE_ARTIFACT_BYTES + 1` despite stale `lstat`.
### Route and skill authorization
`authz.authorize_model_use` shares the `model:use` decision between model details
and follow-up suggestions, including the factory's first configured model when
the caller omits `model_name`. Suggestions check admission before their
best-effort LLM error handler: explicit denies return 403 without invoking the
model, while provider failures honor `authorization.fail_closed`.
`authz.py::resolve_route_permissions()` is shared by HTTP, decorator-only auth, and Live Browser WebSocket admission. It evaluates full `resource:action` targets; provider errors follow `authorization.fail_closed`, while disabled authorization preserves legacy permissions. Owner/admin checks stay separate. Live requires `threads:write` with `is_internal=False` after login/Origin checks; denial closes 4403 and setup errors close 4501. Admission is connection-scoped. On disconnect, Live drains owned `stop_screencast()` cleanup before releasing the session lease. Tests: `test_authorization_route_permissions.py`, `test_browser_readonly_security.py`, `test_auth.py`, `test_auth_middleware.py`.
Skill listing authorization mirrors the models pattern: `routers/skills.py` routes resolve `(provider, principal)` through `authz.py::resolve_skill_authorization()` — a thin sibling of `resolve_model_authorization`, both delegating to the shared `_resolve_route_scoped_authorization` core — and the shared `_filter_visible_skills` helper filters the user-scoped catalog (public + caller's custom skills) through `filter_resources(principal, "skill", ...)` by name. Three user-facing surfaces apply it: `GET /api/skills` (the frontend skill list / slash-command autocomplete), `GET /api/skills/custom`, and `GET /api/skills/{name}` — the detail endpoint returns the standard 404 for an invisible skill so it cannot become an existence oracle the filtered list closed (`get_model`'s 403 is an execution decision via `authorize("model", "use")`, which has no skills equivalent in this layer). Anonymous callers are unfiltered (mirroring `list_models`), and provider resolution/decision errors follow `authorization.fail_closed` (fail-closed → empty listing / 404, fail-open → full listing). Skill management endpoints (`install`/`upload`/`reload`/custom-skill CRUD) stay `require_admin_user`-gated, and runtime activation authorization is a separate layer. The built-in RBAC provider maps this to the per-role `skills` policy key. Tests: `tests/test_skills_listing_authorization.py`.
Batch workers pin `app.state.extensions`; never persist snapshots.
Personal MCP is owner-only; platform MCP shared.
### Web model provider presets
Managed model routes expose provider metadata; routing stays server-owned. Align validation, probing, storage, keys, and revisions.

View File

@ -49,8 +49,6 @@ from app.gateway.routers import (
projects, projects,
runs, runs,
scheduled_tasks, scheduled_tasks,
search,
shares,
skill_mutations, skill_mutations,
skills, skills,
subagent_batches, subagent_batches,
@ -925,18 +923,11 @@ async def _gateway_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
if subagent_batches_config.enabled and batch_repo is None: if subagent_batches_config.enabled and batch_repo is None:
raise RuntimeError("subagent_batches.enabled requires database.backend sqlite or postgres") raise RuntimeError("subagent_batches.enabled requires database.backend sqlite or postgres")
if batch_repo is not None: if batch_repo is not None:
from app.subagent_batches.continuation import BatchContinuations
continuations = BatchContinuations(app)
app.state.batch_continuations = continuations
batch_service = SubagentBatchService( batch_service = SubagentBatchService(
repository=batch_repo, repository=batch_repo,
config=subagent_batches_config, config=subagent_batches_config,
runtime_config=subagent_runtime_config, runtime_config=subagent_runtime_config,
extensions=getattr(app.state, "extensions", None), extensions=getattr(app.state, "extensions", None),
prepare_continuation=continuations.prepare,
dispatch_continuation=continuations.dispatch,
cancel_continuation=continuations.cancel,
) )
app.state.subagent_batch_service = batch_service app.state.subagent_batch_service = batch_service
if subagent_batches_config.enabled: if subagent_batches_config.enabled:
@ -1381,9 +1372,7 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
app.include_router(uploads.router) app.include_router(uploads.router)
# Thread cleanup API is mounted at /api/threads/{thread_id} # Thread cleanup API is mounted at /api/threads/{thread_id}
app.include_router(search.router)
app.include_router(threads.router) app.include_router(threads.router)
app.include_router(shares.router)
# Per-user activity feed (own prefix, so /api/threads/{thread_id} never captures it) # Per-user activity feed (own prefix, so /api/threads/{thread_id} never captures it)
app.include_router(thread_activity.router) app.include_router(thread_activity.router)

View File

@ -243,15 +243,7 @@ def build_artifact_archive(
reserved = frozenset(name.casefold() for name in {BROWSER_FRAMES_DIRNAME, TOOL_RESULTS_DIRNAME, *extra_reserved_dir_names}) reserved = frozenset(name.casefold() for name in {BROWSER_FRAMES_DIRNAME, TOOL_RESULTS_DIRNAME, *extra_reserved_dir_names})
members = [_member(root, path, reserved, deadline, root_components) for path in paths] members = [_member(root, path, reserved, deadline, root_components) for path in paths]
collision_keys = [unicodedata.normalize("NFC", member.entry).casefold() for member in members] collision_keys = [unicodedata.normalize("NFC", member.entry).casefold() for member in members]
collision_set = set(collision_keys) if len(collision_keys) != len(set(collision_keys)):
if len(collision_keys) != len(collision_set):
raise _reject()
implicit_dirs = set()
for key in collision_keys:
parts = key.split("/")
for i in range(1, len(parts)):
implicit_dirs.add("/".join(parts[:i]))
if collision_set & implicit_dirs:
raise _reject() raise _reject()
sizes = [member.initial.st_size for member in members] sizes = [member.initial.st_size for member in members]

View File

@ -63,7 +63,7 @@ class OIDCValidationError(OIDCError):
class OIDCUserInfoMismatch(OIDCError): class OIDCUserInfoMismatch(OIDCError):
"""UserInfo sub is missing or does not match ID token sub.""" """UserInfo sub does not match ID token sub."""
def _json_object(resp: httpx.Response, what: str) -> dict[str, Any]: def _json_object(resp: httpx.Response, what: str) -> dict[str, Any]:
@ -391,10 +391,8 @@ class OIDCService:
async def fetch_userinfo(self, metadata: OIDCMetadata, access_token: str, expected_sub: str) -> dict[str, Any]: async def fetch_userinfo(self, metadata: OIDCMetadata, access_token: str, expected_sub: str) -> dict[str, Any]:
"""Fetch userinfo from the UserInfo endpoint. """Fetch userinfo from the UserInfo endpoint.
The response is returned only when its ``sub`` equals ``expected_sub`` Validates that the ``sub`` claim matches ``expected_sub``
(from the ID token). OIDC Core 5.3.2 requires that check, and a (from the ID token) to prevent userinfo injection.
response without a ``sub`` cannot be tied to this login, so it raises
``OIDCUserInfoMismatch`` too instead of overriding the ID token claims.
""" """
if not metadata.userinfo_endpoint: if not metadata.userinfo_endpoint:
return {} return {}
@ -409,12 +407,7 @@ class OIDCService:
raise OIDCError(f"UserInfo fetch failed: {exc}") from exc raise OIDCError(f"UserInfo fetch failed: {exc}") from exc
userinfo = _json_object(resp, "UserInfo response") userinfo = _json_object(resp, "UserInfo response")
sub = userinfo.get("sub") if userinfo.get("sub") and userinfo["sub"] != expected_sub:
if sub is None or sub == "":
raise OIDCUserInfoMismatch("UserInfo response has no sub claim")
if not isinstance(sub, str):
raise OIDCUserInfoMismatch(f"UserInfo sub claim is not a string ({type(sub).__name__})")
if sub != expected_sub:
raise OIDCUserInfoMismatch("UserInfo sub does not match ID token sub") raise OIDCUserInfoMismatch("UserInfo sub does not match ID token sub")
return userinfo return userinfo

View File

@ -77,9 +77,6 @@ _PAT_ROUTE_RULES: tuple[tuple[frozenset[str], re.Pattern[str]], ...] = (
(frozenset({"GET", "PATCH", "DELETE"}), re.compile(r"^/api/projects/[^/]+$")), (frozenset({"GET", "PATCH", "DELETE"}), re.compile(r"^/api/projects/[^/]+$")),
(frozenset({"POST"}), re.compile(r"^/api/projects/[^/]+/(archive|restore)$")), (frozenset({"POST"}), re.compile(r"^/api/projects/[^/]+/(archive|restore)$")),
(frozenset({"GET"}), re.compile(r"^/api/projects/[^/]+/threads$")), (frozenset({"GET"}), re.compile(r"^/api/projects/[^/]+/threads$")),
(frozenset({"GET", "PATCH"}), re.compile(r"^/api/projects/[^/]+/memory$")),
(frozenset({"DELETE"}), re.compile(r"^/api/projects/[^/]+/memory/[^/]+$")),
(frozenset({"POST"}), re.compile(r"^/api/projects/[^/]+/memory/[^/]+/promote$")),
# Project document shelf (Phase 2 Slice B): enumerated per implemented # Project document shelf (Phase 2 Slice B): enumerated per implemented
# route — list/upload on the collection, content and trash on the item. # route — list/upload on the collection, content and trash on the item.
(frozenset({"GET", "POST"}), re.compile(r"^/api/projects/[^/]+/documents$")), (frozenset({"GET", "POST"}), re.compile(r"^/api/projects/[^/]+/documents$")),

View File

@ -6,7 +6,7 @@ from app.gateway.auth.models import User
class LastAdminRemainsError(RuntimeError): class LastAdminRemainsError(RuntimeError):
"""Raised when a role write would demote the last remaining active admin. """Raised when a role write would demote the last remaining admin.
Bootstrap lockout protection: raised inside the same serialized Bootstrap lockout protection: raised inside the same serialized
transaction that would perform the write, so the check-then-act window transaction that would perform the write, so the check-then-act window
@ -123,9 +123,8 @@ class UserRepository(ABC):
Raises: Raises:
UserNotFoundError: the target row does not exist. UserNotFoundError: the target row does not exist.
LastAdminRemainsError: the write would demote the only remaining LastAdminRemainsError: the write would demote the only remaining
active admin (the count and the write share one serialized admin (the count and the write share one serialized
transaction — two concurrent demotions cannot both pass). transaction — two concurrent demotions cannot both pass).
An already disabled admin may be assigned a different role.
""" """
raise NotImplementedError raise NotImplementedError

View File

@ -316,12 +316,11 @@ class SQLiteUserRepository(UserRepository):
row = await session.get(UserRow, user_id) row = await session.get(UserRow, user_id)
if row is None: if row is None:
raise UserNotFoundError(f"User {user_id} no longer exists") raise UserNotFoundError(f"User {user_id} no longer exists")
if row.system_role == "admin" and not row.disabled and system_role != "admin": if row.system_role == "admin" and system_role != "admin":
# Count ACTIVE admins only: a disabled admin cannot # Count ACTIVE admins only: a disabled admin cannot
# authenticate, so counting them would let "disable B, then # authenticate, so counting them would let "disable B, then
# demote self" (or a combined role+disable request) strand # demote self" (or a combined role+disable request) strand
# the deployment with zero usable management credentials. # the deployment with zero usable management credentials.
# Demoting an already disabled admin removes no active admin.
active_admins = await session.scalar(select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin", UserRow.disabled.is_(False))) active_admins = await session.scalar(select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin", UserRow.disabled.is_(False)))
if active_admins is None or active_admins <= 1: if active_admins is None or active_admins <= 1:
raise LastAdminRemainsError("cannot demote the last remaining admin") raise LastAdminRemainsError("cannot demote the last remaining admin")

View File

@ -10,7 +10,6 @@ Fine-grained permission checks remain in authz.py decorators.
""" """
import logging import logging
import re
from collections.abc import Callable from collections.abc import Callable
from fastapi import HTTPException, Request, Response from fastapi import HTTPException, Request, Response
@ -30,7 +29,7 @@ from app.gateway.auth_disabled import (
from app.gateway.authz import AuthContext, resolve_route_permissions from app.gateway.authz import AuthContext, resolve_route_permissions
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
from app.gateway.request_path import get_request_route_path from app.gateway.request_path import get_request_route_path
from deerflow.runtime.user_context import bind_authenticated_user, reset_current_user, set_current_user from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -61,11 +60,7 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
) )
def _is_public(path: str, method: str = "GET") -> bool: def _is_public(path: str) -> bool:
# A share token grants only these reads. Forks and any future write routes
# under /shares still pass through session authentication and permissions.
if method in {"GET", "HEAD"} and re.fullmatch(r"/api/shares/[A-Za-z0-9_-]{43}(?:/artifacts/.+)?", path):
return True
stripped = path.rstrip("/") stripped = path.rstrip("/")
if stripped in _PUBLIC_EXACT_PATHS: if stripped in _PUBLIC_EXACT_PATHS:
return True return True
@ -96,7 +91,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
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) -> Response:
if _is_public(get_request_route_path(request), request.method): if _is_public(get_request_route_path(request)):
return await call_next(request) return await call_next(request)
internal_user = None internal_user = None
@ -212,14 +207,6 @@ class AuthMiddleware(BaseHTTPMiddleware):
}, },
) )
# Browser extension pages are bound to the account that loaded their
# descriptor. Cookies can change in another tab before React observes it.
# This is a stale-view fence, not an authorization grant; CLI callers may
# omit it and all existing route/resource checks still apply.
expected_viewer = request.headers.get("x-deerflow-plugin-viewer")
if get_request_route_path(request).startswith("/api/extensions/") and expected_viewer is not None and expected_viewer != str(user.id):
return JSONResponse(status_code=409, content={"detail": "Account changed; reload this extension view."})
# Stamp both request.state.user (for the contextvar pattern) # Stamp both request.state.user (for the contextvar pattern)
# and request.state.auth (so @require_permission's "auth is # and request.state.auth (so @require_permission's "auth is
# None" branch short-circuits instead of running the entire # None" branch short-circuits instead of running the entire
@ -239,8 +226,6 @@ class AuthMiddleware(BaseHTTPMiddleware):
request.state.auth = AuthContext(user=user, permissions=permissions) request.state.auth = AuthContext(user=user, permissions=permissions)
token = set_current_user(user) token = set_current_user(user)
try: try:
verified_user_id = str(user.id) if auth_source in {AUTH_SOURCE_SESSION, AUTH_SOURCE_PAT} else None
with bind_authenticated_user(verified_user_id):
return await call_next(request) return await call_next(request)
finally: finally:
reset_current_user(token) reset_current_user(token)

View File

@ -191,10 +191,7 @@ def _get_route_authorization_config() -> AuthorizationConfig:
environments without a config.yaml), preserving legacy all-permissions behavior. environments without a config.yaml), preserving legacy all-permissions behavior.
``get_app_config()`` stats and hashes ``config.yaml`` and ``get_app_config()`` stats and hashes ``config.yaml`` and
``extensions_config.json`` on every call, so async request paths use ``extensions_config.json`` on every call, so async request paths use
:func:`_route_authorization_config_async`, and call the synchronous :func:`_route_authorization_config_async`.
helpers that read it (``resolve_model_authorization``,
``resolve_skill_authorization``, ``authorize_model_use``,
``authorize_sandbox_for_request``) through ``asyncio.to_thread``.
""" """
from deerflow.config.app_config import get_app_config from deerflow.config.app_config import get_app_config
@ -823,7 +820,7 @@ async def try_acquire_sandbox_for_request(
user = await get_optional_user_from_request(request) if request is not None else None user = await get_optional_user_from_request(request) if request is not None else None
if user is not None: if user is not None:
await asyncio.to_thread(authorize_sandbox_for_request, user, is_internal=_is_internal_caller(request, user), app_config=app_config) authorize_sandbox_for_request(user, is_internal=_is_internal_caller(request, user), app_config=app_config)
except SandboxAuthorizationError: except SandboxAuthorizationError:
logger.info("Sandbox sync skipped: sandbox execution not permitted for this caller (thread_id=%s)", thread_id) logger.info("Sandbox sync skipped: sandbox execution not permitted for this caller (thread_id=%s)", thread_id)
return SandboxRequestLease( return SandboxRequestLease(

View File

@ -194,11 +194,6 @@ class LarkAdapter:
class SkillAdapter: class SkillAdapter:
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]: async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
# The listing reports the enabled flag, which the storage layer fails
# closed on an unreadable extensions config; keep this read surface
# loud about the broken config instead of serving the degraded list.
# Blocking filesystem IO: always off the event loop, like the scan below.
await asyncio.to_thread(skills._assert_extensions_config_readable)
response = await asyncio.to_thread(lambda: skills._get_user_skill_storage(context.config).load_skills(enabled_only=False)) response = await asyncio.to_thread(lambda: skills._get_user_skill_storage(context.config).load_skills(enabled_only=False))
response = await skills._filter_visible_skills(context.request, context.config, response) response = await skills._filter_visible_skills(context.request, context.config, response)
return [ return [

View File

@ -6,26 +6,6 @@ from collections.abc import Sequence
from typing import Any from typing import Any
def task_interrupts_by_id(snapshot: Any) -> dict[str, list[Any]]:
"""Group non-empty task interrupts of one snapshot by task id."""
grouped: dict[str, list[Any]] = {}
for task in getattr(snapshot, "tasks", None) or ():
interrupts = list(getattr(task, "interrupts", ()) or ())
if interrupts:
grouped[task.id] = interrupts
return grouped
def is_hitl_approval_payload(value: Any) -> bool:
"""Whether an interrupt payload is a HITL tool-approval request."""
return isinstance(value, dict) and value.get("kind") == "hitl_approval"
def hitl_approval_payloads(grouped: dict[str, list[Any]]) -> list[dict[str, Any]]:
"""Flatten approval payloads from task_interrupts_by_id output."""
return [interrupt.value for interrupts in grouped.values() for interrupt in interrupts if is_hitl_approval_payload(interrupt.value)]
class CheckpointLineageError(RuntimeError): class CheckpointLineageError(RuntimeError):
"""Raised when a requested checkpoint ancestor cannot be resolved safely.""" """Raised when a requested checkpoint ancestor cannot be resolved safely."""

View File

@ -66,10 +66,7 @@ def build_context_usage_payload(*, token_count: int, max_context_tokens: int | N
async def build_context_usage(request: Request, thread_id: str, run_store: Any, user_id: str | None = None) -> dict[str, Any] | None: async def build_context_usage(request: Request, thread_id: str, run_store: Any, user_id: str | None = None) -> dict[str, Any] | None:
"""Return approximate usage for the latest materialized thread checkpoint.""" """Return approximate usage for the latest materialized thread checkpoint."""
try: try:
# ``get_config`` revalidates ``config.yaml`` and app_config = get_config()
# ``extensions_config.json`` on every call (stat + full-file hash), so
# reading it here would put that IO on the loop every thread view pays.
app_config = await asyncio.to_thread(get_config)
except HTTPException: except HTTPException:
return None return None

View File

@ -5,14 +5,13 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
from collections.abc import Awaitable, Callable, Mapping from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from app.gateway.conversation_reader import read_visible_message_page from app.gateway.conversation_reader import read_visible_message_page
from app.gateway.csrf_middleware import _normalize_origin, _request_origin
from deerflow.constants import CONVERSATION_TOOL_NAME, CONVERSATION_TOOL_USE from deerflow.constants import CONVERSATION_TOOL_NAME, CONVERSATION_TOOL_USE
from deerflow.utils.llm_text import strip_think_blocks from deerflow.utils.llm_text import strip_think_blocks
from deerflow.utils.thread_id import validate_thread_id from deerflow.utils.thread_id import validate_thread_id
@ -44,20 +43,13 @@ _BUDGET_TOO_SMALL = {
} }
def _source_id(reference: str, request_origin: str | None) -> str: def _source_id(reference: str, request_url: str) -> str:
"""URLs are local selectors on this DeerFlow's host, never network fetch targets. """URLs are same-origin local selectors, never network fetch targets."""
*request_origin* is the origin the client addressed, as the CSRF check
resolves it (forwarded host and port included). Only the host and port are
compared, not the scheme: behind a TLS proxy Gateway can see ``http`` for a
browser's ``https`` link, because uvicorn ignores ``X-Forwarded-Proto``
from an untrusted peer and a local nginx resends its own scheme.
"""
if "://" not in reference: if "://" not in reference:
return validate_thread_id(reference) return validate_thread_id(reference)
parsed = urlsplit(reference) parsed = urlsplit(reference)
origin = _normalize_origin(f"{parsed.scheme}://{parsed.netloc}") origin = urlsplit(request_url)
if origin is None or request_origin is None or urlsplit(origin).netloc != urlsplit(request_origin).netloc or parsed.query or parsed.fragment: if parsed.scheme not in {"http", "https"} or (parsed.scheme, parsed.netloc) != (origin.scheme, origin.netloc) or parsed.query or parsed.fragment:
raise ValueError("Use a thread ID or a conversation URL from this DeerFlow origin") raise ValueError("Use a thread ID or a conversation URL from this DeerFlow origin")
match = re.fullmatch(r"/workspace/(?:agents/[^/]+/)?chats/([^/]+)", parsed.path) match = re.fullmatch(r"/workspace/(?:agents/[^/]+/)?chats/([^/]+)", parsed.path)
if match is None: if match is None:
@ -65,39 +57,16 @@ def _source_id(reference: str, request_origin: str | None) -> str:
return validate_thread_id(match.group(1)) return validate_thread_id(match.group(1))
def _visible_text(row: dict, *, thread_id: str | None = None) -> tuple[str, str] | None: def _visible_text(row: dict) -> tuple[str, str] | None:
message = row.get("content") message = row.get("content")
if not isinstance(message, Mapping): if not isinstance(message, dict):
return None return None
role = message.get("type") or message.get("role") role = message.get("type") or message.get("role")
if not isinstance(role, str):
return None
role = {"human": "user", "ai": "assistant"}.get(role, role) role = {"human": "user", "ai": "assistant"}.get(role, role)
extra = message.get("additional_kwargs") extra = message.get("additional_kwargs") or {}
if extra is None:
extra = {}
elif not isinstance(extra, Mapping):
logger.warning(
"Ignoring malformed message additional_kwargs for thread %s at row seq %s: expected a mapping, got %s",
thread_id or "<unknown>",
row.get("seq", "<unknown>"),
type(extra).__name__,
)
extra = {}
if role not in {"user", "assistant"} or extra.get("hide_from_ui") or message.get("name") == "summary": if role not in {"user", "assistant"} or extra.get("hide_from_ui") or message.get("name") == "summary":
return None return None
metadata = row.get("metadata") if str((row.get("metadata") or {}).get("caller", "")).startswith(("middleware:", "subagent:")):
if metadata is None:
metadata = {}
elif not isinstance(metadata, Mapping):
logger.warning(
"Ignoring malformed conversation metadata for thread %s at row seq %s: expected a mapping, got %s",
thread_id or "<unknown>",
row.get("seq", "<unknown>"),
type(metadata).__name__,
)
metadata = {}
if str(metadata.get("caller", "")).startswith(("middleware:", "subagent:")):
return None return None
content = message.get("content") content = message.get("content")
if role == "user" and isinstance(extra.get("original_user_content"), str): if role == "user" and isinstance(extra.get("original_user_content"), str):
@ -189,8 +158,7 @@ def prepare_conversation_reader(
if auth is None or not auth.is_authenticated or not auth.has_permission("runs", "read") or not user_id: if auth is None or not auth.is_authenticated or not auth.has_permission("runs", "read") or not user_id:
raise HTTPException(status_code=403, detail="Permission denied: runs:read") raise HTTPException(status_code=403, detail="Permission denied: runs:read")
try: try:
request_origin = _request_origin(request) ids = tuple(dict.fromkeys(_source_id(reference, str(request.url)) for reference in references))
ids = tuple(dict.fromkeys(_source_id(reference, request_origin) for reference in references))
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
return _prepare_owner_bound_conversation_reader(ids, user_id=user_id, run_context=run_context, run_manager=run_manager, app_config=app_config) return _prepare_owner_bound_conversation_reader(ids, user_id=user_id, run_context=run_context, run_manager=run_manager, app_config=app_config)
@ -260,7 +228,7 @@ def _prepare_owner_bound_conversation_reader(
found: dict[int, tuple[str, str, int]] = {} found: dict[int, tuple[str, str, int]] = {}
def include_target(row: dict) -> bool: def include_target(row: dict) -> bool:
parsed = _visible_text(row, thread_id=thread_id) if row["seq"] == message_seq else None parsed = _visible_text(row) if row["seq"] == message_seq else None
if parsed is None: if parsed is None:
return False return False
role, text = parsed role, text = parsed
@ -305,7 +273,7 @@ def _prepare_owner_bound_conversation_reader(
parsed_text: dict[int, tuple[str, str]] = {} parsed_text: dict[int, tuple[str, str]] = {}
def include_message(row: dict) -> bool: def include_message(row: dict) -> bool:
parsed = _visible_text(row, thread_id=thread_id) parsed = _visible_text(row)
if parsed is None: if parsed is None:
return False return False
role, text = parsed role, text = parsed

View File

@ -8,7 +8,7 @@ access from a model argument or an ambient HTTP request.
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Callable, Mapping from collections.abc import Callable
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
@ -70,19 +70,8 @@ def _message_type(message: Any) -> str | None:
return str(value) if value else None return str(value) if value else None
def _is_thread_history_hidden_message_row(row: dict[str, Any], *, thread_id: str | None = None) -> bool: def _is_thread_history_hidden_message_row(row: dict[str, Any]) -> bool:
metadata = row.get("metadata") caller = str((row.get("metadata") or {}).get("caller", ""))
if metadata is None:
metadata = {}
elif not isinstance(metadata, Mapping):
logger.warning(
"Ignoring malformed conversation metadata for thread %s at row seq %s: expected a mapping, got %s",
thread_id or "<unknown>",
row.get("seq", "<unknown>"),
type(metadata).__name__,
)
metadata = {}
caller = str(metadata.get("caller", ""))
return caller.startswith("middleware:") or (caller.startswith("subagent:") and _message_type(row.get("content")) == "ai") return caller.startswith("middleware:") or (caller.startswith("subagent:") and _message_type(row.get("content")) == "ai")
@ -121,7 +110,7 @@ async def scan_visible_thread_messages(
if before_seq is not None and row["seq"] >= before_seq: if before_seq is not None and row["seq"] >= before_seq:
reached_before_bound = True reached_before_bound = True
break break
if (not include_middleware and _is_thread_history_hidden_message_row(row, thread_id=thread_id)) or row.get("run_id") in hidden_run_ids or (message_filter is not None and not message_filter(row)): if (not include_middleware and _is_thread_history_hidden_message_row(row)) or row.get("run_id") in hidden_run_ids or (message_filter is not None and not message_filter(row)):
continue continue
visible.append(row) visible.append(row)
if len(visible) == needed: if len(visible) == needed:
@ -148,7 +137,7 @@ async def scan_visible_thread_messages(
break break
_validate_message_scan_rows(raw, thread_id=thread_id, scan_before=scan_before, scan_after=None) _validate_message_scan_rows(raw, thread_id=thread_id, scan_before=scan_before, scan_after=None)
for row in reversed(raw): for row in reversed(raw):
if (not include_middleware and _is_thread_history_hidden_message_row(row, thread_id=thread_id)) or row.get("run_id") in hidden_run_ids or (message_filter is not None and not message_filter(row)): if (not include_middleware and _is_thread_history_hidden_message_row(row)) or row.get("run_id") in hidden_run_ids or (message_filter is not None and not message_filter(row)):
continue continue
visible_desc.append(row) visible_desc.append(row)
if len(visible_desc) == needed: if len(visible_desc) == needed:

View File

@ -20,7 +20,6 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import tempfile
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path from pathlib import Path
@ -385,27 +384,6 @@ def _validate_agent_storage(config: AppConfig) -> None:
) )
def _probe_managed_subagent_file_storage() -> None:
"""Warn early if this volume cannot support create-only publication."""
from deerflow.config.paths import get_paths
root = get_paths().managed_subagents_dir
try:
root.mkdir(parents=True, exist_ok=True)
# A private directory keeps probe names out of the JSON catalog and
# lets independent Gateway workers probe without touching each other.
with tempfile.TemporaryDirectory(prefix=".hardlink-probe-", dir=root) as scratch:
source = Path(scratch) / "source"
source.touch()
os.link(source, Path(scratch) / "linked")
except OSError as exc:
logger.warning(
"Managed subagent hard-link probe failed for %s: %s. Managed subagent creation may fail on this volume. Use a volume with working hard links or set agent_storage.backend='db' with SQLite/Postgres.",
root,
exc,
)
def _validate_login_throttle_storage(config: AppConfig) -> None: def _validate_login_throttle_storage(config: AppConfig) -> None:
"""Warn when a multi-process deployment counts login failures per process. """Warn when a multi-process deployment counts login failures per process.
@ -810,8 +788,6 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
# Reject agent_storage.backend='db' on a non-durable database, and warn on # Reject agent_storage.backend='db' on a non-durable database, and warn on
# node-divergent file storage under multi-worker Postgres. # node-divergent file storage under multi-worker Postgres.
_validate_agent_storage(startup_config) _validate_agent_storage(startup_config)
if getattr(getattr(startup_config, "agent_storage", None), "backend", None) == "file":
await asyncio.to_thread(_probe_managed_subagent_file_storage)
# Warn when login lockouts stay per-process under several Gateway processes. # Warn when login lockouts stay per-process under several Gateway processes.
_validate_login_throttle_storage(startup_config) _validate_login_throttle_storage(startup_config)
# IM chat-to-thread bindings need no companion warning: ``resolve_channel_store`` # IM chat-to-thread bindings need no companion warning: ``resolve_channel_store``
@ -889,9 +865,6 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
# Initialize repositories — one get_session_factory() call for all. # Initialize repositories — one get_session_factory() call for all.
sf = get_session_factory() sf = get_session_factory()
from deerflow.approvals.store import ApprovalStore
app.state.approval_store = ApprovalStore(sf)
# The login throttle store is resolved once per process from the startup # The login throttle store is resolved once per process from the startup
# snapshot and the engine above (auth.local.throttle_storage is # snapshot and the engine above (auth.local.throttle_storage is
@ -925,15 +898,6 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
run_events_config = getattr(config, "run_events", None) run_events_config = getattr(config, "run_events", None)
app.state.run_events_config = run_events_config app.state.run_events_config = run_events_config
app.state.run_event_store = make_run_event_store(run_events_config) app.state.run_event_store = make_run_event_store(run_events_config)
if sf is not None:
from deerflow.persistence.run.producer import validate_producer_fencing
# An activated database also forbids a memory/JSONL downgrade.
await validate_producer_fencing(
sf,
mode=getattr(run_events_config, "producer_fencing", "off"),
cutover_id=getattr(run_events_config, "producer_cutover_id", None),
)
from deerflow.runtime.events.terminal import RecoveryTerminalPublisher from deerflow.runtime.events.terminal import RecoveryTerminalPublisher
app.state.recovery_terminal_publisher = RecoveryTerminalPublisher(app.state.run_event_store, app.state.run_store) app.state.recovery_terminal_publisher = RecoveryTerminalPublisher(app.state.run_event_store, app.state.run_store)
@ -1272,31 +1236,17 @@ def _selected_checkpoint_ids(config: dict[str, Any]) -> frozenset[str]:
return frozenset(selected) return frozenset(selected)
def _run_completion_hook(*, scheduled_task_service: Any | None, batch_continuations: Any | None = None): def _run_completion_hook(*, scheduled_task_service: Any | None):
"""Notify the scheduler after ordinary run finalization.""" """Notify the scheduler after ordinary run finalization."""
async def handle(record: Any) -> None: async def handle(record: Any) -> None:
# Scheduler persistence is best-effort: a store outage must not skip the
# batch cancellation below, or claimed follow-ups of hard-stopped runs
# stay in `dispatching` forever (#7969).
try:
if scheduled_task_service is not None: if scheduled_task_service is not None:
await scheduled_task_service.handle_run_completion(record) await scheduled_task_service.handle_run_completion(record)
except Exception:
logger.warning("Scheduled task completion hook failed for run %s (non-fatal)", record.run_id, exc_info=True)
if batch_continuations is not None:
await batch_continuations.source_finished(record)
return handle return handle
def get_agent_runs(request: Request):
"""Return the host-bound AgentRuns capability for *request*, or ``None``."""
host = getattr(request.app.state, "agent_runs_host", None)
# Internal/channel and PAT runs deliberately receive no retained delegation.
return host.bind(request) if host is not None else None
def get_run_context(request: Request) -> RunContext: def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons. """Build a :class:`RunContext` from ``app.state`` singletons.
@ -1306,12 +1256,10 @@ def get_run_context(request: Request) -> RunContext:
``event_store`` / ``run_events_config`` pair stays frozen to the snapshot ``event_store`` / ``run_events_config`` pair stays frozen to the snapshot
captured in :func:`langgraph_runtime` so callers never see a store bound to captured in :func:`langgraph_runtime` so callers never see a store bound to
one backend paired with a config pointing at another. one backend paired with a config pointing at another.
Loading ``app_config`` stats and hashes ``config.yaml`` (and reparses it
after an edit), so async callers build the context with
``asyncio.to_thread``.
""" """
agent_runs = get_agent_runs(request) host = getattr(request.app.state, "agent_runs_host", None)
# Internal/channel and PAT runs deliberately receive no retained delegation.
agent_runs = host.bind(request) if host is not None else None
checkpointer = get_checkpointer(request) checkpointer = get_checkpointer(request)
scheduled_task_service = getattr(request.app.state, "scheduled_task_service", None) scheduled_task_service = getattr(request.app.state, "scheduled_task_service", None)
return RunContext( return RunContext(
@ -1324,13 +1272,11 @@ def get_run_context(request: Request) -> RunContext:
checkpoint_snapshot_frequency=getattr(request.app.state, "checkpoint_snapshot_frequency", None), checkpoint_snapshot_frequency=getattr(request.app.state, "checkpoint_snapshot_frequency", None),
thread_store=get_thread_store(request), thread_store=get_thread_store(request),
mcp_task_repo=getattr(request.app.state, "mcp_task_repo", None), mcp_task_repo=getattr(request.app.state, "mcp_task_repo", None),
approval_store=getattr(request.app.state, "approval_store", None),
app_config=get_config(), app_config=get_config(),
extensions=getattr(request.app.state, "extensions", None), extensions=getattr(request.app.state, "extensions", None),
on_run_admitted=_run_admission_hook(checkpointer=checkpointer), on_run_admitted=_run_admission_hook(checkpointer=checkpointer),
on_run_completed=_run_completion_hook( on_run_completed=_run_completion_hook(
scheduled_task_service=scheduled_task_service, scheduled_task_service=scheduled_task_service,
batch_continuations=getattr(request.app.state, "batch_continuations", None),
), ),
) )

View File

@ -18,7 +18,7 @@ from pydantic import TypeAdapter, ValidationError
from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_SESSION, get_auth_disabled_user, is_auth_disabled from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_SESSION, get_auth_disabled_user, is_auth_disabled
from app.gateway.authz import AuthContext, resolve_route_permissions from app.gateway.authz import AuthContext, resolve_route_permissions
from deerflow.runtime.run_origin import DEERFLOW_ORIGIN_KEY, make_origin from deerflow.runtime.run_origin import make_origin
from deerflow.runtime.user_context import reset_current_user, set_current_user from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.thread_id import ThreadId from deerflow.utils.thread_id import ThreadId
@ -73,32 +73,15 @@ class GatewayAgentRunsHost:
return None return None
return _BoundAgentRuns(self, str(user.id), user.token_version, source, frozenset(auth.permissions)) return _BoundAgentRuns(self, str(user.id), user.token_version, source, frozenset(auth.permissions))
def capture_batch_authority(self, runs, *, user_id: str) -> dict:
"""Capture only a native batch's existing host grant; never browser input."""
if not isinstance(runs, _BoundAgentRuns) or runs._host is not self or runs._user_id != user_id or not self.active:
raise AgentRunError(403, "Batch follow-up requires its originating Agent run authority")
return {"token_version": runs._token_version, "source": runs._source, "permissions": sorted(runs._permissions)}
async def restore_batch_authority(self, *, user_id: str, grant: dict):
"""Restore a persisted native batch grant with its original permission ceiling."""
source, version, permissions = grant.get("source"), grant.get("token_version"), grant.get("permissions")
if source not in (AUTH_SOURCE_SESSION, AUTH_SOURCE_AUTH_DISABLED) or type(version) is not int or not isinstance(permissions, list) or any(not isinstance(value, str) for value in permissions):
raise AgentRunError(403, "Invalid batch follow-up authority")
user = get_auth_disabled_user() if source == AUTH_SOURCE_AUTH_DISABLED and is_auth_disabled() else await self.load_user(user_id) if source == AUTH_SOURCE_SESSION and self.load_user is not None else None
if user is None or str(user.id) != user_id or user.token_version != version or user.needs_setup or getattr(user, "disabled", False):
raise AgentRunError(403, "Batch follow-up authorization revoked")
return _BoundAgentRuns(self, user_id, version, source, frozenset(permissions), origin=make_origin("subagent_batch"))
class _BoundAgentRuns: class _BoundAgentRuns:
def __init__(self, host, user_id: str, token_version: int, source: str, permissions: frozenset[str], plugin_namespace: str | None = None, origin: dict | None = None): def __init__(self, host, user_id: str, token_version: int, source: str, permissions: frozenset[str], plugin_namespace: str | None = None):
self._host = host self._host = host
self._user_id = user_id self._user_id = user_id
self._token_version = token_version self._token_version = token_version
self._source = source self._source = source
self._permissions = permissions self._permissions = permissions
self._plugin_namespace = plugin_namespace self._plugin_namespace = plugin_namespace
self._origin = origin
def for_plugin(self, namespace: str): def for_plugin(self, namespace: str):
if not isinstance(namespace, str) or not namespace or len(namespace) > 96 or ":" in namespace: if not isinstance(namespace, str) or not namespace or len(namespace) > 96 or ":" in namespace:
@ -130,7 +113,7 @@ class _BoundAgentRuns:
request.state.auth = AuthContext(user, list(permissions)) request.state.auth = AuthContext(user, list(permissions))
# Host-set, never HTTP input: threads and runs created through this # Host-set, never HTTP input: threads and runs created through this
# handle are marked as extension work (activity feed, unread state). # handle are marked as extension work (activity feed, unread state).
request.state.run_origin = self._origin or make_origin("extension", namespace=self._plugin_namespace) request.state.run_origin = make_origin("extension", namespace=self._plugin_namespace)
token = set_current_user(user) token = set_current_user(user)
try: try:
if thread_id is not None: if thread_id is not None:
@ -163,56 +146,13 @@ class _BoundAgentRuns:
raise AgentRunError(409, "Host assembly evidence unavailable") raise AgentRunError(409, "Host assembly evidence unavailable")
return copy.deepcopy(evidence) return copy.deepcopy(evidence)
async def validate_artifact_source(self, *, thread_id, run_id, idempotency_key): async def read_artifact(self, *, thread_id, path):
from app.gateway.deps import get_run_manager from app.gateway.extension_artifacts import read_output_snapshot
from app.gateway.extension_artifacts import validate_capture_support
from app.gateway.routers.thread_runs import _scope_http_run_idempotency_key
from deerflow.utils.file_io import await_drained
if self._plugin_namespace is None or not isinstance(idempotency_key, str) or not 1 <= len(idempotency_key) <= 200:
raise AgentRunError(422, "Artifact registration requires a plugin-scoped execution key")
async with self._request("runs:read", thread_id) as request:
run = await get_run_manager(request).get(run_id, user_id=self._user_id)
key = _scope_http_run_idempotency_key(request, thread_id, f"extension:{self._plugin_namespace}:{idempotency_key}")
origin = (run.metadata or {}).get(DEERFLOW_ORIGIN_KEY) if run is not None else None
if run is None or run.thread_id != thread_id or run.status != "running" or run.idempotency_key != key or origin != make_origin("extension", namespace=self._plugin_namespace):
raise AgentRunError(422, "Register files during the executing team run")
async with self._request("threads:read", thread_id) as request:
await await_drained(validate_capture_support(request, self._user_id, thread_id))
async def read_artifact(self, *, thread_id, path, expected_checkpoint_id=None):
from app.gateway.extension_artifacts import read_snapshot
from app.gateway.routers.artifacts import reserve_artifact_write from app.gateway.routers.artifacts import reserve_artifact_write
from deerflow.runtime.runs.manager import ConflictError
from deerflow.utils.file_io import await_drained
async with self._request("threads:read", thread_id) as request: async with self._request("threads:read", thread_id) as request:
try:
async with reserve_artifact_write(request, thread_id, user_id=self._user_id): async with reserve_artifact_write(request, thread_id, user_id=self._user_id):
if expected_checkpoint_id is not None: return await asyncio.to_thread(read_output_snapshot, self._user_id, thread_id, path)
if not isinstance(expected_checkpoint_id, str) or not expected_checkpoint_id:
raise AgentRunError(422, "Invalid producing checkpoint")
state = await self.get_state(thread_id=thread_id)
if state.get("checkpoint_id") != expected_checkpoint_id:
raise AgentRunError(412, "Producing conversation changed before artifact capture")
return await await_drained(read_snapshot(request, self._user_id, thread_id, path))
except ConflictError as exc:
raise AgentRunError(409, "Producing conversation is busy") from exc
async def import_artifact(self, *, thread_id, filename, content):
from app.gateway.extension_artifacts import import_snapshot
from app.gateway.routers.artifacts import reserve_artifact_write
from deerflow.runtime.runs.manager import ConflictError
from deerflow.utils.file_io import await_drained
if self._plugin_namespace is None:
raise AgentRunError(422, "Artifact import requires a plugin-scoped capability")
async with self._request("threads:write", thread_id) as request:
try:
async with reserve_artifact_write(request, thread_id, user_id=self._user_id):
return await await_drained(import_snapshot(request, self._user_id, thread_id, self._plugin_namespace, filename, content))
except ConflictError as exc:
raise AgentRunError(409, "Recipient conversation is busy") from exc
async def configuration_revision(self, *, assistant_ids: list[str], durable: bool = False) -> str: async def configuration_revision(self, *, assistant_ids: list[str], durable: bool = False) -> str:
from deerflow.config.agents_config import load_agent_config, load_agent_soul from deerflow.config.agents_config import load_agent_config, load_agent_soul
@ -258,8 +198,7 @@ class _BoundAgentRuns:
response = await create_thread(ThreadCreateRequest(assistant_id=assistant_id, thread_id=thread_id, metadata=_json_object(metadata) or {}), request) response = await create_thread(ThreadCreateRequest(assistant_id=assistant_id, thread_id=thread_id, metadata=_json_object(metadata) or {}), request)
return response.thread_id return response.thread_id
async def _start(self, *, thread_id, input=None, resume=None, context=None, idempotency_key=None, execution_fence=None, artifact_imports=None): async def _start(self, *, thread_id, input=None, resume=None, context=None, idempotency_key=None, execution_fence=None):
from app.gateway.extension_artifacts import validate_import_receipts
from app.gateway.routers.thread_runs import create_run from app.gateway.routers.thread_runs import create_run
from app.gateway.run_models import RunCreateRequest from app.gateway.run_models import RunCreateRequest
from app.gateway.services import resolve_thread_assistant_id from app.gateway.services import resolve_thread_assistant_id
@ -270,16 +209,9 @@ class _BoundAgentRuns:
run_input = _json_object(input) run_input = _json_object(input)
run_context = _json_object(context) run_context = _json_object(context)
command = _json_object({"resume": resume}) if resume is not None else None command = _json_object({"resume": resume}) if resume is not None else None
imports = _json_object({"items": artifact_imports})["items"] if artifact_imports is not None else None
if imports is not None:
if self._plugin_namespace is None or resume is not None:
raise AgentRunError(422, "Artifact admission requires a plugin-scoped new run")
validate_import_receipts(imports)
if run_context is not None and any(key in run_context for key in ("agent_name", "is_bootstrap")): if run_context is not None and any(key in run_context for key in ("agent_name", "is_bootstrap")):
raise AgentRunError(422, "Agent identity is bound to the thread") raise AgentRunError(422, "Agent identity is bound to the thread")
async with self._request("runs:create", thread_id) as request: async with self._request("runs:create", thread_id) as request:
if imports:
request.state.extension_artifact_imports = imports
if execution_fence is not None: if execution_fence is not None:
if not isinstance(execution_fence, ExecutionFence) or self._plugin_namespace is None or execution_fence.namespace != self._plugin_namespace: if not isinstance(execution_fence, ExecutionFence) or self._plugin_namespace is None or execution_fence.namespace != self._plugin_namespace:
raise AgentRunError(422, "Execution fence must belong to this plugin") raise AgentRunError(422, "Execution fence must belong to this plugin")
@ -293,30 +225,8 @@ class _BoundAgentRuns:
response = await create_run(thread_id, body, request, idempotency_key=f"extension:{self._plugin_namespace or ''}:{idempotency_key}" if idempotency_key is not None else None) response = await create_run(thread_id, body, request, idempotency_key=f"extension:{self._plugin_namespace or ''}:{idempotency_key}" if idempotency_key is not None else None)
return _view(response) return _view(response)
async def start(self, *, thread_id, input, context=None, idempotency_key=None, execution_fence=None, artifact_imports=None) -> AgentRun: async def start(self, *, thread_id, input, context=None, idempotency_key=None, execution_fence=None) -> AgentRun:
return await self._start(thread_id=thread_id, input=input, context=context, idempotency_key=idempotency_key, execution_fence=execution_fence, artifact_imports=artifact_imports) return await self._start(thread_id=thread_id, input=input, context=context, idempotency_key=idempotency_key, execution_fence=execution_fence)
async def start_batch_followup(self, *, thread_id, batch_id, input, context, config, execution_fence, goal_wakeup=None):
"""Native single follow-up; the existing SQL admission fences cancellation."""
from app.gateway.run_models import RunCreateRequest
from app.gateway.services import resolve_thread_assistant_id, start_run
from deerflow.subagents.batch_runtime import BATCH_CONTINUATION_NAMESPACE, batch_continuation_key
if self._origin != make_origin("subagent_batch") or execution_fence.namespace != BATCH_CONTINUATION_NAMESPACE or execution_fence.resource_id != batch_id:
raise AgentRunError(403, "Invalid native batch continuation")
async with self._request("runs:create", thread_id) as request:
request.state.extension_execution_fence = execution_fence
assistant_id = await resolve_thread_assistant_id(request, thread_id, fail_closed=True)
body = RunCreateRequest(
assistant_id=assistant_id,
input=_json_object(input),
context=_json_object(context),
config=_json_object(config),
metadata={"batch_goal_wakeup": _json_object(goal_wakeup)} if goal_wakeup is not None else None,
on_disconnect="continue",
)
record = await start_run(body, thread_id, request, idempotency_key=batch_continuation_key(batch_id), require_existing_thread=True)
return _view(record)
async def resume(self, *, thread_id, resume, idempotency_key=None) -> AgentRun: async def resume(self, *, thread_id, resume, idempotency_key=None) -> AgentRun:
if resume is None: if resume is None:
@ -335,40 +245,6 @@ class _BoundAgentRuns:
async with self._request("threads:read", thread_id) as request: async with self._request("threads:read", thread_id) as request:
return (await get_thread_state(thread_id, request)).model_dump(mode="json") return (await get_thread_state(thread_id, request)).model_dump(mode="json")
async def send_input(self, *, thread_id, run_id, text, mode, request_id, context=None):
from app.gateway.routers.thread_runs import create_run_input
from app.gateway.run_inputs import RunInputRequest
async with self._request("runs:create", thread_id) as request:
body = RunInputRequest(request_id=request_id, text=text, mode=mode, context=_json_object(context) or {})
body = body.model_copy(update={"request_id": hashlib.sha256(f"{self._plugin_namespace or 'host'}:{body.request_id}".encode()).hexdigest()})
return await create_run_input(thread_id, run_id, body, request)
async def dispatch_input(self, *, thread_id, run_id, input_id):
from app.gateway.run_inputs import dispatch_one_input
async with self._request("runs:create", thread_id) as request:
return await dispatch_one_input(thread_id, run_id, input_id, request)
async def list_inputs(self, *, thread_id):
from app.gateway.routers.thread_runs import get_thread_inputs
async with self._request("threads:read", thread_id) as request:
return await get_thread_inputs(thread_id, request)
async def authorize_control(self, *, thread_id, run_id, clear_goal=False):
from app.gateway.deps import get_run_manager
async with self._request("runs:cancel", thread_id) as request:
run = await get_run_manager(request).get(run_id, user_id=self._user_id)
if run is None or run.thread_id != thread_id:
raise AgentRunError(404, "Run unavailable")
if run.status not in ("pending", "running"):
raise AgentRunError(409, "Run already stopped")
if clear_goal:
async with self._request("threads:write", thread_id):
pass
async def cancel(self, *, thread_id, run_id, execution_fence=None) -> None: async def cancel(self, *, thread_id, run_id, execution_fence=None) -> None:
from app.gateway.routers.thread_runs import cancel_run from app.gateway.routers.thread_runs import cancel_run

View File

@ -1,243 +1,24 @@
"""Bounded output capture without symlink traversal or mutable-path rereads.""" """Bounded output capture without symlink traversal or mutable-path rereads."""
import hashlib
import logging
import os import os
import re
import stat import stat
import uuid
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
from typing import Any
from deerflow_extension_api import AgentRunError from deerflow_extension_api import AgentRunError
from fastapi import Request
from app.gateway.authz import try_acquire_sandbox_for_request
from app.gateway.path_utils import normalize_outputs_virtual_path from app.gateway.path_utils import normalize_outputs_virtual_path
from deerflow.config.paths import get_paths from deerflow.config.paths import get_paths
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
MAX_BYTES = 2 * 1024 * 1024 MAX_BYTES = 2 * 1024 * 1024
logger = logging.getLogger(__name__)
def validate_import_receipts(values: object) -> None: def read_output_snapshot(user_id, thread_id, path):
"""Reject malformed receipts or more than four bounded imports with 422."""
if not isinstance(values, list) or len(values) > 4:
raise AgentRunError(422, "Choose at most four imported artifacts")
for value in values:
if not isinstance(value, dict) or set(value) != {"filename", "path", "sha256", "size"}:
raise AgentRunError(422, "Invalid artifact import receipt")
filename = value["filename"]
if not isinstance(filename, str) or not filename or filename in (".", "..") or any(c in filename for c in "/\\") or any(ord(c) < 32 for c in filename):
raise AgentRunError(422, "Invalid artifact import filename")
try:
valid = len(filename.encode()) <= 255
except UnicodeError:
valid = False
if (
not valid
or value["path"] != "/mnt/user-data/uploads/" + filename
or not isinstance(value["sha256"], str)
or re.fullmatch(r"[0-9a-f]{64}", value["sha256"]) is None
or type(value["size"]) is not int
or not 0 <= value["size"] <= MAX_BYTES
):
raise AgentRunError(422, "Invalid artifact import receipt")
@asynccontextmanager
async def snapshot_sandbox(request: Request, user_id: str, thread_id: str) -> AsyncIterator[Sandbox | None]:
"""Yield a remote sandbox lease, or None for host-mounted thread data.
Permission denial raises 403 and an unavailable sandbox raises 503. Release
drains on cancellation; exact snapshots never fall back after a denied lease.
"""
from app.gateway.routers.uploads import _uses_thread_data_mounts
from deerflow.config.app_config import get_app_config
from deerflow.utils.file_io import await_drained, run_file_io
provider = get_sandbox_provider()
lease = None
try:
if not _uses_thread_data_mounts(provider):
lease = await try_acquire_sandbox_for_request(request, provider, thread_id, user_id=user_id, app_config=await run_file_io(get_app_config), owner_prefix="gateway:artifact-snapshot", release_on_last=True)
if lease.denied:
raise AgentRunError(403, "Sandbox permission denied for artifact snapshots")
if lease.sandbox is None:
raise AgentRunError(503, "Sandbox unavailable for artifact snapshots")
yield lease.sandbox if lease is not None else None
finally:
if lease is not None:
await await_drained(lease.release())
async def validate_capture_support(request: Request, user_id: str, thread_id: str) -> None:
"""Require POSIX capture and probe remote support before registration.
Raise 422 for unsupported capture, retaining lease authorization failures.
"""
from deerflow.sandbox.artifact_snapshot import validate_snapshot_support
from deerflow.utils.file_io import run_file_io
if os.name != "posix":
raise AgentRunError(422, "Artifact capture requires POSIX")
async with snapshot_sandbox(request, user_id, thread_id) as sandbox:
if sandbox is not None:
await run_file_io(validate_snapshot_support, sandbox)
def validate_import(filename: object, content: object) -> None:
"""Reject unsafe filenames, non-POSIX hosts or content over 2 MiB with 422."""
if os.name != "posix" or not isinstance(filename, str) or not filename or filename in (".", "..") or any(c in filename for c in "/\\") or any(ord(c) < 32 for c in filename):
raise AgentRunError(422, "Use a plain artifact filename on POSIX")
try:
valid = len(filename.encode()) <= 160
except UnicodeError:
valid = False
if not valid or not isinstance(content, bytes) or len(content) > MAX_BYTES:
raise AgentRunError(422, "Artifact filename is too long or content exceeds 2 MiB")
def write_import_snapshot(user_id: str, thread_id: str, namespace: str, filename: str, content: bytes) -> dict[str, Any]:
"""Publish a confined content-addressed upload and return its import receipt.
Reuse only identical regular files; retain existing edits and raise 422 for
unsafe or changed destinations. Run filesystem work off the event loop.
"""
validate_import(filename, content)
digest = hashlib.sha256(content).hexdigest()
identity = hashlib.sha256(namespace.encode() + b"\0" + filename.encode() + b"\0" + content).hexdigest()[:32]
destination = f"artifact-{identity}-{filename}"
paths = get_paths()
root = paths.base_dir.resolve()
relative = paths.sandbox_uploads_dir(thread_id, user_id=user_id).relative_to(paths.base_dir)
directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
temporary = ".upload-" + uuid.uuid4().hex + ".part"
created = False
try:
for part in relative.parts:
try:
os.mkdir(part, mode=0o755, dir_fd=directory)
except FileExistsError:
pass
next_dir = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
os.close(directory)
directory = next_dir
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
created = True
with os.fdopen(fd, "wb") as target:
target.write(content)
target.flush()
os.fsync(target.fileno())
os.fchmod(target.fileno(), 0o644)
try:
os.link(temporary, destination, src_dir_fd=directory, dst_dir_fd=directory, follow_symlinks=False)
except FileExistsError:
pass
os.unlink(temporary, dir_fd=directory)
created = False
fd = os.open(destination, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
with os.fdopen(fd, "rb") as existing:
before = os.fstat(existing.fileno())
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or before.st_size != len(content):
raise AgentRunError(422, "Imported artifact was changed; existing file retained")
data = existing.read(MAX_BYTES + 1)
after = os.fstat(existing.fileno())
if data != content or (before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns):
raise AgentRunError(422, "Imported artifact was changed; existing file retained")
return {"filename": destination, "path": "/mnt/user-data/uploads/" + destination, "sha256": digest, "size": len(content)}
except OSError as exc:
raise AgentRunError(422, "Artifact import destination unavailable or unsafe") from exc
finally:
try:
if created:
os.unlink(temporary, dir_fd=directory)
except OSError:
logger.warning("Failed to clean up an abandoned artifact import staging file", exc_info=True)
finally:
os.close(directory)
async def import_snapshot(request: Request, user_id: str, thread_id: str, namespace: str, filename: str, content: bytes) -> dict[str, Any]:
"""Return a receipt after publishing identical host and remote uploads.
Retain existing edits; unsafe/changed snapshots raise 422 and lease failures
keep their authorization/availability status. Cancellation drains file I/O.
"""
from deerflow.sandbox.artifact_snapshot import publish_snapshot
from deerflow.utils.file_io import run_file_io
validate_import(filename, content)
async with snapshot_sandbox(request, user_id, thread_id) as sandbox:
result = await run_file_io(write_import_snapshot, user_id, thread_id, namespace, filename, content)
if sandbox is not None:
await run_file_io(publish_snapshot, sandbox, result["filename"], content)
return result
async def read_snapshot(request: Request, user_id: str, thread_id: str, path: str, *, max_bytes: int = MAX_BYTES) -> bytes:
"""Capture bounded output bytes through host storage or an authorized lease.
Non-output paths raise HTTP 400; unsafe files raise 422 and host mutation
during capture raises 409. Remote snapshot and lease errors retain their
status. Cancellation drains I/O.
"""
from deerflow.sandbox.artifact_snapshot import read_snapshot as read_remote
from deerflow.utils.file_io import run_file_io
path = normalize_outputs_virtual_path(path)
async with snapshot_sandbox(request, user_id, thread_id) as sandbox:
if sandbox is not None:
return await run_file_io(read_remote, sandbox, path.removeprefix("/mnt/user-data/outputs/"), max_bytes=max_bytes)
return await run_file_io(read_output_snapshot, user_id, thread_id, path, max_bytes=max_bytes)
async def verify_imports(request: Request, user_id: str, thread_id: str, receipts: list[dict[str, Any]], *, lifetime: AsyncExitStack | None = None) -> None:
"""Verify receipt bytes after new admission and before Agent construction.
A supplied lifetime retains the remote lease through execution; otherwise it
ends here. Missing, changed or inaccessible copies raise an artifact-specific
422. Cancellation drains file I/O before the enclosing lease is released.
"""
from deerflow.sandbox.artifact_snapshot import verify_snapshot
from deerflow.utils.file_io import run_file_io
try:
async with AsyncExitStack() as temporary:
# A run retains its remote client through execution: releasing it
# after preflight could let the provider recycle the verified VM.
sandbox = await (lifetime or temporary).enter_async_context(snapshot_sandbox(request, user_id, thread_id))
for receipt in receipts:
data = await run_file_io(_read_host_snapshot, user_id, thread_id, receipt["filename"], "uploads")
if len(data) != receipt["size"] or hashlib.sha256(data).hexdigest() != receipt["sha256"]:
raise AgentRunError(422, "Imported copy changed")
if sandbox is not None:
await run_file_io(verify_snapshot, sandbox, receipt["filename"], receipt["sha256"], receipt["size"])
except (AgentRunError, OSError) as exc:
raise AgentRunError(422, "Attached artifact copy is unavailable or changed; inspect the team artifacts and send a new request") from exc
def read_output_snapshot(user_id: str, thread_id: str, path: str, *, max_bytes: int = MAX_BYTES) -> bytes:
"""Read bounded host output bytes without following links.
Non-output paths raise HTTP 400; unsafe files raise 422 and mutation during
capture raises 409. Run filesystem work off the event loop.
"""
if os.name != "posix" or not isinstance(path, str): if os.name != "posix" or not isinstance(path, str):
raise AgentRunError(422, "Output capture requires POSIX and an output path") raise AgentRunError(422, "Output capture requires POSIX and an output path")
path = normalize_outputs_virtual_path(path) path = normalize_outputs_virtual_path(path)
return _read_host_snapshot(user_id, thread_id, path.removeprefix("/mnt/user-data/outputs/"), "outputs", max_bytes=max_bytes)
def _read_host_snapshot(user_id: str, thread_id: str, path: str, area: str, *, max_bytes: int = MAX_BYTES) -> bytes:
paths = get_paths() paths = get_paths()
root = paths.base_dir.resolve() root = paths.base_dir.resolve()
base = paths.sandbox_outputs_dir(thread_id, user_id=user_id) if area == "outputs" else paths.sandbox_uploads_dir(thread_id, user_id=user_id) relative = paths.sandbox_outputs_dir(thread_id, user_id=user_id).relative_to(paths.base_dir)
relative = base.relative_to(paths.base_dir) parts = (*relative.parts, *path.removeprefix("/mnt/user-data/outputs/").split("/"))
parts = (*relative.parts, *path.split("/"))
directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY) directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
try: try:
for part in parts[:-1]: for part in parts[:-1]:
@ -247,11 +28,11 @@ def _read_host_snapshot(user_id: str, thread_id: str, path: str, area: str, *, m
fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
with os.fdopen(fd, "rb") as source: with os.fdopen(fd, "rb") as source:
before = os.fstat(source.fileno()) before = os.fstat(source.fileno())
if not stat.S_ISREG(before.st_mode) or before.st_size > max_bytes or before.st_nlink != 1: if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_BYTES:
raise AgentRunError(422, "Artifact must be a regular, unlinked file within the capture limit") raise AgentRunError(422, "Artifact must be a regular file of at most 2 MiB")
content = source.read(max_bytes + 1) content = source.read(MAX_BYTES + 1)
after = os.fstat(source.fileno()) after = os.fstat(source.fileno())
if len(content) > max_bytes or (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns): if len(content) > MAX_BYTES or (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns):
raise AgentRunError(409, "Artifact changed during capture") raise AgentRunError(409, "Artifact changed during capture")
return content return content
except OSError as exc: except OSError as exc:

View File

@ -16,12 +16,6 @@ installation id, with a 55-minute TTL so we refresh well before the
60-minute GitHub limit. If two webhook deliveries land within a few 60-minute GitHub limit. If two webhook deliveries land within a few
seconds we'll mint two tokens — that's fine and well under any seconds we'll mint two tokens — that's fine and well under any
rate limit. rate limit.
Building the App JWT reads the private key and signs with it, so the
async mint runs it in a worker thread. Parsing a PEM key is the costly
step (tens of ms for RSA-2048, holding the GIL, so a worker thread alone
would still stall the event loop), so the parsed key is reused for as
long as the configured PEM text stays the same.
""" """
from __future__ import annotations from __future__ import annotations
@ -29,11 +23,9 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any
import httpx import httpx
import jwt import jwt
@ -75,12 +67,6 @@ _install_locks: dict[int, asyncio.Lock] = {}
# insert the per-installation lock, never while we hold one. # insert the per-installation lock, never while we hold one.
_install_locks_lock = asyncio.Lock() _install_locks_lock = asyncio.Lock()
# The most recently parsed App private key, as ``(pem_text, key)``. A
# changed PEM (a rotated env var or key file) is parsed again on the next
# mint. The lock keeps concurrent worker-thread mints from parsing twice.
_signing_key: tuple[str, Any] | None = None
_signing_key_lock = threading.Lock()
async def _lock_for(installation_id: int) -> asyncio.Lock: async def _lock_for(installation_id: int) -> asyncio.Lock:
"""Return the lock dedicated to ``installation_id``, creating on demand.""" """Return the lock dedicated to ``installation_id``, creating on demand."""
@ -128,24 +114,6 @@ def load_app_private_key() -> str:
return p.read_text(encoding="utf-8") return p.read_text(encoding="utf-8")
def _app_signing_key() -> Any:
"""Return the App private key ready for RS256, parsing it only when it changed.
The PEM is still read on every call, so a rotated key is used by the
next mint without a restart.
"""
global _signing_key
pem = load_app_private_key()
with _signing_key_lock:
cached = _signing_key
if cached is not None and cached[0] == pem:
return cached[1]
# Same parsing (and the same errors) as passing the PEM to jwt.encode.
key = jwt.get_algorithm_by_name("RS256").prepare_key(pem)
_signing_key = (pem, key)
return key
def mint_app_jwt(*, now: float | None = None) -> str: def mint_app_jwt(*, now: float | None = None) -> str:
"""Sign a short-lived JWT identifying this App to GitHub. """Sign a short-lived JWT identifying this App to GitHub.
@ -164,7 +132,7 @@ def mint_app_jwt(*, now: float | None = None) -> str:
# numeric App id rendered as a decimal string. # numeric App id rendered as a decimal string.
"iss": str(app_id()), "iss": str(app_id()),
} }
return jwt.encode(payload, _app_signing_key(), algorithm="RS256") return jwt.encode(payload, load_app_private_key(), algorithm="RS256")
async def _request_new_installation_token( async def _request_new_installation_token(
@ -173,10 +141,8 @@ async def _request_new_installation_token(
client: httpx.AsyncClient | None = None, client: httpx.AsyncClient | None = None,
) -> _CachedToken: ) -> _CachedToken:
"""Hit ``POST /app/installations/{id}/access_tokens`` once.""" """Hit ``POST /app/installations/{id}/access_tokens`` once."""
# Reading the key file and signing are blocking; keep them off the loop.
app_jwt = await asyncio.to_thread(mint_app_jwt)
headers = { headers = {
"Authorization": f"Bearer {app_jwt}", "Authorization": f"Bearer {mint_app_jwt()}",
"Accept": "application/vnd.github+json", "Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28", "X-GitHub-Api-Version": "2022-11-28",
} }
@ -258,11 +224,6 @@ async def mint_installation_token(
def _clear_token_cache_for_tests() -> None: def _clear_token_cache_for_tests() -> None:
"""Drop every cached token and the parsed key. Tests reach for this between cases.""" """Drop every cached token. Tests reach for this between cases."""
global _signing_key
_token_cache.clear() _token_cache.clear()
_install_locks.clear() _install_locks.clear()
# Under the lock, so a mint parsing in a worker thread finishes first
# instead of storing its key after the reset.
with _signing_key_lock:
_signing_key = None

View File

@ -95,7 +95,7 @@ def _build_index(agents: list[tuple[str, AgentConfig]]) -> _Registry:
# it is here, so this branch is unreachable. Keep the # it is here, so this branch is unreachable. Keep the
# guard for type-checker happiness. # guard for type-checker happiness.
continue continue
index.setdefault((binding.repo.lower(), event), []).append(GitHubAgentMatch(user_id=user_id, agent=cfg, trigger=resolved)) index.setdefault((binding.repo, event), []).append(GitHubAgentMatch(user_id=user_id, agent=cfg, trigger=resolved))
return index return index
@ -145,4 +145,4 @@ def lookup_agents(
and the pre-resolved trigger config for this specific event, so the and the pre-resolved trigger config for this specific event, so the
caller does not need to walk the agent's ``bindings`` again. caller does not need to walk the agent's ``bindings`` again.
""" """
return registry.get((repo.lower(), event), []) return registry.get((repo, event), [])

View File

@ -81,11 +81,6 @@ def _comment_body(event: str, payload: dict[str, Any]) -> str:
def _author_login(event: str, payload: dict[str, Any]) -> str | None: def _author_login(event: str, payload: dict[str, Any]) -> str | None:
"""Login of the human who triggered the event, for ``allow_authors``.""" """Login of the human who triggered the event, for ``allow_authors``."""
# Object authors need not be the actor on edits, labels, or dismissals.
# Keep the object fallback for payloads without a sender identity.
sender_login = (payload.get("sender") or {}).get("login")
if isinstance(sender_login, str):
return sender_login
if event in ("issue_comment", "pull_request_review_comment"): if event in ("issue_comment", "pull_request_review_comment"):
login = (payload.get("comment") or {}).get("user", {}).get("login") login = (payload.get("comment") or {}).get("user", {}).get("login")
elif event == "pull_request": elif event == "pull_request":

View File

@ -51,7 +51,6 @@ from sqlalchemy import text
from deerflow.persistence.engine import get_engine from deerflow.persistence.engine import get_engine
from deerflow.utils.file_io import await_drained from deerflow.utils.file_io import await_drained
from deerflow.utils.http_policy import control_plane_trust_env
if TYPE_CHECKING: if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig from deerflow.config.app_config import AppConfig
@ -314,7 +313,10 @@ def _make_provisioner_client(provisioner_url: str) -> httpx.AsyncClient:
``NO_PROXY``, so without this a private provisioner behind a proxy-only ``NO_PROXY``, so without this a private provisioner behind a proxy-only
egress would be reported unreachable on every probe. egress would be reported unreachable on every probe.
""" """
return httpx.AsyncClient(timeout=_PROBE_TIMEOUT_SECONDS, trust_env=control_plane_trust_env(provisioner_url)) # Lazy: importing the aio_sandbox package pulls in the whole provider stack.
from deerflow.community.aio_sandbox.backend import sandbox_http_trust_env
return httpx.AsyncClient(timeout=_PROBE_TIMEOUT_SECONDS, trust_env=sandbox_http_trust_env(provisioner_url))
async def _probe_provisioner(provisioner_url: str | None) -> str: async def _probe_provisioner(provisioner_url: str | None) -> str:

View File

@ -1,74 +0,0 @@
"""Host-authorized history copies under durable thread-operation admission."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from fastapi import Request
from app.gateway.deps import get_run_manager, get_thread_store
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from deerflow.persistence.run.sql import RunRepository
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
from deerflow.runtime.events.producer import RunEventProducerRejected, history_seed_authority, producer_scope
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.runs.manager import RunRecord
from deerflow.runtime.runs.schemas import ThreadOperationKind
@dataclass(frozen=True)
class HistorySeedReservation:
record: RunRecord
thread: dict | None
@asynccontextmanager
async def reserve_history_seed(request: Request, thread_id: str) -> AsyncIterator[HistorySeedReservation | None]:
"""Keep snapshot reads and seed writes exclusive with delete and admission."""
events = request.app.state.run_event_store
manager = getattr(request.app.state, "run_manager", None)
runs = getattr(manager, "_store", None)
threads = getattr(request.app.state, "thread_store", None)
if not isinstance(events, DbRunEventStore):
yield None
return
if not isinstance(runs, RunRepository) or not isinstance(threads, ThreadMetaRepository):
if events._producer_fencing == "strict":
raise RunEventProducerRejected("History seeding requires shared SQL lifecycle stores")
yield None
return
if runs._sf.kw.get("bind") is not events._sf.kw.get("bind") or threads._sf.kw.get("bind") is not events._sf.kw.get("bind"):
raise RunEventProducerRejected("History seeding requires shared SQL lifecycle stores")
async with get_run_manager(request).reserve_thread_operation(
thread_id,
kind=ThreadOperationKind.checkpoint_write,
user_id=get_trusted_internal_owner_user_id(request),
) as reservation:
yield HistorySeedReservation(reservation, await threads.get(thread_id, user_id=None))
async def write_history_seed(
request: Request,
events: list[dict],
*,
reservation: HistorySeedReservation | None,
seed_kind: str,
thread: dict | None = None,
assistant_id: str | None = None,
) -> None:
store = request.app.state.run_event_store
if reservation is None:
await store.put_batch(events)
return
if thread is None:
# Only the checkpoint migration may materialize missing legacy metadata.
# The caller read that checkpoint while holding this same reservation.
if seed_kind != "checkpoint":
raise RunEventProducerRejected("Branch seeding requires its original thread identity")
threads = get_thread_store(request)
thread = reservation.thread
if thread is None:
thread = await threads.create(reservation.record.thread_id, assistant_id=assistant_id, user_id=reservation.record.user_id)
authority = history_seed_authority(reservation.record, thread, seed_kind=seed_kind)
with producer_scope(authority):
await store.put_batch(events)

View File

@ -1,351 +0,0 @@
"""Server-side validation for HITL tool-approval resume and admission.
All checks live here (called from ``services.start_run``) so every entry point
— HTTP routes, extension host, internal launchers — shares one choke point.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from fastapi import HTTPException
from app.gateway.checkpoint_lineage import hitl_approval_payloads
from deerflow.approvals.protocol import combine_approval_payloads
from deerflow.runtime.checkpoint_history import recent_checkpoint_history
logger = logging.getLogger(__name__)
class ApprovalRequestIds(dict):
"""Identity map plus the validated LangGraph interrupt-ID resume map."""
resume_map: dict | None = None
if TYPE_CHECKING:
from deerflow.runtime.runs.manager import ApprovalAnswerHandle
def _error(status: int, code: str, message: str) -> HTTPException:
return HTTPException(status_code=status, detail={"code": code, "message": message})
def _responses_of(command: Any) -> list[Any] | None:
if not isinstance(command, Mapping):
return None
resume = command.get("resume")
if not isinstance(resume, Mapping):
return None
responses = resume.get("responses")
return responses if isinstance(responses, list) else None
def _all_reject(responses: list[Any] | None) -> bool:
return bool(responses) and all(isinstance(r, Mapping) and r.get("option_id") == "reject" for r in responses)
async def has_any_pending_interrupt(accessor: Any, read_config: dict[str, Any]) -> bool:
"""Whether a recent checkpoint has an unconsumed interrupt write.
Used for generic (non-hitl) resume admission where snapshot.next may be
empty after a head-moving write. The newest resume/interrupt marker wins,
so an answered pause cannot be resurrected by its older interrupt write.
"""
async for checkpoint in recent_checkpoint_history(accessor.checkpointer, read_config):
writes = list(getattr(checkpoint, "pending_writes", ()) or ())
if any(len(write) >= 2 and write[1] == "__resume__" for write in writes):
return False
if any(len(write) >= 2 and write[1] == "__interrupt__" for write in writes):
return True
return False
async def _pending_interrupts_from_checkpoint_history(checkpointer: Any, read_config: dict[str, Any]) -> dict[str, list[Any]]:
"""Read pending interrupts without materializing a graph state snapshot."""
async for checkpoint in recent_checkpoint_history(checkpointer, read_config):
writes = list(getattr(checkpoint, "pending_writes", ()) or ())
if any(len(write) >= 2 and write[1] == "__resume__" for write in writes):
return {}
interrupt_writes = [write for write in writes if len(write) >= 3 and write[1] == "__interrupt__"]
if not interrupt_writes:
continue
grouped: dict[str, list[Any]] = {}
for write in interrupt_writes:
interrupts = write[2] if isinstance(write[2], (list, tuple)) else (write[2],)
grouped.setdefault(str(write[0]), []).extend(interrupts)
return grouped
return {}
async def has_waiting_for_input_run(request: Any, thread_id: str) -> bool:
"""Whether any run in the thread is paused for input (any interrupt kind)."""
from app.gateway.deps import get_run_store
try:
run_store = get_run_store(request)
except (HTTPException, AttributeError) as exc:
if isinstance(exc, AttributeError) or exc.status_code == 503:
return False
raise
runs = await run_store.list_by_thread(thread_id, limit=5)
return any(run.get("status") == "waiting_for_input" for run in runs)
async def mark_approval_answered(request: Any, thread_id: str, *, expected_request_ids: dict[str, str] | None = None) -> ApprovalAnswerHandle | None:
"""Mark the validated paused run as answered and return its recovery handle."""
from app.gateway.deps import get_run_manager, get_run_store
try:
run_manager = get_run_manager(request)
except (HTTPException, AttributeError) as exc:
if isinstance(exc, HTTPException) and exc.status_code != 503:
raise
# Lightweight compositions may provide only a store. Keep their
# persistence on RunManager's retry and in-memory mutation path too.
from deerflow.runtime import RunManager
try:
run_manager = RunManager(store=get_run_store(request))
except (HTTPException, AttributeError) as store_exc:
if isinstance(store_exc, AttributeError) or store_exc.status_code == 503:
return None
raise
return await run_manager.mark_approval_answered(thread_id, expected_request_ids=expected_request_ids)
async def _interrupt_from_checkpoint_history(accessor: Any, read_config: dict[str, Any], payload: dict[str, Any]) -> tuple[str, Any] | None:
"""Recover the original LangGraph task identity after the head has moved."""
if accessor is None:
return None
if getattr(accessor, "checkpointer", None) is None:
return None
async for checkpoint in recent_checkpoint_history(accessor.checkpointer, read_config):
writes = list(getattr(checkpoint, "pending_writes", ()) or ())
if any(len(write) >= 2 and write[1] == "__resume__" for write in writes):
return None
for write in writes:
if len(write) < 3 or write[1] != "__interrupt__":
continue
interrupts = write[2] if isinstance(write[2], (list, tuple)) else (write[2],)
for pending_interrupt in interrupts:
if getattr(pending_interrupt, "value", None) == payload:
return str(write[0]), pending_interrupt
return None
async def find_pending_interrupts(
request: Any,
thread_id: str,
*,
accessor: Any = None,
read_config: dict[str, Any] | None = None,
snapshot: Any = None,
) -> dict[str, list[Any]]:
"""Find pending interrupts from an existing snapshot or raw checkpoints.
The live snapshot catches a pause before the worker has finalized the run;
raw pending writes cover head-moving writes and restricted mutation graphs;
the durable run record remains the final fallback.
"""
from langgraph.types import Interrupt
from app.gateway.checkpoint_lineage import task_interrupts_by_id
from app.gateway.deps import get_checkpointer, get_run_store
if snapshot is None and accessor is not None and read_config is not None:
snapshot = await accessor.aget(read_config)
if snapshot is not None:
grouped = task_interrupts_by_id(snapshot)
if grouped:
return grouped
try:
run_store = get_run_store(request)
except (HTTPException, AttributeError) as exc:
if not isinstance(exc, AttributeError) and exc.status_code != 503:
raise
runs = []
else:
runs = await run_store.list_by_thread(thread_id, limit=10)
# A current loop snapshot is authoritative for ordinary polling. Update
# checkpoints can have moved the head past an unconsumed interrupt, and
# checkpoint-only compositions have no snapshot: retain recovery for both.
if (
snapshot is not None
and (getattr(snapshot, "metadata", None) or {}).get("source") == "loop"
and (getattr(snapshot, "config", None) or {}).get("configurable", {}).get("checkpoint_id")
and not (read_config or {}).get("configurable", {}).get("checkpoint_id")
and not any(run.get("status") == "waiting_for_input" for run in runs)
):
return {}
checkpointer = getattr(accessor, "checkpointer", None)
if checkpointer is None:
try:
checkpointer = get_checkpointer(request)
except (HTTPException, AttributeError) as exc:
if not isinstance(exc, AttributeError) and exc.status_code != 503:
raise
if checkpointer is not None:
grouped = await _pending_interrupts_from_checkpoint_history(
checkpointer,
read_config or {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
)
if grouped:
return grouped
for run in runs:
if run.get("status") != "waiting_for_input":
continue
payload = (run.get("metadata") or {}).get("hitl_approval")
if payload:
recovered = await _interrupt_from_checkpoint_history(accessor, read_config, payload) if read_config is not None else None
if recovered is not None:
task_id, pending_interrupt = recovered
return {task_id: [pending_interrupt]}
task_id = run.get("run_id") or "approval"
return {task_id: [Interrupt(value=payload, id=payload.get("request_id", task_id))]}
return {}
def _approval_request_ids(payload: dict[str, Any]) -> dict[str, str]:
"""Extract and validate the action identity binding for one HITL pause."""
expected_ids = payload.get("pending_tool_call_ids")
if not isinstance(expected_ids, list) or any(not isinstance(call_id, str) for call_id in expected_ids) or len(set(expected_ids)) != len(expected_ids):
raise _error(422, "invalid_approval_response", "pending approval has invalid tool_call_ids")
expected_set = set(expected_ids)
actions = payload.get("actions")
if not isinstance(actions, list):
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
expected_request_ids: dict[str, str] = {}
for action in actions:
if not isinstance(action, Mapping):
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
call_id = action.get("tool_call_id")
request_id = action.get("request_id")
if not isinstance(call_id, str) or call_id not in expected_set or call_id in expected_request_ids or not isinstance(request_id, str) or not request_id:
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
expected_request_ids[call_id] = request_id
if set(expected_request_ids) != expected_set:
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
return expected_request_ids
async def current_approval_request_ids(
request: Any,
thread_id: str,
*,
accessor: Any,
read_config: dict[str, Any],
) -> dict[str, str] | None:
"""Read the current live pause's identity for checkpoint-only recovery."""
pending = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=read_config)
payloads = hitl_approval_payloads(pending)
return _approval_request_ids(combine_approval_payloads(payloads)) if payloads else None
async def validate_resume_request(*, request: Any, thread_id: str, accessor: Any, read_config: dict[str, Any], enabled: bool, body: Any) -> dict[str, str] | None:
"""Validate a command.resume against the current pause, or raise 409/422.
Static breakpoints (next non-empty, no approval payload) pass through
unchanged. All-reject remains the escape hatch for the validated pause when
the feature is disabled or the payload was lost; attachment later verifies
that checkpoint-only recovery still targets that same pause.
"""
snapshot = await accessor.aget(read_config)
responses = _responses_of(getattr(body, "command", None))
all_reject = _all_reject(responses)
pending = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=read_config, snapshot=snapshot)
payloads = hitl_approval_payloads(pending)
has_waiting_run = await has_waiting_for_input_run(request, thread_id)
has_pending_interrupt = await has_any_pending_interrupt(accessor, read_config)
head_checkpoint_id = (getattr(snapshot, "config", None) or {}).get("configurable", {}).get("checkpoint_id")
if not pending and not has_waiting_run and not (getattr(snapshot, "next", None) or ()) and not has_pending_interrupt:
raise _error(409, "no_pending_approval", "No resumable pause exists for this thread")
if not payloads:
return None
if responses is None:
raise _error(422, "invalid_approval_response", "resume must carry a 'responses' list for hitl_approval")
expected_request_ids = ApprovalRequestIds(_approval_request_ids(combine_approval_payloads(payloads)))
action_by_request = {action["request_id"]: action for payload in payloads for action in payload["actions"]}
expected_ids = list(expected_request_ids)
expected_set = set(expected_ids)
if len(responses) != len(expected_ids):
raise _error(422, "invalid_approval_response", f"expected {len(expected_ids)} responses, got {len(responses)}")
seen_ids: set[str] = set()
for response in responses:
if not isinstance(response, Mapping):
raise _error(422, "invalid_approval_response", "each response must be an object")
response_id = response.get("tool_call_id")
if not isinstance(response_id, str) or response_id not in expected_set:
raise _error(422, "invalid_approval_response", "unknown tool_call_id")
if response.get("request_id") != expected_request_ids[response_id]:
raise _error(422, "invalid_approval_response", "stale or mismatched request_id")
if response_id in seen_ids:
raise _error(422, "invalid_approval_response", "duplicate tool_call_id")
seen_ids.add(response_id)
action = action_by_request[response["request_id"]]
supported = ("approve", "reject", "edit", "approve_thread") if all(payload.get("version") == 2 for payload in payloads) else ("approve", "reject")
if response.get("option_id") not in supported:
raise _error(422, "invalid_approval_response", f"unsupported option_id; expected one of {', '.join(repr(option) for option in supported)}")
if response.get("option_id") == "approve_thread" and not action.get("grant_allowed"):
raise _error(422, "invalid_approval_response", "This operation does not permit a thread grant")
if response.get("option_id") == "edit":
from jsonschema import Draft202012Validator
from deerflow.approvals.policy import canonical_json
args, schema = response.get("args"), action.get("args_schema")
try:
limit = min(payload.get("max_payload_bytes", 512 * 1024) for payload in payloads)
if not isinstance(args, dict) or not isinstance(schema, dict) or len(canonical_json(args).encode()) > limit:
raise ValueError("Invalid arguments")
Draft202012Validator(schema).validate(args)
if isinstance(schema.get("properties"), dict) and set(args) - set(schema["properties"]):
raise ValueError("Unknown or injected arguments")
except Exception as exc:
raise _error(422, "invalid_approval_arguments", "Edited arguments do not match the tool schema") from exc
if all(payload.get("version") == 2 for payload in payloads):
expected_request_ids.resume_map = {
item.id: {"responses": [response for response in responses if response["request_id"] in {action["request_id"] for action in item.value["actions"]}]}
for interrupts in pending.values()
for item in interrupts
if isinstance(getattr(item, "value", None), dict) and item.value.get("kind") == "hitl_approval"
}
if all_reject:
return expected_request_ids
if not enabled:
raise _error(409, "hitl_approval_disabled", "hitl_approval is currently disabled; resume with all-reject to clear the pause")
requested_checkpoint_id = getattr(body, "checkpoint_id", None)
body_checkpoint = getattr(body, "checkpoint", None)
if requested_checkpoint_id is None and isinstance(body_checkpoint, Mapping):
requested_checkpoint_id = body_checkpoint.get("checkpoint_id")
if requested_checkpoint_id and head_checkpoint_id and str(requested_checkpoint_id) != str(head_checkpoint_id):
raise _error(422, "checkpoint_not_head", "A resume must target the current head checkpoint, never an older pause checkpoint")
return expected_request_ids
async def reject_new_run_when_approval_pending(
request: Any,
thread_id: str,
*,
accessor: Any = None,
read_config: dict[str, Any] | None = None,
) -> None:
"""Reject a brand-new (non-resume) run while an approval is pending.
Without this guard a fresh input silently discards the pending approval
(measured behavior of LangGraph). Internal launchers catch this specific
error code to implement skip-and-retry semantics.
"""
pending = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=read_config)
if hitl_approval_payloads(pending):
raise _error(409, "approval_pending", "Thread has a pending tool approval; resume or reject it before starting a new run")

View File

@ -1,78 +0,0 @@
"""Bounded, read-only provider catalog retrieval. Never follow credentialed redirects."""
import asyncio
import json
import httpx
from deerflow.config.managed_model_providers import get_managed_model_provider
from deerflow.config.managed_models import ManagedModel
MAX_MODELS = 2000
MAX_BYTES = 2 * 1024 * 1024
class DiscoveryError(ValueError):
def __init__(self, code: str):
self.code = code
super().__init__(code)
async def fetch_model_catalog(profile: ManagedModel) -> tuple[list[str], bool]:
provider = get_managed_model_provider(profile.provider)
endpoint = provider.endpoint or profile.base_url
key = profile.api_key.get_secret_value() if profile.api_key else None
native = provider.protocol == "anthropic-messages"
url = endpoint.rstrip("/") + ("/v1/models" if native else "/models")
headers = {"Accept": "application/json", "Accept-Encoding": "identity"}
if native:
headers.update({"x-api-key": key or "", "anthropic-version": "2023-06-01"})
elif key:
headers["Authorization"] = f"Bearer {key}"
# HTTPX constructs TLS trust synchronously; keep that work off the event loop.
client = await asyncio.to_thread(httpx.AsyncClient, timeout=15, follow_redirects=False)
names: set[str] = set()
cursor = None
size = 0
try:
async with asyncio.timeout(20), client:
for _ in range(20):
params = {"limit": 100, **({"after_id": cursor} if cursor else {})} if native else None
async with client.stream("GET", url, headers=headers, params=params) as response:
if response.status_code in (401, 403):
raise DiscoveryError("authentication_failed")
if response.status_code in (404, 405):
raise DiscoveryError("discovery_unsupported")
if response.status_code != 200:
raise DiscoveryError("discovery_failed")
# Reject compression before HTTPX can allocate a decoded
# chunk larger than our response budget.
if response.headers.get("content-encoding", "identity").strip().lower() != "identity":
raise DiscoveryError("discovery_failed")
chunks = []
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > MAX_BYTES:
raise DiscoveryError("response_limit")
chunks.append(chunk)
data = json.loads(b"".join(chunks))
if not isinstance(data, dict) or not isinstance(data.get("data"), list):
raise DiscoveryError("discovery_failed")
for item in data["data"]:
name = item.get("id") if isinstance(item, dict) else None
if not isinstance(name, str) or not name or len(name) > 200 or name != name.strip() or any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in name):
continue
names.add(name)
if len(names) >= MAX_MODELS:
return sorted(names), True
if not native or not data.get("has_more"):
return sorted(names), bool(data.get("has_more"))
next_cursor = data.get("last_id")
if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor or len(next_cursor) > 200:
raise DiscoveryError("discovery_failed")
cursor = next_cursor
return sorted(names), True
except DiscoveryError:
raise
except Exception:
raise DiscoveryError("discovery_failed") from None

View File

@ -1,55 +1,100 @@
# Gateway routers (`app/gateway/routers/`) # Router contracts
One module per API area. The parent [Gateway guide](../AGENTS.md) covers trust `GET /api/plugins/diagnostics` uses `require_admin_user` before reading the
boundaries and run admission; this file covers what every route handler has to do. canonical runtime diagnostic snapshot. Return bounded entry-point identifiers,
known levels and fixed summaries; raw messages stay in logs. Never clear the
sink or expand PAT permissions.
## Verify Branch metadata inherits valid `assistant_id` values verbatim. Validate with
`custom_agent_name` and clear invalid legacy values to null in the new row;
leave the source record unchanged.
```bash Console usage attributes legacy rows without a per-model usage map to
cd backend `model_name`, including their recorded `total_input_tokens`. Accumulate those
python -m pytest tests -q -k "<area>_router" # e.g. threads_router, console_router inputs with newer usage buckets for the same model; do not infer cache hits.
make test-blocking-io # after adding any file, config or network read Regression coverage lives in `tests/test_console_router.py`.
```
## Rules for a route handler Shared scheduled-task update validation preserves an unchanged future `once`
instant on a live task inside `scheduler.min_once_delay_seconds`; new/changed
instants and terminal reactivation still enforce the minimum delay.
- **Authorize first.** Check the session, role or permission before the handler Scheduled-task admission rejects nonexistent naive `run_at` and `end_at`
reads credentials, parses an upload or makes an outbound request. An wall times through the shared Gateway datetime resolver. Keep this check out
unauthorized caller must not be able to trigger that work. of runtime `next_run_at`: legacy tasks retain their accepted execution time,
- **Fixed text in 5xx responses.** Exceptions from storage, config loading and including unchanged live once definitions. Explicit offsets retain their instant;
subprocesses contain host paths. Log the exception on the server and return a ambiguous naive times keep the existing fold choice.
fixed detail string.
- **No blocking calls on the event loop.** `get_app_config()` stats and hashes the The MCP task list accepts optional `TaskStatus` and `active_only` query filters.
config files on every call, and storage backends do file I/O. Call both through Pass both to the repository before its limit; preserve `threads:read`, owner
`asyncio.to_thread`. The blocking-I/O suite fails the build otherwise. checks, current-thread incarnation scoping, and the unfiltered array response.
- **Writes survive disconnects.** Wrap a persistent write in
`app.gateway.persistent_writes.run_drained_write`. A cancelled request then Custom Agent packages are strict, versioned definition-only JSON documents.
still waits for the worker that owns the write, and no half-written state stays Export explicitly projects the caller's portable `AgentConfig` fields and SOUL;
behind. exclude `github` installation bindings, memory contents, conversations and
- **Catalog responses are projections.** A listing endpoint returns names and credentials. Import may rename locally but must remain owner-scoped and
display fields. Provider paths, credentials and raw tool or server configuration create-only: collisions return 409, never overwrite or upsert. Ordinary creation
stay on the server. and import share `_persist_new_agent`, which uses `run_drained_write` so a
- **Provenance comes from the session.** Values that say who or what triggered an cancelled request still drains the owned persistence worker.
action (for example the plugin `ActionContext.interaction`) are set from the
authenticated request only. Ignore the same field in a request body. `GET /api/skills`, `/api/skills/custom` and `/api/skills/{name}` serve every
- **Unreadable configuration is not default configuration.** When a policy cannot signed-in user, so their unexpected-failure 500s return a fixed detail. Load
be read, skip the optional work (cleanup, sweep) and keep serving. Applying a errors (e.g. a malformed `extensions_config.json`) carry host paths and stay in
default policy can delete data the operator wanted to keep. the server log.
- **Imports create, they do not overwrite.** Agent and skill imports are scoped to
the caller and return 409 on a name collision. Exports leave out credentials, Thread upload, upload delete and shelf attach-to-thread 500s name the file but
memory, conversations and installation bindings. not the cause: staging and unlink `OSError`s carry host paths and stay in the
- **Memory backup imports** replace the selected facts and require an explicit server log.
`facts` array. Omitting it returns 422. An empty array clears that bucket.
- **Outbound requests from a route** (model discovery, connection tests) have a Skill mutation operation lists return `next_cursor: null` when `has_more` is false, including empty pages after a supplied `after_id`. The next cursor is the final returned operation only when a further page exists.
time limit and a size limit, do not follow redirects, and return redacted
provider errors. `GET /api/agent-tool-groups` projects only configured group names for the
- **Locks that several Gateway processes share** are file locks beside the data, custom-Agent settings allowlist. Never expose tool provider paths, credentials,
taken off the event loop. A process mutex alone does not order writes between or raw tool configuration through this catalog.
workers.
- **Path segments from clients.** A name that becomes a directory (agent name, Custom Agent update guards apply legacy shared-file checks only when the
skill name, file name) is validated when it is created, including Windows resolved store is `FileAgentStore`, including the `update_agent` tool. DB
reserved device names. Read and delete routes stay permissive so that data imports retain source files for rollback; those files cannot veto SQL updates.
stored by an older release can still be exported and removed. Keep store resolution and filesystem checks off-loop in the HTTP route.
- **Search results use the latest owner-visible message.** A historical jump Agent PUT uses `preserve_non_managed_fields(..., new_tool_groups=...)` to clear
resolves only the requested thread. Use the shared history seed path for an inherited knowledge default when changed groups remove knowledge access.
legacy checkpoints so it keeps its existing write reservation. Explicit scope/null updates still win; unchanged groups are not a migration.
Agent/profile and deployment/personal MCP mutation 500s plus the
scheduled-task `invalid_assistant` 422 use fixed text. Log exceptions
server-side; they can carry host paths.
Committed MCP reconciliation failures are logged once by
`fail_mcp_reconciliation`, with only the underlying exception type.
`_run_drained_mcp_apply` passes `_McpCommittedReconciliationError` as an expected
error to `run_drained_write` to avoid a duplicate diagnostic. It still drains
retired-pool cleanup outside the config locks and reports the fixed-detail 500.
Custom-skill edit, delete and rollback hold the owning root's process mutex and
`.custom.mutation.lock` sidecar across predecessor read, storage mutation and
history append. Peer Gateway processes sharing that root must use this same
critical section to preserve history ordering. Keep the sidecar distinct from
the nested projection lock, retain it across requests, and acquire/release both
off-loop inside the drained worker. Unrelated users retain independent locks;
external filesystem writers do not participate in this history guarantee.
Plugin actions stamp `ActionContext.interaction=session` only from authenticated
session provenance. Never accept it from payloads or propagate it to model tools;
extensions use it to distinguish owner review from model/tool output.
Skill archive upload copies (`_copy_uploaded_skill_archive`) report the original
size-limit or read error when temporary cleanup or its warning log fails.
Successful copies still return the uploaded bytes unchanged. The upload route's
`finally` discards that archive through the same helper before closing the
multipart form. Form close is suppressed, so a close, cleanup, or warning
failure cannot turn a successful install into a 500, replace an install
error, or skip the discard. Coverage: `tests/test_skill_archive_upload_cleanup.py`.
State updates synchronize titles into thread metadata only for the root checkpoint
namespace. Nonroot writes preserve the conversation title, branch-title sequence
and activity timestamp. `tests/test_threads_router.py` checks metadata/search
isolation and root title synchronization in full and delta modes.
Unauthenticated auth routes (`/register`, `/setup-status`, `/providers`,
`/oauth/{provider}`, `/callback/{provider}`, the `/login/local` throttle) read
`get_app_config()` through `asyncio.to_thread`: each read stats and hashes the
config files. Coverage: `tests/blocking_io/test_auth_router_config_reads.py`.

View File

@ -96,7 +96,7 @@ class AgentUpdateRequest(BaseModel):
"""Request body for updating a custom agent.""" """Request body for updating a custom agent."""
display_name: AgentDisplayName | None = Field(default=None, description="Updated display name; null clears it") display_name: AgentDisplayName | None = Field(default=None, description="Updated display name; null clears it")
description: str | None = Field(default=None, description="Updated description; null resets it to empty") description: str | None = Field(default=None, description="Updated description")
model: str | None = Field(default=None, description="Updated model override") model: str | None = Field(default=None, description="Updated model override")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist") tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)") mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)")
@ -601,9 +601,7 @@ async def update_agent(name: str, body: AgentUpdateRequest, request: Request) ->
if config_changed: if config_changed:
updated = { updated = {
"name": agent_cfg.name, "name": agent_cfg.name,
# Explicit null resets to the AgentConfig default; storing None "description": body.description if "description" in fields_set else agent_cfg.description,
# would make every later load of this agent fail validation.
"description": (body.description or "") if "description" in fields_set else agent_cfg.description,
} }
if "display_name" in fields_set: if "display_name" in fields_set:
updated["display_name"] = body.display_name or None updated["display_name"] = body.display_name or None

View File

@ -4,21 +4,17 @@ import hashlib
import logging import logging
import mimetypes import mimetypes
import os import os
import secrets
import stat import stat
import tempfile import tempfile
import threading
import zipfile import zipfile
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import BinaryIO
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from starlette.datastructures import MutableHeaders
from app.gateway.authz import SandboxRequestLease, require_permission, try_acquire_sandbox_for_request from app.gateway.authz import SandboxRequestLease, require_permission, try_acquire_sandbox_for_request
from app.gateway.deps import get_run_manager from app.gateway.deps import get_run_manager
@ -45,8 +41,6 @@ _SKILL_ARCHIVE_READ_CHUNK_SIZE = 64 * 1024
MAX_EDITABLE_ARTIFACT_BYTES = 2 * 1024 * 1024 MAX_EDITABLE_ARTIFACT_BYTES = 2 * 1024 * 1024
MAX_VIEWED_IMAGE_BLOB_BYTES = 20 * 1024 * 1024 MAX_VIEWED_IMAGE_BLOB_BYTES = 20 * 1024 * 1024
_ARTIFACT_EDIT_TEMP_PREFIX = ".artifact-edit-" _ARTIFACT_EDIT_TEMP_PREFIX = ".artifact-edit-"
_ARTIFACT_COPY_BEFORE_STREAM = os.name == "nt"
_artifact_digest_source = threading.local()
class ArtifactUpdateRequest(BaseModel): class ArtifactUpdateRequest(BaseModel):
@ -340,7 +334,7 @@ def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tupl
return ("inline_file", mime_type) return ("inline_file", mime_type)
def _sha256_of_file(path: Path, *, pinned: tuple[BinaryIO, os.stat_result] | None = None) -> str: def _sha256_of_file(path: Path) -> str:
"""Return the hex SHA-256 digest of *path* without loading it whole. """Return the hex SHA-256 digest of *path* without loading it whole.
Computing the digest on the Gateway lets the browser skip its own Computing the digest on the Gateway lets the browser skip its own
@ -352,217 +346,45 @@ def _sha256_of_file(path: Path, *, pinned: tuple[BinaryIO, os.stat_result] | Non
requests a browser issues while scrubbing/paginating a preview do not each requests a browser issues while scrubbing/paginating a preview do not each
re-hash a potentially huge artifact from scratch (raised in PR review). re-hash a potentially huge artifact from scratch (raised in PR review).
""" """
file_stat = path.stat() if pinned is None else pinned[1] stat = path.stat()
# Sandbox syncs and other writers can atomically replace a same-size file # Sandbox syncs and other writers can atomically replace a same-size file
# while preserving its mtime. Identity separates those generations; ctime # while preserving its mtime. Identity separates those generations; ctime
# also invalidates in-place writes where the filesystem exposes change time. # also invalidates in-place writes where the filesystem exposes change time.
key = ( return _sha256_of_file_cached(
str(path), str(path),
file_stat.st_dev, stat.st_dev,
file_stat.st_ino, stat.st_ino,
file_stat.st_ctime_ns, stat.st_ctime_ns,
file_stat.st_mtime_ns, stat.st_mtime_ns,
file_stat.st_size, stat.st_size,
) )
if pinned is None:
return _sha256_of_file_cached(*key)
previous = getattr(_artifact_digest_source, "pinned", None)
_artifact_digest_source.pinned = (key, pinned[0])
try:
return _sha256_of_file_cached(*key)
finally:
_artifact_digest_source.pinned = previous
@functools.lru_cache(maxsize=256) @functools.lru_cache(maxsize=256)
def _sha256_of_file_cached(path: str, device: int, inode: int, ctime_ns: int, mtime_ns: int, size: int) -> str: def _sha256_of_file_cached(path: str, device: int, inode: int, ctime_ns: int, mtime_ns: int, size: int) -> str:
"""Cached SHA-256 of *path*; metadata arguments separate file generations.""" """Cached SHA-256 of *path*; metadata arguments separate file generations."""
pinned = getattr(_artifact_digest_source, "pinned", None) digest = hashlib.sha256()
# A nested lookup for a different file must hash its own bytes.
if pinned is None or pinned[0] != (path, device, inode, ctime_ns, mtime_ns, size):
with open(path, "rb") as handle: with open(path, "rb") as handle:
return hashlib.file_digest(handle, "sha256").hexdigest() for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
handle = pinned[1] return digest.hexdigest()
previous_offset = handle.tell()
try:
handle.seek(0)
return hashlib.file_digest(handle, "sha256").hexdigest()
finally:
handle.seek(previous_offset)
def _artifact_etag(path: Path) -> str: def _artifact_etag(path: Path) -> str:
"""Keep content revisions for editable files and metadata tags for large files.""" """Keep content revisions for editable files and metadata-only tags for large files."""
file_stat = path.stat() file_stat = path.stat()
if file_stat.st_size <= MAX_EDITABLE_ARTIFACT_BYTES: if file_stat.st_size <= MAX_EDITABLE_ARTIFACT_BYTES:
# Populate the shared cache only with metadata from the hashed handle. return f'"{_sha256_of_file(path)}"'
with path.open("rb") as handle:
return _artifact_etag_from_handle(path, handle, os.fstat(handle.fileno()))
return _artifact_stat_etag(file_stat)
def _artifact_stat_etag(file_stat: os.stat_result) -> str:
# Do not read oversized artifacts just to validate a small byte range. # Do not read oversized artifacts just to validate a small byte range.
# The prefix keeps this opaque validator distinct from an editable SHA-256. # The prefix keeps this opaque validator distinct from an editable SHA-256.
identity = f"{file_stat.st_dev}:{file_stat.st_ino}:{file_stat.st_ctime_ns}:{file_stat.st_mtime_ns}:{file_stat.st_size}" identity = f"{file_stat.st_dev}:{file_stat.st_ino}:{file_stat.st_ctime_ns}:{file_stat.st_mtime_ns}:{file_stat.st_size}"
return f'"stat-{hashlib.sha256(identity.encode("ascii")).hexdigest()}"' return f'"stat-{hashlib.sha256(identity.encode("ascii")).hexdigest()}"'
def _artifact_etag_from_handle(path: Path, handle: BinaryIO, file_stat: os.stat_result) -> str:
"""Build the validator from the same file generation the response will read."""
if file_stat.st_size <= MAX_EDITABLE_ARTIFACT_BYTES:
digest = _sha256_of_file(path, pinned=(handle, file_stat))
return f'"{digest}"'
return _artifact_stat_etag(file_stat)
def _prepare_artifact_stream(path: Path) -> tuple[BinaryIO, os.stat_result, str]:
"""Pin one file generation before response headers authorize a byte range."""
handle = path.open("rb")
try:
file_stat = os.fstat(handle.fileno())
if not stat.S_ISREG(file_stat.st_mode):
raise RuntimeError(f"File at path {path} is not a file.")
return handle, file_stat, _artifact_etag_from_handle(path, handle, file_stat)
except BaseException:
_close_artifact_handle(handle)
raise
def _copy_artifact_ranges(handle: BinaryIO, ranges: list[tuple[int, int]]) -> BinaryIO:
"""Pack the selected ranges into a private temporary file in send order."""
snapshot = tempfile.TemporaryFile(mode="w+b")
try:
for start, end in ranges:
handle.seek(start)
remaining = end - start
while remaining:
chunk = handle.read(min(1 << 20, remaining))
if not chunk:
raise OSError("Artifact was truncated while preparing the response")
snapshot.write(chunk)
remaining -= len(chunk)
snapshot.seek(0)
return snapshot
except BaseException:
_close_artifact_handle(snapshot)
raise
def _close_artifact_handle(handle: BinaryIO) -> None:
try:
handle.close()
except OSError:
logger.warning("Could not close artifact response handle", exc_info=True)
class _ArtifactFileResponse(FileResponse): class _ArtifactFileResponse(FileResponse):
"""Require an ETag to authorize conditional ranges of replaceable artifacts.""" """Require an ETag to authorize conditional ranges of replaceable artifacts."""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._artifact_handle: BinaryIO | None = None
async def __call__(self, scope, receive, send) -> None:
async def prepare() -> None:
self._artifact_handle, self.stat_result, etag = await asyncio.to_thread(_prepare_artifact_stream, Path(self.path))
self.headers["etag"] = etag
self.set_stat_headers(self.stat_result)
try:
await await_drained(prepare())
await super().__call__(scope, receive, send)
finally:
if self._artifact_handle is not None:
handle, self._artifact_handle = self._artifact_handle, None
await await_drained(asyncio.to_thread(_close_artifact_handle, handle))
async def _snapshot_windows_body(self, ranges: list[tuple[int, int]], send_header_only: bool) -> bool:
if not _ARTIFACT_COPY_BEFORE_STREAM:
return False
source = self._artifact_handle
assert source is not None
async def snapshot() -> None:
# Windows replacement needs the public handle closed. A private
# copy also leaves the artifact's link count unchanged for ZIP.
self._artifact_handle = None if send_header_only else await asyncio.to_thread(_copy_artifact_ranges, source, ranges)
await asyncio.to_thread(_close_artifact_handle, source)
await await_drained(snapshot())
return True
async def _handle_simple(self, send, send_header_only: bool, send_pathsend: bool) -> None:
assert self.stat_result is not None
await self._snapshot_windows_body([(0, self.stat_result.st_size)], send_header_only)
await send({"type": "http.response.start", "status": self.status_code, "headers": self.raw_headers})
if send_header_only:
await send({"type": "http.response.body", "body": b"", "more_body": False})
return
handle = self._artifact_handle
assert handle is not None
await await_drained(asyncio.to_thread(handle.seek, 0))
more_body = True
while more_body:
chunk = await await_drained(asyncio.to_thread(handle.read, self.chunk_size))
more_body = len(chunk) == self.chunk_size
await send({"type": "http.response.body", "body": chunk, "more_body": more_body})
async def _handle_single_range(self, send, start: int, end: int, file_size: int, send_header_only: bool) -> None:
snapshot = await self._snapshot_windows_body([(start, end)], send_header_only)
headers = MutableHeaders(raw=list(self.raw_headers))
headers["content-range"] = f"bytes {start}-{end - 1}/{file_size}"
headers["content-length"] = str(end - start)
await send({"type": "http.response.start", "status": 206, "headers": headers.raw})
if send_header_only:
await send({"type": "http.response.body", "body": b"", "more_body": False})
return
handle = self._artifact_handle
assert handle is not None
if not snapshot:
await await_drained(asyncio.to_thread(handle.seek, start))
more_body = True
while more_body:
chunk = await await_drained(asyncio.to_thread(handle.read, min(self.chunk_size, end - start)))
start += len(chunk)
more_body = len(chunk) == self.chunk_size and start < end
await send({"type": "http.response.body", "body": chunk, "more_body": more_body})
async def _handle_multiple_ranges(self, send, ranges: list[tuple[int, int]], file_size: int, send_header_only: bool) -> None:
snapshot = await self._snapshot_windows_body(ranges, send_header_only)
boundary = secrets.token_hex(13)
content_length, header_generator = self.generate_multipart(ranges, boundary, file_size, self.headers["content-type"])
headers = MutableHeaders(raw=list(self.raw_headers))
headers["content-type"] = f"multipart/byteranges; boundary={boundary}"
headers["content-length"] = str(content_length)
await send({"type": "http.response.start", "status": 206, "headers": headers.raw})
if send_header_only:
await send({"type": "http.response.body", "body": b"", "more_body": False})
return
handle = self._artifact_handle
assert handle is not None
for start, end in ranges:
await send({"type": "http.response.body", "body": header_generator(start, end), "more_body": True})
if not snapshot:
await await_drained(asyncio.to_thread(handle.seek, start))
while start < end:
chunk = await await_drained(asyncio.to_thread(handle.read, min(self.chunk_size, end - start)))
if not chunk:
raise OSError("Artifact was truncated while streaming a byte range")
start += len(chunk)
await send({"type": "http.response.body", "body": chunk, "more_body": True})
await send({"type": "http.response.body", "body": b"\r\n", "more_body": True})
await send({"type": "http.response.body", "body": f"--{boundary}--".encode("latin-1"), "more_body": False})
def _should_use_range(self, http_if_range: str) -> bool: def _should_use_range(self, http_if_range: str) -> bool:
# Sandbox syncs can preserve mtimes across replacements, so Last-Modified # Sandbox syncs can preserve mtimes across replacements, so Last-Modified
# is not a strong validator. Date-form If-Range must return the full file. # is not a strong validator. Date-form If-Range must return the full file.

View File

@ -13,7 +13,6 @@ which persists no run history to report on.
import asyncio import asyncio
import logging import logging
import math
from datetime import UTC, datetime, time, timedelta from datetime import UTC, datetime, time, timedelta
from typing import NamedTuple from typing import NamedTuple
@ -182,14 +181,6 @@ def _build_pricing_map() -> dict[str, _ModelPricing]:
except (TypeError, ValueError): except (TypeError, ValueError):
logger.warning("console: ignoring malformed pricing on model %s", model_cfg.name) logger.warning("console: ignoring malformed pricing on model %s", model_cfg.name)
continue continue
# ``float("nan")`` / ``float(".inf")`` parse successfully, and NaN then
# survives every ``<=`` comparison below and every ``round()``, ending up
# in the JSON body where Starlette rejects it (allow_nan=False) -- a 500
# for the whole usage report. Treat non-finite prices as malformed, the
# same way the rest of the config layer does (allow_inf_nan=False).
if any(not math.isfinite(price) for price in (input_price, output_price, cache_hit_price) if price is not None):
logger.warning("console: ignoring non-finite pricing on model %s", model_cfg.name)
continue
if input_price <= 0 and output_price <= 0: if input_price <= 0 and output_price <= 0:
continue continue
model_currency = str(raw.get("currency") or "USD").strip().upper() or "USD" model_currency = str(raw.get("currency") or "USD").strip().upper() or "USD"
@ -237,36 +228,8 @@ def _token_cost(input_tokens: int, output_tokens: int, price: _ModelPricing, cac
return (uncached / 1_000_000) * price.input_per_million + (cache_read / 1_000_000) * hit_price + (output_tokens / 1_000_000) * price.output_per_million return (uncached / 1_000_000) * price.input_per_million + (cache_read / 1_000_000) * hit_price + (output_tokens / 1_000_000) * price.output_per_million
def _token_count(value: object) -> int:
"""Coerce one stored token counter to a non-negative ``int``.
``token_usage_by_model`` is a JSON column written from provider-reported
usage, so a bucket value is not guaranteed to be a number: providers have
shipped counters as strings ("n/a", "1.0"), and rows written by older
build/import paths may hold anything JSON can express. The console already
skips buckets that are not dicts and pricing that is not numeric; coercing
counters the same way keeps one junk bucket from failing the whole report
with a 500. Booleans are rejected because ``int(True) == 1`` would invent a
token, and negatives cannot be spend.
A JSON integer is not bounded by the float range: ``10**400`` parses as an
exact ``int``, and ``_token_cost`` then divides it by a million, which
raises ``OverflowError`` on the way back to a float. Such a value is junk
in exactly the same way as ``"n/a"``, so it is rejected here too.
"""
if isinstance(value, bool):
return 0
try:
count = int(value) # type: ignore[arg-type]
if count < 0 or not math.isfinite(float(count)):
return 0
except (TypeError, ValueError, OverflowError):
return 0
return count
def _usage_is_missing(usage: dict) -> bool: def _usage_is_missing(usage: dict) -> bool:
return _token_count(usage.get("missing_usage_calls")) > 0 return int(usage.get("missing_usage_calls") or 0) > 0
def _has_missing_usage(token_usage_by_model: object) -> bool: def _has_missing_usage(token_usage_by_model: object) -> bool:
@ -300,11 +263,11 @@ def _run_cost(
price = _lookup_pricing(pricing, model) price = _lookup_pricing(pricing, model)
if price is None: if price is None:
continue continue
input_tokens = _token_count(usage.get("input_tokens")) input_tokens = int(usage.get("input_tokens") or 0)
output_tokens = _token_count(usage.get("output_tokens")) output_tokens = int(usage.get("output_tokens") or 0)
if input_tokens == 0 and output_tokens == 0: if input_tokens == 0 and output_tokens == 0:
continue continue
cost += _token_cost(input_tokens, output_tokens, price, _token_count(usage.get("cache_read_tokens"))) cost += _token_cost(input_tokens, output_tokens, price, int(usage.get("cache_read_tokens") or 0))
priced = True priced = True
if priced: if priced:
return cost return cost
@ -548,9 +511,9 @@ async def console_usage(
entry.runs += 1 entry.runs += 1
if not isinstance(usage, dict): if not isinstance(usage, dict):
continue continue
entry.tokens += _token_count(usage.get("total_tokens")) entry.tokens += int(usage.get("total_tokens", 0) or 0)
entry.input_tokens += _token_count(usage.get("input_tokens")) entry.input_tokens += int(usage.get("input_tokens") or 0)
entry.cache_read_tokens += _token_count(usage.get("cache_read_tokens")) entry.cache_read_tokens += int(usage.get("cache_read_tokens") or 0)
price = _lookup_pricing(pricing, model) price = _lookup_pricing(pricing, model)
if _usage_is_missing(usage): if _usage_is_missing(usage):
missing_models.add(model.lower()) missing_models.add(model.lower())
@ -559,7 +522,7 @@ async def console_usage(
# equal rates on an unrelated model are not aliases. # equal rates on an unrelated model are not aliases.
missing_model_prices.add(id(price)) missing_model_prices.add(id(price))
if price is not None: if price is not None:
model_cost = _token_cost(_token_count(usage.get("input_tokens")), _token_count(usage.get("output_tokens")), price, _token_count(usage.get("cache_read_tokens"))) model_cost = _token_cost(int(usage.get("input_tokens") or 0), int(usage.get("output_tokens") or 0), price, int(usage.get("cache_read_tokens") or 0))
entry.cost = round((entry.cost or 0.0) + model_cost, 6) entry.cost = round((entry.cost or 0.0) + model_cost, 6)
elif row.model_name and run_tokens > 0: elif row.model_name and run_tokens > 0:
# Legacy rows predating token_usage_by_model: fall back to the run's model. # Legacy rows predating token_usage_by_model: fall back to the run's model.

View File

@ -7,8 +7,6 @@ request, while startup-scoped capabilities report the runtime that actually
started. started.
""" """
from typing import Literal
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -84,12 +82,6 @@ class ThreadActivityFeature(BaseModel):
available: bool = Field(..., description="Whether GET /api/thread-activity, POST /api/threads/{thread_id}/read and the thread search `unread` field work (SQL persistence)") available: bool = Field(..., description="Whether GET /api/thread-activity, POST /api/threads/{thread_id}/read and the thread search `unread` field work (SQL persistence)")
class ToolApprovalFeature(BaseModel):
enabled: bool = False
default_mode: Literal["ask", "auto", "full"] = "ask"
allowed_modes: list[Literal["ask", "auto", "full"]] = Field(default_factory=list)
class FeaturesResponse(BaseModel): class FeaturesResponse(BaseModel):
"""Frontend-facing feature availability flags.""" """Frontend-facing feature availability flags."""
@ -101,8 +93,6 @@ class FeaturesResponse(BaseModel):
knowledge_base: KnowledgeBaseFeature knowledge_base: KnowledgeBaseFeature
scheduled_tasks: ScheduledTasksFeature scheduled_tasks: ScheduledTasksFeature
thread_activity: ThreadActivityFeature thread_activity: ThreadActivityFeature
run_inputs: bool = False
tool_approval: ToolApprovalFeature = Field(default_factory=ToolApprovalFeature)
@router.get( @router.get(
@ -114,11 +104,8 @@ class FeaturesResponse(BaseModel):
async def list_features(request: Request, config: AppConfig = Depends(get_config)) -> FeaturesResponse: async def list_features(request: Request, config: AppConfig = Depends(get_config)) -> FeaturesResponse:
"""Return availability of optional frontend features.""" """Return availability of optional frontend features."""
browser = browser_capability(config) browser = browser_capability(config)
approval = getattr(config, "hitl_approval", None)
subagent_batch_worker_running = bool(getattr(request.app.state, "subagent_batches_available", False)) subagent_batch_worker_running = bool(getattr(request.app.state, "subagent_batches_available", False))
return FeaturesResponse( return FeaturesResponse(
tool_approval=ToolApprovalFeature(enabled=approval.enabled, default_mode=approval.default_mode, allowed_modes=approval.allowed_modes) if approval is not None else ToolApprovalFeature(),
run_inputs=getattr(getattr(request.app.state, "run_manager", None), "input_store", None) is not None,
agents_api=AgentsApiFeature(enabled=config.agents_api.enabled), agents_api=AgentsApiFeature(enabled=config.agents_api.enabled),
browser_control=BrowserControlFeature(enabled=browser.available), browser_control=BrowserControlFeature(enabled=browser.available),
# MCP task bindings and the submitter are startup-scoped. Report the # MCP task bindings and the submitter are startup-scoped. Report the

View File

@ -14,7 +14,6 @@ from pydantic import BaseModel, Field
from app.gateway.authz import require_permission from app.gateway.authz import require_permission
from app.gateway.deps import get_current_user, get_feedback_repo, get_run_store from app.gateway.deps import get_current_user, get_feedback_repo, get_run_store
from deerflow.persistence.feedback import FeedbackAlreadyExistsError
from deerflow.utils.thread_id import ThreadId from deerflow.utils.thread_id import ThreadId
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -134,7 +133,6 @@ async def create_feedback(
raise HTTPException(status_code=404, detail=f"Run {run_id} not found in thread {thread_id}") raise HTTPException(status_code=404, detail=f"Run {run_id} not found in thread {thread_id}")
feedback_repo = get_feedback_repo(request) feedback_repo = get_feedback_repo(request)
try:
return await feedback_repo.create( return await feedback_repo.create(
run_id=run_id, run_id=run_id,
thread_id=thread_id, thread_id=thread_id,
@ -143,11 +141,6 @@ async def create_feedback(
message_id=body.message_id, message_id=body.message_id,
comment=body.comment, comment=body.comment,
) )
except FeedbackAlreadyExistsError:
raise HTTPException(
status_code=409,
detail="Feedback already exists for this run; use PUT to update it",
) from None
@router.get("/{thread_id}/runs/{run_id}/feedback", response_model=list[FeedbackResponse]) @router.get("/{thread_id}/runs/{run_id}/feedback", response_model=list[FeedbackResponse])

View File

@ -17,7 +17,7 @@ from deerflow.config.agents_config import load_agent_config
from deerflow.config.app_config import AppConfig from deerflow.config.app_config import AppConfig
from deerflow.knowledge import KnowledgeRequestContext, RetrievalCatalogPage from deerflow.knowledge import KnowledgeRequestContext, RetrievalCatalogPage
from deerflow.knowledge.providers import get_knowledge_capabilities, resolve_knowledge_provider from deerflow.knowledge.providers import get_knowledge_capabilities, resolve_knowledge_provider
from deerflow.runtime.user_context import get_authenticated_user_id, get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"]) router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
@ -94,8 +94,7 @@ async def list_retrieval_catalog_datasets(
config: AppConfig = Depends(get_config), config: AppConfig = Depends(get_config),
) -> dict[str, Any]: ) -> dict[str, Any]:
provider = await _scope_catalog(config, agent_name, request, "catalog") provider = await _scope_catalog(config, agent_name, request, "catalog")
context = KnowledgeRequestContext(user_id=get_effective_user_id(), agent_name=agent_name, authenticated_user_id=get_authenticated_user_id()) return await _catalog_result(provider.catalog(context=KnowledgeRequestContext(user_id=get_effective_user_id(), agent_name=agent_name), page=page, page_size=page_size, search=search))
return await _catalog_result(provider.catalog(context=context, page=page, page_size=page_size, search=search))
@router.get("/retrieval-catalog/datasets/{dataset_id:path}/documents") @router.get("/retrieval-catalog/datasets/{dataset_id:path}/documents")
@ -110,5 +109,4 @@ async def list_retrieval_catalog_documents(
config: AppConfig = Depends(get_config), config: AppConfig = Depends(get_config),
) -> dict[str, Any]: ) -> dict[str, Any]:
provider = await _scope_catalog(config, agent_name, request, "documents") provider = await _scope_catalog(config, agent_name, request, "documents")
context = KnowledgeRequestContext(user_id=get_effective_user_id(), agent_name=agent_name, authenticated_user_id=get_authenticated_user_id()) return await _catalog_result(provider.documents(dataset_id, context=KnowledgeRequestContext(user_id=get_effective_user_id(), agent_name=agent_name), page=page, page_size=page_size, search=search))
return await _catalog_result(provider.documents(dataset_id, context=context, page=page, page_size=page_size, search=search))

View File

@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator from pydantic import BaseModel, ConfigDict, model_validator
from app.gateway.deps import require_admin_user from app.gateway.deps import require_admin_user
from app.gateway.persistent_writes import run_drained_write from app.gateway.persistent_writes import run_drained_write
@ -180,100 +180,3 @@ async def test_model(request: Request, body: SaveModelRequest):
except Exception as exc: except Exception as exc:
# Provider exception strings may contain keys, URLs or response bodies. # Provider exception strings may contain keys, URLs or response bodies.
return {"ok": False, "message": "authentication_failed" if getattr(exc, "status_code", None) in (401, 403) else "connection_failed"} return {"ok": False, "message": "authentication_failed" if getattr(exc, "status_code", None) in (401, 403) else "connection_failed"}
class ModelConnection(BaseModel):
"""A transient connection draft; credentials never enter a browser query cache."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
provider: str = "openai"
base_url: str | None = None
api_key: SecretStr | None = None
saved_model_name: str | None = Field(default=None, max_length=100)
expected_revision: str | None = Field(default=None, max_length=100)
@model_validator(mode="after")
def valid_connection(self):
ManagedModel(provider=self.provider, base_url=self.base_url, api_key=self.api_key, model="catalog-check")
if bool(self.saved_model_name) != bool(self.expected_revision):
raise ValueError("Saved connection requires its revision")
return self
class ImportModelRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
connection: ModelConnection
model: str = Field(min_length=1, max_length=200)
@field_validator("model")
@classmethod
def valid_id(cls, value):
if value != value.strip():
raise ValueError("Invalid model ID")
return ManagedModel.valid_model_id(value)
def _connection_profile(body: ModelConnection, model: str = "catalog-check") -> ManagedModel:
previous = None
if body.saved_model_name:
previous = next((item for item in ManagedModelStore().list() if item.name == body.saved_model_name), None)
if previous is None or previous.revision != body.expected_revision:
raise HTTPException(409, "Connection changed; reload before continuing")
profile = ManagedModel(provider=body.provider, base_url=body.base_url, api_key=body.api_key, model=model)
profile = profile.model_copy(update={"api_key": retained_api_key(profile, previous)})
if profile.provider != "openai-compatible" and not (profile.api_key and profile.api_key.get_secret_value()):
raise HTTPException(422, {"code": "missing_api_key", "message": "Enter the connection API key"})
return profile
def _same_connection(left: ManagedModel, right: ManagedModel) -> bool:
return (left.provider, left.base_url, left.api_key) == (right.provider, right.base_url, right.api_key)
def _existing_import(profile: ManagedModel) -> ManagedModel | None:
return next((item for item in ManagedModelStore().list() if item.model == profile.model and _same_connection(item, profile)), None)
@router.post("/discover")
async def discover_models(request: Request, body: ModelConnection):
from app.gateway.model_discovery import DiscoveryError, fetch_model_catalog
await require_admin_user(request, detail=_ADMIN)
try:
profile = await asyncio.to_thread(_connection_profile, body)
ids, truncated = await fetch_model_catalog(profile)
records = await asyncio.to_thread(lambda: ManagedModelStore().list())
configured = {item.model for item in records if _same_connection(item, profile)}
return {"models": [{"id": mid, "configured": mid in configured} for mid in ids], "truncated": truncated}
except DiscoveryError as exc:
raise HTTPException(502, {"code": exc.code, "message": "Could not retrieve the model catalog"}) from None
except (OSError, ValueError):
raise HTTPException(503, "Managed model storage is unavailable") from None
def _save_import(profile: ManagedModel, connection: ModelConnection):
config = get_app_config()
reserved = {item.name for item in config.models if item.name not in config._managed_model_names}
source = (connection.saved_model_name, connection.expected_revision) if connection.saved_model_name and connection.expected_revision else None
try:
return ManagedModelStore().save(profile, expected_revision=None, reserved_names=reserved, deduplicate=True, expected_source=source).public()
except FileExistsError:
raise HTTPException(409, "Connection changed; reload before importing") from None
@router.post("/import")
async def import_model(request: Request, body: ImportModelRequest):
"""One bounded, retry-safe item. The UI sequences items and reports partial success."""
await require_admin_user(request, detail=_ADMIN)
try:
profile = await asyncio.to_thread(_connection_profile, body.connection, body.model)
existing = await asyncio.to_thread(_existing_import, profile)
if existing:
return {"status": "existing", "model": existing.public()}
probe = await test_model(request, SaveModelRequest(config=profile))
if not probe["ok"]:
return {"status": "failed", "message": probe["message"]}
saved = await run_drained_write("Import managed model", _save_import, (HTTPException,), profile, body.connection)
return {"status": "saved", "model": saved}
except (OSError, ValueError):
raise HTTPException(503, "Managed model storage is unavailable") from None

View File

@ -15,7 +15,6 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
from app.gateway.deps import require_admin_user from app.gateway.deps import require_admin_user
from app.gateway.mcp_oauth_tokens import forget_unconfigured_mcp_oauth_tokens from app.gateway.mcp_oauth_tokens import forget_unconfigured_mcp_oauth_tokens
from app.gateway.persistent_writes import run_drained_write from app.gateway.persistent_writes import run_drained_write
from app.gateway.secret_masking import MASKED_VALUE, mask_secret_values, normalize_config_key
from deerflow.config.extensions_config import ( from deerflow.config.extensions_config import (
ExtensionsConfig, ExtensionsConfig,
McpRoutingConfig, McpRoutingConfig,
@ -458,7 +457,6 @@ class McpServerConfigResponse(BaseModel):
url: str | None = Field(default=None, description="URL of the MCP server (for sse or http type)") url: str | None = Field(default=None, description="URL of the MCP server (for sse or http type)")
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)") headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
oauth: McpOAuthConfigResponse | None = Field(default=None, description="OAuth configuration for MCP HTTP/SSE servers") oauth: McpOAuthConfigResponse | None = Field(default=None, description="OAuth configuration for MCP HTTP/SSE servers")
agent_auth: bool = Field(default=False, description="Require an administrator-granted agent credential on this deployment HTTP server")
user_auth: McpUserScopedAuthConfigResponse | None = Field(default=None, description="Per-user credential injection for MCP HTTP/SSE servers") user_auth: McpUserScopedAuthConfigResponse | None = Field(default=None, description="Per-user credential injection for MCP HTTP/SSE servers")
headers_from_context: McpContextHeadersConfigResponse | None = Field(default=None, description="Per-request credential injection for MCP HTTP/SSE servers: map header names to config.context.secrets keys") headers_from_context: McpContextHeadersConfigResponse | None = Field(default=None, description="Per-request credential injection for MCP HTTP/SSE servers: map header names to config.context.secrets keys")
description: str = Field(default="", description="Human-readable description of what this MCP server provides") description: str = Field(default="", description="Human-readable description of what this MCP server provides")
@ -554,19 +552,29 @@ class McpCacheResetResponse(BaseModel):
message: str = Field(description="Human-readable reset status") message: str = Field(description="Human-readable reset status")
_MASKED_VALUE = MASKED_VALUE _MASKED_VALUE = "***"
_SENSITIVE_EXTRA_KEY_RE = re.compile( _SENSITIVE_EXTRA_KEY_RE = re.compile(
r"(^|_)(api_key|apikey|access_key|private_key|client_secret|secret|token|password|passwd|credential|credentials|authorization|bearer)(_|$)", r"(^|_)(api_key|apikey|access_key|private_key|client_secret|secret|token|password|passwd|credential|credentials|authorization|bearer)(_|$)",
re.IGNORECASE, re.IGNORECASE,
) )
def _normalize_config_key(key: str) -> str:
with_boundaries = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", key)
with_boundaries = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", with_boundaries)
return re.sub(r"[^a-z0-9]+", "_", with_boundaries.lower()).strip("_")
def _is_sensitive_extra_key(key: str) -> bool: def _is_sensitive_extra_key(key: str) -> bool:
return bool(_SENSITIVE_EXTRA_KEY_RE.search(normalize_config_key(key))) return bool(_SENSITIVE_EXTRA_KEY_RE.search(_normalize_config_key(key)))
def _mask_sensitive_extra_value(value: Any) -> Any: def _mask_sensitive_extra_value(value: Any) -> Any:
return mask_secret_values(value, _is_sensitive_extra_key) if isinstance(value, dict):
return {key: _MASKED_VALUE if _is_sensitive_extra_key(str(key)) else _mask_sensitive_extra_value(nested) for key, nested in value.items()}
if isinstance(value, list):
return [_mask_sensitive_extra_value(item) for item in value]
return value
def _contains_masked_sensitive_extra_value(key: str, value: Any) -> bool: def _contains_masked_sensitive_extra_value(key: str, value: Any) -> bool:
@ -1107,9 +1115,6 @@ def _merge_preserving_secrets(
} }
if preserve_omitted_fields and "user_auth" not in incoming.model_fields_set: if preserve_omitted_fields and "user_auth" not in incoming.model_fields_set:
update["user_auth"] = existing.user_auth update["user_auth"] = existing.user_auth
# Changing an ordinary field must not silently remove credential enforcement.
if "agent_auth" not in incoming.model_fields_set:
update["agent_auth"] = existing.agent_auth
if preserve_omitted_fields and "headers_from_context" not in incoming.model_fields_set: if preserve_omitted_fields and "headers_from_context" not in incoming.model_fields_set:
update["headers_from_context"] = existing.headers_from_context update["headers_from_context"] = existing.headers_from_context
if preserve_omitted_fields and "routing" not in incoming.model_fields_set: if preserve_omitted_fields and "routing" not in incoming.model_fields_set:

View File

@ -1,7 +1,6 @@
"""Memory API router for retrieving and managing global memory data.""" """Memory API router for retrieving and managing global memory data."""
import asyncio import asyncio
import re
from typing import Any, Literal from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
@ -10,13 +9,11 @@ from pydantic import BaseModel, Field, field_validator
from app.gateway.authz import require_permission from app.gateway.authz import require_permission
from app.gateway.internal_auth import get_trusted_internal_owner_user_id from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.persistent_writes import run_drained_write from app.gateway.persistent_writes import run_drained_write
from app.gateway.secret_masking import mask_secret_values, normalize_config_key from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError, MemoryManager, get_memory_manager
from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError, MemoryDuplicateFactIdError, MemoryManager, get_memory_manager
from deerflow.config.agents_config import AGENT_NAME_PATTERN from deerflow.config.agents_config import AGENT_NAME_PATTERN
from deerflow.config.memory_config import get_memory_config from deerflow.config.memory_config import get_memory_config
from deerflow.config.paths import make_safe_user_id from deerflow.config.paths import make_safe_user_id
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.host_paths import windows_incompatible_segment
router = APIRouter(prefix="/api", tags=["memory"]) router = APIRouter(prefix="/api", tags=["memory"])
@ -120,12 +117,6 @@ class MemoryResponse(BaseModel):
facts: list[Fact] = Field(default_factory=list) facts: list[Fact] = Field(default_factory=list)
class MemoryImportRequest(MemoryResponse):
"""Replacement imports must explicitly supply the facts to restore."""
facts: list[Fact] = Field(description="Facts to restore. An empty list explicitly clears the selected fact bucket.")
def _map_memory_fact_value_error(exc: ValueError) -> HTTPException: def _map_memory_fact_value_error(exc: ValueError) -> HTTPException:
"""Convert updater validation errors into stable API responses.""" """Convert updater validation errors into stable API responses."""
if exc.args and exc.args[0] == "confidence": if exc.args and exc.args[0] == "confidence":
@ -168,8 +159,6 @@ def _unsupported_501(manager: object, label: str) -> HTTPException:
def _management_agent_name_or_501( def _management_agent_name_or_501(
manager: MemoryManager, manager: MemoryManager,
agent_name: str | None, agent_name: str | None,
*,
for_creation: bool = False,
) -> str | None: ) -> str | None:
"""Validate an optional agent-scoped management target. """Validate an optional agent-scoped management target.
@ -179,16 +168,6 @@ def _management_agent_name_or_501(
into a mutation of the wrong bucket. Preserve the caller's spelling here: into a mutation of the wrong bucket. Preserve the caller's spelling here:
remote backends such as Mem0 use the agent name as a case-sensitive storage remote backends such as Mem0 use the agent name as a case-sensitive storage
identity, while DeerMem performs its own lowercase canonicalization. identity, while DeerMem performs its own lowercase canonicalization.
``for_creation`` also refuses names that cannot become a host path
component. DeerMem resolves an agent scope to
``{storage}/users/{uid}/agents/{name}/facts`` and creates it on the first
write; the character class above admits Windows reserved device names
(``NUL``, ``CON``, ``COM1``, ...), so that write raised
``FileNotFoundError`` and the endpoint's ``OSError`` branch answered
``500``. Only the two endpoints that create the scope set this: reads and
deletes stay permissive so a scope an older release stored can still be
exported and cleared.
""" """
if agent_name is None: if agent_name is None:
return None return None
@ -197,20 +176,6 @@ def _management_agent_name_or_501(
status_code=422, status_code=422,
detail=(f"Invalid agent name '{agent_name}'. Must match {AGENT_NAME_PATTERN.pattern} (letters, digits, and hyphens only)."), detail=(f"Invalid agent name '{agent_name}'. Must match {AGENT_NAME_PATTERN.pattern} (letters, digits, and hyphens only)."),
) )
if for_creation:
# Reuse the canonical Windows-name check that agent directories,
# thread IDs, uploads, sandbox writes and skill support files use.
# Sibling PR #7939 applies the same rule inside the shared
# ``validate_agent_name()`` (``deerflow.config.agents_config``); this
# router inlines ``AGENT_NAME_PATTERN`` and preserves caller spelling for
# case-sensitive backends rather than delegating to it, so keep the two
# copies in sync if the reserved set ever changes.
reason = windows_incompatible_segment(agent_name)
if reason is not None:
raise HTTPException(
status_code=422,
detail=f"Invalid agent name '{agent_name}'. Contains a {reason}.",
)
if getattr(manager, "supports_agent_scoped_management", False) is not True: if getattr(manager, "supports_agent_scoped_management", False) is not True:
raise HTTPException( raise HTTPException(
status_code=501, status_code=501,
@ -269,18 +234,6 @@ class FactPatchRequest(BaseModel):
confidence: float | None = Field(default=None, ge=0.0, le=1.0, description="Confidence score (0-1)") confidence: float | None = Field(default=None, ge=0.0, le=1.0, description="Confidence score (0-1)")
# Anchored at the end of the key: DeerMem knobs such as ``token_counting`` and
# ``guaranteed_token_budget`` are not secrets, and ``api_key_env`` names an
# environment variable rather than holding the key. This is a denylist: plural
# ``tokens`` stays out because ``max_injection_tokens`` is a knob, and bare
# ``key``, ``auth`` or ``session`` are too generic to mask.
_SECRET_CONFIG_KEY_RE = re.compile(r"(^|_)(api_?keys?|access_keys?|secret_keys?|private_keys?|auth_keys?|secrets?|token|passwords?|passwds?|credentials?|authorization|bearer|cookies?)$")
def _is_secret_config_key(key: str) -> bool:
return bool(_SECRET_CONFIG_KEY_RE.search(normalize_config_key(key)))
class MemoryConfigResponse(BaseModel): class MemoryConfigResponse(BaseModel):
"""Response model for memory configuration.""" """Response model for memory configuration."""
@ -289,13 +242,7 @@ class MemoryConfigResponse(BaseModel):
injection_enabled: bool = Field(..., description="Whether memory is injected into the system prompt (call-site gate).") injection_enabled: bool = Field(..., description="Whether memory is injected into the system prompt (call-site gate).")
shutdown_flush_timeout_seconds: float = Field(..., description="Hard budget (s) to drain pending memory updates on Gateway graceful shutdown; must fit inside the pod's K8s terminationGracePeriodSeconds.") shutdown_flush_timeout_seconds: float = Field(..., description="Hard budget (s) to drain pending memory updates on Gateway graceful shutdown; must fit inside the pod's K8s terminationGracePeriodSeconds.")
manager_class: str = Field(..., description="Active memory backend selector (backend name or dotted path).") manager_class: str = Field(..., description="Active memory backend selector (backend name or dotted path).")
backend_config: dict = Field(..., description="Backend-private config (self-interpreted by the backend); credential values such as api_key are masked as ***.") backend_config: dict = Field(..., description="Backend-private config (self-interpreted by the backend).")
@field_validator("backend_config")
@classmethod
def _mask_backend_secrets(cls, value: dict) -> dict:
"""Config loading already resolved ``$VAR`` references, so keys would be plaintext."""
return mask_secret_values(value, _is_secret_config_key, keep_unset=True)
class MemoryStatusResponse(BaseModel): class MemoryStatusResponse(BaseModel):
@ -445,7 +392,7 @@ async def clear_memory(request: Request, agent_name: str | None = None) -> Memor
async def create_memory_fact_endpoint(body: FactCreateRequest, request: Request, agent_name: str | None = None) -> MemoryResponse: async def create_memory_fact_endpoint(body: FactCreateRequest, request: Request, agent_name: str | None = None) -> MemoryResponse:
"""Create a single fact manually.""" """Create a single fact manually."""
manager = await asyncio.to_thread(get_memory_manager) manager = await asyncio.to_thread(get_memory_manager)
selected_agent = _management_agent_name_or_501(manager, agent_name, for_creation=True) selected_agent = _management_agent_name_or_501(manager, agent_name)
scope_kwargs = _agent_scope_kwargs(selected_agent) scope_kwargs = _agent_scope_kwargs(selected_agent)
try: try:
memory_data, fact_id = await run_drained_write( memory_data, fact_id = await run_drained_write(
@ -572,16 +519,13 @@ async def export_memory(request: Request, agent_name: str | None = None) -> Memo
response_model=MemoryResponse, response_model=MemoryResponse,
response_model_exclude_none=True, response_model_exclude_none=True,
summary="Import Memory Data", summary="Import Memory Data",
description=( description="Import the default memory document, or replace only one selected agent's facts while preserving shared summaries.",
"Import the default memory document, or replace only one selected agent's facts while preserving shared summaries. "
"The request requires an explicit `facts` array. Omitting it returns HTTP 422, and an empty array clears the selected fact bucket."
),
) )
@require_permission("memory", "write") @require_permission("memory", "write")
async def import_memory(body: MemoryImportRequest, request: Request, agent_name: str | None = None) -> MemoryResponse: async def import_memory(body: MemoryResponse, request: Request, agent_name: str | None = None) -> MemoryResponse:
"""Import memory data, preserving shared summaries for a selected agent.""" """Import memory data, preserving shared summaries for a selected agent."""
manager = await asyncio.to_thread(get_memory_manager) manager = await asyncio.to_thread(get_memory_manager)
selected_agent = _management_agent_name_or_501(manager, agent_name, for_creation=True) selected_agent = _management_agent_name_or_501(manager, agent_name)
scope_kwargs = _agent_scope_kwargs(selected_agent) scope_kwargs = _agent_scope_kwargs(selected_agent)
try: try:
memory_data = await run_drained_write( memory_data = await run_drained_write(
@ -596,8 +540,6 @@ async def import_memory(body: MemoryImportRequest, request: Request, agent_name:
raise _unsupported_501(manager, "import memory") from None raise _unsupported_501(manager, "import memory") from None
except (MemoryConflictError, MemoryCorruptionError) as exc: except (MemoryConflictError, MemoryCorruptionError) as exc:
raise _map_memory_manager_error(exc) from exc raise _map_memory_manager_error(exc) from exc
except MemoryDuplicateFactIdError as exc:
raise HTTPException(status_code=400, detail="Invalid memory import: fact IDs must be unique.") from exc
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid memory import: facts must be a list of objects with non-empty content.") from exc raise HTTPException(status_code=400, detail="Invalid memory import: facts must be a list of objects with non-empty content.") from exc
except OSError as exc: except OSError as exc:

View File

@ -66,7 +66,6 @@ class ModelResponse(BaseModel):
description: str | None = Field(None, description="Model description") description: str | None = Field(None, description="Model description")
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode (deprecated: derived from `reasoning`)") supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode (deprecated: derived from `reasoning`)")
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort (deprecated: derived from `reasoning`)") supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort (deprecated: derived from `reasoning`)")
provider: str | None = Field(default=None, description="Managed provider display label; no endpoint or credentials")
reasoning: ReasoningCapabilitiesResponse = Field(..., description="Normalized reasoning capability contract") reasoning: ReasoningCapabilitiesResponse = Field(..., description="Normalized reasoning capability contract")
@ -76,7 +75,6 @@ def _model_response(model: ModelConfig) -> ModelResponse:
model=model.model, model=model.model,
display_name=model.display_name, display_name=model.display_name,
description=model.description, description=model.description,
provider=model._managed_provider,
supports_thinking=model.supports_thinking, supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort, supports_reasoning_effort=model.supports_reasoning_effort,
reasoning=ReasoningCapabilitiesResponse(**reasoning_capabilities_payload(resolve_reasoning_contract(model))), reasoning=ReasoningCapabilitiesResponse(**reasoning_capabilities_payload(resolve_reasoning_contract(model))),
@ -197,7 +195,7 @@ async def list_models(
owner = await _resolve_bound_owner_for_model_list(request) owner = await _resolve_bound_owner_for_model_list(request)
principal_user = owner if owner is not None else user principal_user = owner if owner is not None else user
try: try:
provider, principal = await asyncio.to_thread(resolve_model_authorization, principal_user, is_internal=_is_internal_caller(request, user)) provider, principal = resolve_model_authorization(principal_user, is_internal=_is_internal_caller(request, user))
except _AuthorizationUnavailable as exc: except _AuthorizationUnavailable as exc:
if exc.fail_closed: if exc.fail_closed:
visible_models = [] visible_models = []
@ -263,6 +261,6 @@ async def get_model(
# model exists but the role lacks permission to use it). # model exists but the role lacks permission to use it).
user = await get_optional_user_from_request(request) user = await get_optional_user_from_request(request)
if user is not None: if user is not None:
await asyncio.to_thread(authorize_model_use, user, model_name, is_internal=_is_internal_caller(request, user), app_config=config) authorize_model_use(user, model_name, is_internal=_is_internal_caller(request, user), app_config=config)
return _model_response(model) return _model_response(model)

View File

@ -45,8 +45,6 @@ def _read_personal_config(user_id: str) -> dict:
def _validate_personal_server(server: mcp.McpServerConfigResponse, *, admin: bool) -> None: def _validate_personal_server(server: mcp.McpServerConfigResponse, *, admin: bool) -> None:
if server.agent_auth:
raise HTTPException(422, "agent_auth is available only for deployment MCP servers")
# A personal store must not grant ordinary users the deployment operator's # A personal store must not grant ordinary users the deployment operator's
# ability to launch arbitrary host packages or query internal services. # ability to launch arbitrary host packages or query internal services.
if not admin: if not admin:

View File

@ -70,7 +70,6 @@ async def list_plugins(request: Request, response: Response):
principal = _principal(request) principal = _principal(request)
response.headers["Cache-Control"] = "private, no-store" response.headers["Cache-Control"] = "private, no-store"
entries = [] entries = []
documentation = dict(request.app.state.extensions.documentation)
for source, plugin in request.app.state.extensions.plugins: for source, plugin in request.app.state.extensions.plugins:
settings = plugin_settings(source, plugin) settings = plugin_settings(source, plugin)
module = plugin.frontend module = plugin.frontend
@ -94,36 +93,8 @@ async def list_plugins(request: Request, response: Response):
"transport": transport, "transport": transport,
"settings": {key: settings[key] for key in public}, "settings": {key: settings[key] for key in public},
"backend_actions": [action.name for action in plugin.backend], "backend_actions": [action.name for action in plugin.backend],
"model_tools": [tool.name for tool in plugin.tools],
"documentation": documentation.get(source) if documentation.get(source, {}).get("id") == plugin.namespace else None,
} }
) )
registered_sources = {source for source, _ in request.app.state.extensions.plugins}
known_ids = {entry["namespace"] for entry in entries}
for source, page in request.app.state.extensions.documentation:
if source in registered_sources or page["id"] in known_ids:
continue
locales = page["locales"]
copy = next((value for tag, value in locales.items() if tag.lower() == "en-us"), None)
if copy is None:
copy = next((value for tag, value in locales.items() if tag.partition("-")[0] == "en"), next(iter(locales.values())))
entries.append(
{
"namespace": page["id"],
"title": copy["title"],
"description": copy["description"],
"viewer_id": principal.user_id,
"module": None,
"entry": None,
"transport": None,
"settings": {"enabled": True},
"backend_actions": [],
"model_tools": [],
"documentation": page,
"runtime_only": True,
}
)
known_ids.add(page["id"])
return entries return entries

View File

@ -1,6 +1,5 @@
"""CRUD API for projects (Phase 1: organization only — no documents/trash).""" """CRUD API for projects (Phase 1: organization only — no documents/trash)."""
import asyncio
import logging import logging
from typing import Any, Literal from typing import Any, Literal
@ -9,12 +8,9 @@ from pydantic import BaseModel, Field, field_validator
from app.gateway.authz import require_permission from app.gateway.authz import require_permission
from app.gateway.deps import get_config, get_project_repo, get_thread_store from app.gateway.deps import get_config, get_project_repo, get_thread_store
from app.gateway.persistent_writes import run_drained_write
from deerflow.config.app_config import AppConfig, get_app_config from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.config.projects_config import ProjectsConfig from deerflow.config.projects_config import ProjectsConfig
from deerflow.projects.memory import MAX_FACTS, get_project_memory_repository
from deerflow.runtime.secret_context import redact_metadata_secrets from deerflow.runtime.secret_context import redact_metadata_secrets
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.time import coerce_iso from deerflow.utils.time import coerce_iso
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -49,72 +45,6 @@ class ProjectListResponse(BaseModel):
projects: list[ProjectResponse] projects: list[ProjectResponse]
class ProjectMemoryFactResponse(BaseModel):
id: str
content: str
source_thread_id: str
created_at: str
class ProjectMemoryResponse(BaseModel):
enabled: bool
facts: list[ProjectMemoryFactResponse]
max_facts: int = MAX_FACTS
class ProjectMemoryPatch(BaseModel):
enabled: bool
class ProjectMemoryPromotion(BaseModel):
expected_instructions: str
class ProjectMemoryPromotionResponse(BaseModel):
instructions: str
memory: ProjectMemoryResponse
async def _memory_call(method: str, project_id: str, *args, **kwargs):
user_id = get_effective_user_id()
def call():
return getattr(get_project_memory_repository(), method)(project_id, user_id, *args, **kwargs)
result = await asyncio.to_thread(call) if method == "get" else await run_drained_write("Project memory update", call, (ValueError,))
if result is None:
raise _not_found()
return result
@router.get("/{project_id}/memory", response_model=ProjectMemoryResponse)
@require_permission("projects", "read")
async def get_project_memory(project_id: str, request: Request) -> ProjectMemoryResponse:
return ProjectMemoryResponse(**await _memory_call("get", project_id))
@router.patch("/{project_id}/memory", response_model=ProjectMemoryResponse)
@require_permission("projects", "write")
async def patch_project_memory(project_id: str, body: ProjectMemoryPatch, request: Request) -> ProjectMemoryResponse:
return ProjectMemoryResponse(**await _memory_call("set_enabled", project_id, body.enabled))
@router.delete("/{project_id}/memory/{fact_id}", response_model=ProjectMemoryResponse)
@require_permission("projects", "write")
async def delete_project_memory(project_id: str, fact_id: str, request: Request) -> ProjectMemoryResponse:
return ProjectMemoryResponse(**await _memory_call("delete", project_id, fact_id))
@router.post("/{project_id}/memory/{fact_id}/promote", response_model=ProjectMemoryPromotionResponse)
@require_permission("projects", "write")
async def promote_project_memory(project_id: str, fact_id: str, body: ProjectMemoryPromotion, request: Request) -> ProjectMemoryPromotionResponse:
try:
result = await _memory_call("promote", project_id, fact_id, max_bytes=_projects_config().instructions_max_bytes, expected_instructions=body.expected_instructions)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return ProjectMemoryPromotionResponse(instructions=result["instructions"], memory=ProjectMemoryResponse(**result["memory"]))
class ProjectsConfigResponse(BaseModel): class ProjectsConfigResponse(BaseModel):
"""The projects-block knobs the UI needs before it can validate client-side.""" """The projects-block knobs the UI needs before it can validate client-side."""

View File

@ -1,160 +0,0 @@
"""Workspace search and direct navigation to an owner-visible message."""
from __future__ import annotations
import base64
import hashlib
import json
import logging
from dataclasses import asdict
from typing import Literal
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.deps import get_run_event_store, get_run_manager, get_thread_store
from app.gateway.search_corpus import GatewaySearchCorpus
from deerflow.utils.thread_id import ThreadId
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["search"])
class WorkspaceSearchRequest(BaseModel):
query: str = Field(default="", max_length=256)
types: list[Literal["thread", "message"]] = Field(default_factory=lambda: ["thread", "message"], min_length=1, max_length=2)
archived: bool | None = Field(default=None, strict=True)
project_id: str | None = Field(default=None, max_length=64)
limit: int = Field(default=20, ge=1, le=100)
cursor: str | None = Field(default=None, max_length=4096)
def snippet(text: str, query: str) -> list[dict]:
"""Plain text only. Map case-fold expansions back to source characters."""
normalized = text.casefold()
position = normalized.find(query.casefold()) if query else -1
if position < 0:
return [{"text": text[:200] + ("…" if len(text) > 200 else "")}]
offsets = []
for index, character in enumerate(text):
offsets.extend([index] * len(character.casefold()))
if len(offsets) >= position + len(query.casefold()):
break
start, end = offsets[position], offsets[position + len(query.casefold()) - 1] + 1
left, right = max(0, start - 65), min(len(text), end + 110)
return [{"text": ("…" if left else "") + text[left:start]}, {"text": text[start:end], "matched": True}, {"text": text[end:right] + ("…" if right < len(text) else "")}]
def _corpus(request: Request) -> GatewaySearchCorpus:
return GatewaySearchCorpus(get_thread_store(request), get_run_event_store(request), get_run_manager(request), str(request.state.auth.require_user().id), request=request)
def _encode_cursor(scope: str, phase: str, position) -> str:
return base64.urlsafe_b64encode(json.dumps([scope, phase, position]).encode()).decode()
@router.post("/search")
@require_permission("threads", "read")
async def search_workspace(body: WorkspaceSearchRequest, request: Request):
corpus = _corpus(request)
query = body.query.strip()
scope = hashlib.sha256(json.dumps([corpus.user_id, query, sorted(set(body.types)), body.archived, body.project_id, "project_id" in body.model_fields_set]).encode()).hexdigest()
phase, position = "thread", None
if body.cursor:
try:
saved_scope, phase, position = json.loads(base64.urlsafe_b64decode(body.cursor))
if saved_scope != scope or phase not in {"thread", "message"}:
raise ValueError
if phase == "thread" and (not isinstance(position, list) or len(position) != 3 or not isinstance(position[0], int) or not all(isinstance(v, str) for v in position[1:])):
raise ValueError
if phase == "message" and position is not None and not isinstance(position, str):
raise ValueError
except (ValueError, TypeError) as exc:
raise HTTPException(400, "Invalid search cursor") from exc
all_threads = await corpus.threads()
threads = tuple(t for t in all_threads if (body.archived is None or t.archived == body.archived) and ("project_id" not in body.model_fields_set or t.project_id == body.project_id))
lookup = {t.thread_id: t for t in threads}
providers = getattr(getattr(request.app.state, "extensions", None), "workspace_search_providers", ())
messages_available = bool(providers) and request.state.auth.has_permission("runs", "read")
response = {"items": [], "cursor": None, "indexing": False, "messages_available": messages_available}
def thread_hit(thread):
return {
"type": "thread",
"thread_id": thread.thread_id,
"title": thread.title,
"agent_name": thread.agent_name,
"archived": thread.archived,
"project_id": thread.project_id,
"updated_at": thread.updated_at,
"snippet": snippet(thread.title, query),
}
if phase == "thread" and "thread" in body.types:
def key(thread):
title = thread.title.casefold()
return (2 if query and title == query.casefold() else 1, thread.updated_at, thread.thread_id)
matches = sorted((t for t in threads if not query or query.casefold() in t.title.casefold()), key=key, reverse=True)
if position is not None:
matches = [t for t in matches if key(t) < tuple(position)]
selected = matches[: body.limit]
response["items"].extend(thread_hit(t) for t in selected)
if len(matches) > body.limit:
response["cursor"] = _encode_cursor(scope, "thread", key(selected[-1]))
return response
if not query or "message" not in body.types or not messages_available:
return response
if len(response["items"]) == body.limit:
response["cursor"] = _encode_cursor(scope, "message", None)
return response
provider = providers[0][1]
provider_cursor = position if phase == "message" else None
# Invalidated candidates must not consume a visible result slot. Keep a
# bounded amount of work per HTTP page and expose continuation when needed.
for _ in range(5):
try:
page = await provider.search(corpus, query=query, threads=threads, limit=body.limit - len(response["items"]), cursor=provider_cursor)
except ValueError as exc:
raise HTTPException(400, "Invalid search cursor") from exc
except Exception:
logger.exception("Workspace search provider failed")
raise HTTPException(503, "Message search is temporarily unavailable") from None
response["indexing"] = page.indexing
for candidate in page.items:
thread = lookup.get(candidate.thread_id)
if thread is None or candidate.incarnation != thread.incarnation or not await corpus.owns(thread):
continue
# Re-read authoritative visible text before returning ANY snippet.
current = await corpus.latest_message(thread, candidate.message_id)
if current is None or current.seq != candidate.seq or query.casefold() not in current.text.casefold():
continue
hit = thread_hit(thread)
hit.update(type="message", message_id=current.message_id, seq=current.seq, role=current.role, updated_at=current.created_at, snippet=snippet(current.text, query))
response["items"].append(hit)
if page.indexing:
break
if page.cursor == provider_cursor and page.cursor is not None:
raise HTTPException(503, "Search pagination did not advance")
provider_cursor = page.cursor
if provider_cursor is None or len(response["items"]) >= body.limit:
break
if provider_cursor:
response["cursor"] = _encode_cursor(scope, "message", provider_cursor)
return response
@router.get("/threads/{thread_id}/messages/around")
@require_permission("runs", "read", owner_check=True, require_existing=True)
async def message_context(thread_id: ThreadId, request: Request, message_id: str = Query(min_length=1, max_length=512), seq: int | None = Query(default=None, ge=1), limit: int = Query(default=20, ge=1, le=50)):
corpus = _corpus(request)
window = await corpus.around(thread_id, message_id, seq=seq, limit=limit)
if window is None:
raise HTTPException(404, "Message is unavailable")
thread = await corpus.thread(thread_id)
if thread is None:
raise HTTPException(404, "Message is unavailable")
messages = [asdict(message) for row in window.pop("data") if (message := corpus.project(thread, row)) is not None]
return {**window, "messages": messages}

View File

@ -1,130 +0,0 @@
"""Owner-managed snapshots, public read capabilities, and authenticated imports."""
import logging
from datetime import UTC, datetime, timedelta
from typing import Annotated
from uuid import UUID
from deerflow_extension_api import AgentRunError
from fastapi import APIRouter, HTTPException, Path, Request
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.routers.artifacts import _build_content_disposition, _slice_byte_range
from app.gateway.services import reserve_checkpoint_write
from app.gateway.share_fork import fork_share
from app.gateway.share_snapshot import capture_share
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.thread_shares.sql import ThreadShareRepository
from deerflow.runtime import ConflictError
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.file_io import await_drained
from deerflow.utils.thread_id import ThreadId
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["shares"])
ShareToken = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]{43}$")]
PRIVATE_HEADERS = {"Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Robots-Tag": "noindex, nofollow", "X-Content-Type-Options": "nosniff"}
class ShareCreateRequest(BaseModel):
checkpoint_id: str | None = Field(default=None, max_length=128)
expires_in_seconds: int = Field(default=7 * 86400, ge=3600, le=30 * 86400)
class ShareForkRequest(BaseModel):
request_id: UUID
def share_repository() -> ThreadShareRepository:
factory = get_session_factory()
if factory is None:
raise HTTPException(503, "Sharing requires SQL persistence")
return ThreadShareRepository(factory)
@router.post("/threads/{thread_id}/shares", status_code=201)
@require_permission("threads", "write", owner_check=True, require_existing=True)
@require_permission("runs", "read")
async def create_share(thread_id: ThreadId, body: ShareCreateRequest, request: Request):
async def create():
repo = share_repository()
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
if await repo.count_active(thread_id, get_effective_user_id()) >= 10:
raise HTTPException(409, "Revoke an existing link before creating another")
captured = await capture_share(request, thread_id, body.checkpoint_id)
return await repo.create(**captured, expires_at=datetime.now(UTC) + timedelta(seconds=body.expires_in_seconds))
try:
result = await await_drained(create())
return JSONResponse(result, status_code=201, headers=PRIVATE_HEADERS)
except ConflictError:
raise HTTPException(409, "Thread has work in flight; share it after the work finishes") from None
except AgentRunError as exc:
raise HTTPException(exc.status_code, str(exc)) from None
except HTTPException:
raise
except Exception:
logger.exception("Failed to create conversation share")
raise HTTPException(500, "Unable to create share") from None
@router.get("/threads/{thread_id}/shares")
@require_permission("threads", "read", owner_check=True, require_existing=True)
async def list_shares(thread_id: ThreadId, request: Request):
return JSONResponse(await share_repository().list_owned(thread_id, get_effective_user_id()), headers=PRIVATE_HEADERS)
@router.delete("/threads/{thread_id}/shares/{share_id}", status_code=204)
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def revoke_share(thread_id: ThreadId, share_id: str, request: Request):
if not await await_drained(share_repository().revoke(thread_id, share_id, get_effective_user_id())):
raise HTTPException(404, "Share not found")
return Response(status_code=204, headers=PRIVATE_HEADERS)
@router.get("/shares/{share_token}")
async def read_share(share_token: ShareToken):
share = await share_repository().get_public(share_token)
if share is None:
raise HTTPException(404, "Share unavailable", headers=PRIVATE_HEADERS)
return JSONResponse({**share["snapshot"], "checkpoint_id": share["checkpoint_id"], "seq": share["seq"], "created_at": share["created_at"], "expires_at": share["expires_at"], "artifacts": share["artifacts"]}, headers=PRIVATE_HEADERS)
@router.get("/shares/{share_token}/artifacts/{path:path}")
@router.head("/shares/{share_token}/artifacts/{path:path}")
async def read_share_artifact(share_token: ShareToken, path: str, request: Request, download: bool = False):
artifact = await share_repository().get_artifact(share_token, path)
if artifact is None:
raise HTTPException(404, "Shared artifact unavailable", headers=PRIVATE_HEADERS)
content, status, range_headers = _slice_byte_range(artifact["content"], request.headers.get("range"))
# Even a direct tab navigation cannot run active content with our origin.
# HTML previews render in an opaque sandbox and cannot contact the network.
headers = {
**PRIVATE_HEADERS,
**range_headers,
"Content-Disposition": _build_content_disposition("attachment" if download else "inline", path.rsplit("/", 1)[-1]),
"Content-Length": str(len(content)),
}
# Chromium's built-in PDF viewer cannot load in a CSP sandbox. The PDF
# media type and nosniff keep it outside the active HTML document surface.
if artifact["mime_type"] != "application/pdf":
headers["Content-Security-Policy"] = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'"
return Response(content if request.method != "HEAD" else b"", status_code=status, media_type=artifact["mime_type"], headers=headers)
@router.post("/shares/{share_token}/fork")
@require_permission("threads", "write")
async def import_share(share_token: ShareToken, body: ShareForkRequest, request: Request):
try:
return JSONResponse(await await_drained(fork_share(request, share_repository(), share_token, str(body.request_id))), headers=PRIVATE_HEADERS)
except ConflictError:
raise HTTPException(409, "Import is already in progress; retry shortly") from None
except AgentRunError as exc:
raise HTTPException(exc.status_code, str(exc)) from None
except HTTPException:
raise
except Exception:
logger.exception("Failed to import conversation share")
raise HTTPException(500, "Unable to import share") from None

View File

@ -338,20 +338,6 @@ async def _publish_skills_cache_reset(user_id: str | None) -> str | None:
return await asyncio.to_thread(publish_skills_cache_reset, user_id=user_id) return await asyncio.to_thread(publish_skills_cache_reset, user_id=user_id)
def _assert_extensions_config_readable() -> None:
"""Raise when the extensions config exists but cannot be read.
The storage layer fails closed on an unreadable config (every skill stays
listed, none is enabled), which keeps the runtime activation gate safe
without taking the listing down. The read surface must stay loud instead:
the ``enabled`` column it reports is derived from this config, so serving
a degraded list would present a broken deployment as a legitimately empty
one. The raised error reaches each handler's generic 500 branch, which
keeps the host path in the server log and out of the response body.
"""
ExtensionsConfig.from_file()
def _load_user_skills(config: AppConfig) -> list[Skill]: def _load_user_skills(config: AppConfig) -> list[Skill]:
"""Load every skill visible to the caller's storage, enabled or not. """Load every skill visible to the caller's storage, enabled or not.
@ -361,7 +347,6 @@ def _load_user_skills(config: AppConfig) -> list[Skill]:
each ``SKILL.md`` -- work that scales with the number of installed skills each ``SKILL.md`` -- work that scales with the number of installed skills
(#5747 moved the same call off the loop for ``get_custom_skill``). (#5747 moved the same call off the loop for ``get_custom_skill``).
""" """
_assert_extensions_config_readable()
return _get_user_skill_storage(config).load_skills(enabled_only=False) return _get_user_skill_storage(config).load_skills(enabled_only=False)
@ -492,7 +477,7 @@ async def _filter_visible_skills(
return skills return skills
try: try:
provider, principal = await asyncio.to_thread(resolve_skill_authorization, user, is_internal=_is_internal_caller(request, user)) provider, principal = resolve_skill_authorization(user, is_internal=_is_internal_caller(request, user))
except _AuthorizationUnavailable as exc: except _AuthorizationUnavailable as exc:
return [] if exc.fail_closed else skills return [] if exc.fail_closed else skills

View File

@ -8,7 +8,7 @@ from collections.abc import AsyncIterator
from fastapi import APIRouter, HTTPException, Path, Query, Request from fastapi import APIRouter, HTTPException, Path, Query, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.gateway.authz import require_cancel_permission_if, require_permission from app.gateway.authz import require_permission
from app.gateway.deps import ( from app.gateway.deps import (
get_current_user, get_current_user,
get_subagent_batch_repo, get_subagent_batch_repo,
@ -110,7 +110,6 @@ async def cancel_batch(thread_id: ThreadId, batch_id: str, request: Request) ->
if not getattr(request.app.state, "subagent_batches_available", False): if not getattr(request.app.state, "subagent_batches_available", False):
raise HTTPException(status_code=503, detail="Subagent batch worker is not running") raise HTTPException(status_code=503, detail="Subagent batch worker is not running")
_repo, user_id, _batch = await _owned_batch(request, thread_id, batch_id) _repo, user_id, _batch = await _owned_batch(request, thread_id, batch_id)
require_cancel_permission_if(request, _batch.get("continuation_status") is not None)
result = await get_subagent_batch_service(request).cancel_batch(batch_id=batch_id, user_id=user_id) result = await get_subagent_batch_service(request).cancel_batch(batch_id=batch_id, user_id=user_id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail="Subagent batch not found") raise HTTPException(status_code=404, detail="Subagent batch not found")
@ -123,7 +122,7 @@ async def retry_batch_item(thread_id: ThreadId, batch_id: str, item_id: str, req
repo, user_id, _batch = await _owned_batch(request, thread_id, batch_id) repo, user_id, _batch = await _owned_batch(request, thread_id, batch_id)
item = await repo.retry_item(batch_id, item_id, user_id=user_id) item = await repo.retry_item(batch_id, item_id, user_id=user_id)
if item is None: if item is None:
raise HTTPException(status_code=409, detail="Only failed items of a batch that is not cancelled can be retried") raise HTTPException(status_code=409, detail="Only failed items can be retried")
return item return item

View File

@ -35,8 +35,6 @@ class SubagentResponse(BaseModel):
disallowed_tools: list[str] | None = None disallowed_tools: list[str] | None = None
skills: list[str] | None = None skills: list[str] | None = None
model: str = "inherit" model: str = "inherit"
thinking_enabled: bool | None = None
reasoning_effort: str | None = None
max_turns: int = 50 max_turns: int = 50
timeout_seconds: int = 900 timeout_seconds: int = 900
enabled: bool = True enabled: bool = True
@ -59,8 +57,6 @@ class ManagedSubagentCreateRequest(BaseModel):
disallowed_tools: list[str] | None = None disallowed_tools: list[str] | None = None
skills: list[str] | None = None skills: list[str] | None = None
model: str = "inherit" model: str = "inherit"
thinking_enabled: bool | None = None
reasoning_effort: str | None = None
max_turns: int = Field(default=50, ge=1) max_turns: int = Field(default=50, ge=1)
timeout_seconds: int = Field(default=900, ge=1) timeout_seconds: int = Field(default=900, ge=1)
enabled: bool = True enabled: bool = True
@ -74,8 +70,6 @@ class ManagedSubagentUpdateRequest(BaseModel):
disallowed_tools: list[str] | None = None disallowed_tools: list[str] | None = None
skills: list[str] | None = None skills: list[str] | None = None
model: str | None = None model: str | None = None
thinking_enabled: bool | None = None
reasoning_effort: str | None = None
max_turns: int | None = Field(default=None, ge=1) max_turns: int | None = Field(default=None, ge=1)
timeout_seconds: int | None = Field(default=None, ge=1) timeout_seconds: int | None = Field(default=None, ge=1)
enabled: bool | None = None enabled: bool | None = None
@ -88,9 +82,9 @@ def _validate_model(model: str, app_config) -> None:
raise HTTPException(status_code=422, detail=f"Unknown model '{model}'. Use 'inherit' or a configured model name.") raise HTTPException(status_code=422, detail=f"Unknown model '{model}'. Use 'inherit' or a configured model name.")
def _validate_path_name(name: str, *, for_creation: bool = False) -> str: def _validate_path_name(name: str) -> str:
try: try:
return normalize_managed_subagent_name(name, for_creation=for_creation) return normalize_managed_subagent_name(name)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
@ -100,7 +94,7 @@ def _explicit_overrides(name: str, app_config) -> dict[str, Any]:
if override is None: if override is None:
return {} return {}
result: dict[str, Any] = {} result: dict[str, Any] = {}
for field in ("timeout_seconds", "max_turns", "model", "thinking_enabled", "reasoning_effort", "skills"): for field in ("timeout_seconds", "max_turns", "model", "skills"):
if field not in override.model_fields_set: if field not in override.model_fields_set:
continue continue
value = getattr(override, field) value = getattr(override, field)
@ -125,8 +119,6 @@ def _catalog(include_system_prompt: bool) -> SubagentsListResponse:
disallowed_tools=definition.disallowed_tools, disallowed_tools=definition.disallowed_tools,
skills=definition.skills, skills=definition.skills,
model=definition.model, model=definition.model,
thinking_enabled=definition.thinking_enabled,
reasoning_effort=definition.reasoning_effort,
max_turns=definition.max_turns, max_turns=definition.max_turns,
timeout_seconds=definition.timeout_seconds, timeout_seconds=definition.timeout_seconds,
source="builtin", source="builtin",
@ -144,8 +136,6 @@ def _catalog(include_system_prompt: bool) -> SubagentsListResponse:
disallowed_tools=definition.disallowed_tools, disallowed_tools=definition.disallowed_tools,
skills=definition.skills, skills=definition.skills,
model=definition.model, model=definition.model,
thinking_enabled=definition.thinking_enabled,
reasoning_effort=definition.reasoning_effort,
max_turns=definition.max_turns, max_turns=definition.max_turns,
timeout_seconds=definition.timeout_seconds, timeout_seconds=definition.timeout_seconds,
source="config", source="config",
@ -181,7 +171,6 @@ async def list_subagents(request: Request) -> SubagentsListResponse:
@router.post("", response_model=SubagentResponse, status_code=201, summary="Create Managed Subagent") @router.post("", response_model=SubagentResponse, status_code=201, summary="Create Managed Subagent")
async def create_managed_subagent(request: Request, body: ManagedSubagentCreateRequest) -> SubagentResponse: async def create_managed_subagent(request: Request, body: ManagedSubagentCreateRequest) -> SubagentResponse:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
_validate_path_name(body.name, for_creation=True)
try: try:
definition = ManagedSubagentDefinition(**body.model_dump(exclude_none=True)) definition = ManagedSubagentDefinition(**body.model_dump(exclude_none=True))
except ValidationError as exc: except ValidationError as exc:

View File

@ -1,4 +1,3 @@
import asyncio
import json import json
import logging import logging
@ -39,11 +38,11 @@ class SuggestionsConfigResponse(BaseModel):
_strip_markdown_code_fence = llm_text.strip_markdown_code_fence _strip_markdown_code_fence = llm_text.strip_markdown_code_fence
_strip_think_blocks_outside_json_strings = llm_text.strip_think_blocks_outside_json_strings _strip_think_blocks = llm_text.strip_think_blocks
def _parse_json_string_list(text: str) -> list[str] | None: def _parse_json_string_list(text: str) -> list[str] | None:
candidate = _strip_think_blocks_outside_json_strings(text) candidate = _strip_think_blocks(text)
candidate = _strip_markdown_code_fence(candidate) candidate = _strip_markdown_code_fence(candidate)
start = candidate.find("[") start = candidate.find("[")
end = candidate.rfind("]") end = candidate.rfind("]")
@ -123,7 +122,7 @@ async def generate_suggestions(
# default when the caller omits model_name. Keep permission failures out # default when the caller omits model_name. Keep permission failures out
# of the best-effort LLM error handler below so a denial remains a 403. # of the best-effort LLM error handler below so a denial remains a 403.
user = await get_current_user_from_request(request) user = await get_current_user_from_request(request)
await asyncio.to_thread(authorize_model_use, user, body.model_name, is_internal=_is_internal_caller(request, user), app_config=config) authorize_model_use(user, body.model_name, is_internal=_is_internal_caller(request, user), app_config=config)
system_instruction = ( system_instruction = (
"You are generating follow-up questions to help the user continue the conversation.\n" "You are generating follow-up questions to help the user continue the conversation.\n"

View File

@ -16,7 +16,6 @@ import hashlib
import logging import logging
import re import re
import uuid import uuid
from collections.abc import Mapping
from copy import deepcopy from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
@ -55,7 +54,6 @@ from app.gateway.conversation_reader import (
from app.gateway.deps import get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge from app.gateway.deps import get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE, get_trusted_internal_owner_user_id from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE, get_trusted_internal_owner_user_id
from app.gateway.pagination import trim_run_message_page from app.gateway.pagination import trim_run_message_page
from app.gateway.run_inputs import RunInputRequest, change_run_input, list_thread_inputs, submit_run_input
from app.gateway.run_models import RunCreateRequest from app.gateway.run_models import RunCreateRequest
from app.gateway.services import ( from app.gateway.services import (
abuild_checkpoint_state_accessor, abuild_checkpoint_state_accessor,
@ -66,19 +64,16 @@ from app.gateway.services import (
wait_for_run_completion, wait_for_run_completion,
) )
from app.gateway.sse_headers import sse_response_headers from app.gateway.sse_headers import sse_response_headers
from app.gateway.tool_approvals import ApprovalResponseBody, answer_approval, list_approvals, revoke_approval_grants
from app.gateway.utils import sanitize_log_param from app.gateway.utils import sanitize_log_param
from deerflow.agents.human_input import read_human_input_response from deerflow.agents.human_input import read_human_input_response
from deerflow.agents.middlewares.dynamic_context_middleware import strip_injected_user_message_id_suffix from deerflow.agents.middlewares.dynamic_context_middleware import strip_injected_user_message_id_suffix
from deerflow.authz.sandbox_authz import safe_app_config_async from deerflow.authz.sandbox_authz import safe_app_config_async
from deerflow.config.paths import get_paths, make_safe_user_id from deerflow.config.paths import get_paths, make_safe_user_id
from deerflow.constants import DELIVERY_PRESENTATION_TOOL_NAMES
from deerflow.runtime import CancelOutcome, ConflictError, RunRecord, RunStatus, ThreadOperationKind, serialize_channel_values_for_api from deerflow.runtime import CancelOutcome, ConflictError, RunRecord, RunStatus, ThreadOperationKind, serialize_channel_values_for_api
from deerflow.runtime.goal import is_active_goal from deerflow.runtime.goal import is_active_goal
from deerflow.runtime.runs.store.base import format_run_cursor_created_at, normalize_run_created_at_iso from deerflow.runtime.runs.store.base import format_run_cursor_created_at, normalize_run_created_at_iso
from deerflow.runtime.secret_context import redact_config_secrets, redact_metadata_secrets from deerflow.runtime.secret_context import redact_config_secrets, redact_metadata_secrets
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.exceptions import SandboxAuthorizationError
from deerflow.utils.llm_text import strip_leading_think_blocks from deerflow.utils.llm_text import strip_leading_think_blocks
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, get_original_user_content_text, message_to_text from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, get_original_user_content_text, message_to_text
from deerflow.utils.thread_id import ThreadId from deerflow.utils.thread_id import ThreadId
@ -86,50 +81,6 @@ from deerflow.workspace_changes import get_workspace_changes_response
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads", tags=["runs"]) router = APIRouter(prefix="/api/threads", tags=["runs"])
@router.get("/{thread_id}/tool-approvals")
@require_permission("threads", "read", owner_check=True, require_existing=True)
async def get_tool_approvals(thread_id: ThreadId, request: Request):
return await list_approvals(thread_id, request)
@router.post("/{thread_id}/runs/{run_id}/tool-approvals/{request_id}")
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def submit_tool_approval(thread_id: ThreadId, run_id: str, request_id: str, body: ApprovalResponseBody, request: Request):
return await answer_approval(thread_id, run_id, request_id, body, request)
@router.delete("/{thread_id}/tool-approvals/grants")
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def revoke_tool_approval_grants(thread_id: ThreadId, request: Request):
return await revoke_approval_grants(thread_id, request)
@router.post("/{thread_id}/runs/{run_id}/inputs")
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def create_run_input(thread_id: ThreadId, run_id: str, body: RunInputRequest, request: Request):
return await submit_run_input(thread_id, run_id, body, request)
@router.get("/{thread_id}/inputs")
@require_permission("threads", "read", owner_check=True, require_existing=True)
async def get_thread_inputs(thread_id: ThreadId, request: Request):
return await list_thread_inputs(thread_id, request)
@router.delete("/{thread_id}/runs/{run_id}/inputs/{input_id}")
@require_permission("runs", "cancel", owner_check=True, require_existing=True)
async def remove_run_input(thread_id: ThreadId, run_id: str, input_id: str, request: Request):
return await change_run_input(thread_id, run_id, input_id, request)
@router.post("/{thread_id}/runs/{run_id}/inputs/{input_id}/retry")
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def retry_run_input(thread_id: ThreadId, run_id: str, input_id: str, request: Request):
return await change_run_input(thread_id, run_id, input_id, request, retry=True)
REGENERATE_HISTORY_SCAN_LIMIT = 200 REGENERATE_HISTORY_SCAN_LIMIT = 200
# Doubled to keep ~200 effective checkpoints when duration-only checkpoints # Doubled to keep ~200 effective checkpoints when duration-only checkpoints
# (one per successful run in steady state) consume roughly half of history. # (one per successful run in steady state) consume roughly half of history.
@ -230,7 +181,7 @@ def compute_run_durations(runs) -> dict[str, int]:
return durations return durations
def stamp_turn_duration_on_last_ai(messages, run_durations: dict[str, int], *, thread_id: str | None = None) -> None: def stamp_turn_duration_on_last_ai(messages, run_durations: dict[str, int]) -> None:
"""Attach each run's elapsed seconds to that run's final visible AI message only. """Attach each run's elapsed seconds to that run's final visible AI message only.
``turn_duration`` is the run's wall-clock lifetime (``compute_run_durations``), ``turn_duration`` is the run's wall-clock lifetime (``compute_run_durations``),
@ -258,37 +209,10 @@ def stamp_turn_duration_on_last_ai(messages, run_durations: dict[str, int], *, t
continue continue
content = msg.get("content") content = msg.get("content")
payload = content if isinstance(content, dict) else msg payload = content if isinstance(content, dict) else msg
metadata = msg.get("metadata") metadata = msg.get("metadata") or {}
if metadata is None:
metadata = {}
elif not isinstance(metadata, Mapping):
logger.warning(
"Ignoring malformed event metadata for thread %s at row seq %s: expected a mapping, got %s",
sanitize_log_param(thread_id or "<unknown>"),
msg.get("seq", "<unknown>"),
type(metadata).__name__,
)
metadata = {}
is_middleware = str(metadata.get("caller", "")).startswith("middleware:") is_middleware = str(metadata.get("caller", "")).startswith("middleware:")
if payload.get("type") == "ai" and not is_middleware: if payload.get("type") == "ai" and not is_middleware:
raw_additional_kwargs = payload.get("additional_kwargs") payload.setdefault("additional_kwargs", {})["turn_duration"] = run_durations[rid]
if raw_additional_kwargs is None:
additional_kwargs: dict[str, Any] = {}
elif isinstance(raw_additional_kwargs, Mapping):
# A Mapping is not necessarily mutable (for example, a
# MappingProxyType from a serializer), so copy it before
# attaching the duration.
additional_kwargs = dict(raw_additional_kwargs)
else:
logger.warning(
"Ignoring malformed message additional_kwargs for thread %s at row seq %s: expected a mapping, got %s",
sanitize_log_param(thread_id or "<unknown>"),
msg.get("seq", "<unknown>"),
type(raw_additional_kwargs).__name__,
)
additional_kwargs = {}
payload["additional_kwargs"] = additional_kwargs
additional_kwargs["turn_duration"] = run_durations[rid]
stamped.add(rid) stamped.add(rid)
@ -829,19 +753,9 @@ async def _require_successful_source_run(thread_id: str, run_id: str, request: R
raise HTTPException(status_code=409, detail="Could not find source run for assistant message") raise HTTPException(status_code=409, detail="Could not find source run for assistant message")
if _run_status_value(record) != RunStatus.success.value: if _run_status_value(record) != RunStatus.success.value:
raise HTTPException(status_code=409, detail="Only successful assistant runs can be edited and rerun") raise HTTPException(status_code=409, detail="Only successful assistant runs can be edited and rerun")
await _reject_multi_input_replay(run_id, request, user_id=user_id)
return record return record
async def _reject_multi_input_replay(run_id: str, request: Request, *, user_id=None) -> None:
store = getattr(get_run_manager(request), "input_store", None)
if getattr(store, "supports_run_inputs", False) is not True:
return
row = await store.get(run_id, user_id=user_id)
if row is not None and any(entry.get("mode") == "steer" and entry.get("status") != "cancelled" for entry in (row.get("run_inputs") or {}).get("items", [])):
raise HTTPException(409, "This Run was adjusted while working; send a new message instead of replaying part of it")
async def _find_interrupted_target_run_id( async def _find_interrupted_target_run_id(
thread_id: str, thread_id: str,
source_human: Any, source_human: Any,
@ -918,7 +832,6 @@ async def _prepare_regenerate_payload(thread_id: str, message_id: str, request:
raise HTTPException(status_code=409, detail="Could not find the user message for this assistant response") raise HTTPException(status_code=409, detail="Could not find the user message for this assistant response")
if target_run_id is None: if target_run_id is None:
raise HTTPException(status_code=409, detail="Could not find source run for assistant message") raise HTTPException(status_code=409, detail="Could not find source run for assistant message")
await _reject_multi_input_replay(target_run_id, request, user_id=await _run_scope_user_id(request, thread_id))
previous_human_id = _message_id(previous_human) previous_human_id = _message_id(previous_human)
if not previous_human_id: if not previous_human_id:
raise HTTPException(status_code=409, detail="The source user message is missing an id") raise HTTPException(status_code=409, detail="The source user message is missing an id")
@ -1631,7 +1544,7 @@ async def list_thread_messages(
run_durations = compute_run_durations(runs) run_durations = compute_run_durations(runs)
if run_durations: if run_durations:
stamp_turn_duration_on_last_ai(messages, run_durations, thread_id=thread_id) stamp_turn_duration_on_last_ai(messages, run_durations)
return messages return messages
@ -1700,7 +1613,7 @@ async def _enrich_thread_message_page(
# endpoints already use ``stamp_turn_duration_on_last_ai``; the page # endpoints already use ``stamp_turn_duration_on_last_ai``; the page
# endpoint was inlining the equivalent loop but stamping every AI row, # endpoint was inlining the equivalent loop but stamping every AI row,
# which #4163 fixed for the other paths and missed here. # which #4163 fixed for the other paths and missed here.
stamp_turn_duration_on_last_ai(data, run_durations, thread_id=thread_id) stamp_turn_duration_on_last_ai(data, run_durations)
return data return data
@ -1765,7 +1678,7 @@ async def list_run_messages(
if record: if record:
durations = compute_run_durations([record]) durations = compute_run_durations([record])
if durations: if durations:
stamp_turn_duration_on_last_ai(data, durations, thread_id=thread_id) stamp_turn_duration_on_last_ai(data, durations)
return {"data": data, "has_more": has_more} return {"data": data, "has_more": has_more}
@ -1822,15 +1735,8 @@ def _presented_files_from_delivery(events: list[dict]) -> list[str]:
raise HTTPException(status_code=409, detail="This response has no verified artifact delivery") raise HTTPException(status_code=409, detail="This response has no verified artifact delivery")
content = events[0].get("content") content = events[0].get("content")
by_tool = content.get("by_tool") if isinstance(content, dict) else None by_tool = content.get("by_tool") if isinstance(content, dict) else None
if not isinstance(by_tool, dict): presented = by_tool.get("present_files") if isinstance(by_tool, dict) else None
raise HTTPException(status_code=409, detail="This response has no verified artifact delivery") if not isinstance(presented, list) or not presented or any(not isinstance(path, str) for path in presented):
presented: list[str] = []
for tool_name in DELIVERY_PRESENTATION_TOOL_NAMES:
paths = by_tool.get(tool_name, [])
if not isinstance(paths, list):
raise HTTPException(status_code=409, detail="This response has no verified artifact delivery")
presented.extend(paths)
if not presented or any(not isinstance(path, str) for path in presented):
raise HTTPException(status_code=409, detail="This response has no verified artifact delivery") raise HTTPException(status_code=409, detail="This response has no verified artifact delivery")
return presented return presented
@ -1882,10 +1788,7 @@ async def create_run_artifact_archive(
raw_owner_user_id = get_trusted_internal_owner_user_id(request) raw_owner_user_id = get_trusted_internal_owner_user_id(request)
effective_user_id = make_safe_user_id(raw_owner_user_id) if raw_owner_user_id else get_effective_user_id() effective_user_id = make_safe_user_id(raw_owner_user_id) if raw_owner_user_id else get_effective_user_id()
try:
app_config = await safe_app_config_async() app_config = await safe_app_config_async()
except SandboxAuthorizationError as exc:
raise HTTPException(status_code=503, detail="Artifact archive is temporarily unavailable") from exc
custom_tool_output_dir = getattr(getattr(app_config, "tool_output", None), "storage_subdir", None) custom_tool_output_dir = getattr(getattr(app_config, "tool_output", None), "storage_subdir", None)
extra_reserved_dir_names = {custom_tool_output_dir} if isinstance(custom_tool_output_dir, str) else set() extra_reserved_dir_names = {custom_tool_output_dir} if isinstance(custom_tool_output_dir, str) else set()
paths = get_paths() paths = get_paths()
@ -1951,18 +1854,12 @@ async def list_run_events(
user_id = await _run_scope_user_id(request, thread_id) user_id = await _run_scope_user_id(request, thread_id)
event_store = get_run_event_store(request) event_store = get_run_event_store(request)
if event_types: if event_types:
# Split on commas and normalise each entry: the value goes straight into types = event_types.split(",")
# a SQL ``IN``, so a padded entry ("message, tool") would silently match
# nothing and return a 200 with a truncated audit trail. Blank entries
# carry no intent and are dropped; a filter that reduces to nothing is
# the same as no filter, matching the empty-string case above.
types = [name for entry in event_types.split(",") if (name := entry.strip())]
if len(types) > MAX_EVENT_TYPE_FILTERS: if len(types) > MAX_EVENT_TYPE_FILTERS:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=f"event_types accepts at most {MAX_EVENT_TYPE_FILTERS} values", detail=f"event_types accepts at most {MAX_EVENT_TYPE_FILTERS} values",
) )
types = types or None
else: else:
types = None types = None
events = await event_store.list_events( events = await event_store.list_events(

View File

@ -12,7 +12,6 @@ matching the LangGraph Platform wire format expected by the
from __future__ import annotations from __future__ import annotations
import asyncio
import inspect import inspect
import logging import logging
import shutil import shutil
@ -20,13 +19,13 @@ import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from langgraph.checkpoint.base import empty_checkpoint from langgraph.checkpoint.base import empty_checkpoint
from langgraph.types import Overwrite from langgraph.types import Overwrite
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app.gateway.authz import _is_internal_caller, authorize_model_use, require_permission from app.gateway.authz import require_permission
from app.gateway.checkpoint_lineage import ( from app.gateway.checkpoint_lineage import (
CheckpointLineageError, CheckpointLineageError,
CheckpointParentMissingError, CheckpointParentMissingError,
@ -38,9 +37,7 @@ from app.gateway.checkpoint_lineage import (
resolve_history_versions, resolve_history_versions,
resolve_stamp_candidate_versions, resolve_stamp_candidate_versions,
) )
from app.gateway.deps import get_checkpointer, get_config, get_current_user_from_request, get_run_event_store, get_run_manager, get_run_store from app.gateway.deps import get_checkpointer, get_run_event_store, get_run_manager, get_run_store
from app.gateway.history_seed import reserve_history_seed, write_history_seed
from app.gateway.hitl_approval import find_pending_interrupts
from app.gateway.internal_auth import get_trusted_internal_owner_user_id from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.services import ( from app.gateway.services import (
abuild_checkpoint_state_accessor, abuild_checkpoint_state_accessor,
@ -52,9 +49,7 @@ from app.gateway.services import (
strip_server_owned_state_metadata, strip_server_owned_state_metadata,
) )
from app.gateway.utils import sanitize_log_param from app.gateway.utils import sanitize_log_param
from deerflow.agents.human_input import extract_unanswered_human_input_request
from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import Paths, get_paths from deerflow.config.paths import Paths, get_paths
from deerflow.config.summarization_config import ContextSize from deerflow.config.summarization_config import ContextSize
from deerflow.persistence.thread_meta import PROJECT_FILTER_UNSET, THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, THREAD_PROJECT_METADATA_KEY, ThreadOwnershipConflictError from deerflow.persistence.thread_meta import PROJECT_FILTER_UNSET, THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, THREAD_PROJECT_METADATA_KEY, ThreadOwnershipConflictError
@ -68,12 +63,11 @@ from deerflow.runtime.context_compaction import (
ThreadCompactionResult, ThreadCompactionResult,
compact_thread_context, compact_thread_context,
) )
from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY, checkpoint_agent_binding_metadata from deerflow.runtime.context_keys import checkpoint_agent_binding_metadata
from deerflow.runtime.events.message_seq import stamp_messages_with_seq from deerflow.runtime.events.message_seq import stamp_messages_with_seq
from deerflow.runtime.goal import ( from deerflow.runtime.goal import (
DEFAULT_MAX_GOAL_CONTINUATIONS, DEFAULT_MAX_GOAL_CONTINUATIONS,
GOAL_OUTCOME_CHANNEL, GOAL_OUTCOME_CHANNEL,
GoalWriteConflict,
build_goal_state, build_goal_state,
ensure_thread_checkpoint, ensure_thread_checkpoint,
goal_thread_lock, goal_thread_lock,
@ -81,18 +75,11 @@ from deerflow.runtime.goal import (
read_thread_goal, read_thread_goal,
write_thread_goal, write_thread_goal,
) )
from deerflow.runtime.goal_synthesis import (
GoalSynthesisError,
GoalSynthesisUnclear,
build_synthesis_context,
synthesize_goal_objective,
)
from deerflow.runtime.journal import build_branch_history_seed_events from deerflow.runtime.journal import build_branch_history_seed_events
from deerflow.runtime.run_origin import DEERFLOW_ORIGIN_KEY, admit_origin from deerflow.runtime.run_origin import DEERFLOW_ORIGIN_KEY, admit_origin
from deerflow.runtime.runs.manager import ConflictError from deerflow.runtime.runs.manager import ConflictError
from deerflow.runtime.runs.worker import RUN_MESSAGE_IDS_METADATA_KEY, valid_duration_entry, valid_run_message_id_entry from deerflow.runtime.runs.worker import RUN_MESSAGE_IDS_METADATA_KEY, valid_duration_entry, valid_run_message_id_entry
from deerflow.runtime.secret_context import redact_metadata_secrets from deerflow.runtime.secret_context import redact_metadata_secrets
from deerflow.runtime.serialization import serialize_lc_object
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
from deerflow.uploads.companions import register_companion, resolve_companion from deerflow.uploads.companions import register_companion, resolve_companion
from deerflow.utils.file_io import await_drained, run_file_io from deerflow.utils.file_io import await_drained, run_file_io
@ -139,13 +126,6 @@ def _checkpoint_mode_http_error(exc: Exception, thread_id: str) -> HTTPException
# ``deerflow_origin`` marks a thread the server created for a schedule or an # ``deerflow_origin`` marks a thread the server created for a schedule or an
# extension; only the server sets it (``create_thread`` below, run admission). # extension; only the server sets it (``create_thread`` below, run admission).
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id", THREAD_PROJECT_METADATA_KEY, DEERFLOW_ORIGIN_KEY}) _SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id", THREAD_PROJECT_METADATA_KEY, DEERFLOW_ORIGIN_KEY})
# Checkpoint metadata the server writes and later reads back as trusted:
# LangGraph's lineage keys and delta counters (``step`` drives the next turn,
# ``parents`` the history walk, and ``counters_since_delta_snapshot`` is read
# by every run and state update) and the agent binding that gates memory
# writes. ``create_thread`` copies client metadata into the root checkpoint
# without them; the thread row keeps the client's metadata as sent.
_CHECKPOINT_OWNED_METADATA_KEYS: frozenset[str] = frozenset({"step", "source", "writes", "parents", "created_at", "counters_since_delta_snapshot", CHECKPOINT_AGENT_NAME_METADATA_KEY})
_SIDECAR_METADATA_KEY = "deerflow_sidecar" _SIDECAR_METADATA_KEY = "deerflow_sidecar"
_BRANCH_METADATA_KEY = "deerflow_branch" _BRANCH_METADATA_KEY = "deerflow_branch"
_BRANCH_TITLE_SEQUENCE_METADATA_KEY = "branch_title_sequence" _BRANCH_TITLE_SEQUENCE_METADATA_KEY = "branch_title_sequence"
@ -530,8 +510,6 @@ class ThreadCreateRequest(BaseModel):
class ThreadSearchRequest(BaseModel): class ThreadSearchRequest(BaseModel):
"""Request body for searching threads.""" """Request body for searching threads."""
query: str | None = Field(default=None, max_length=256, description="Literal case-insensitive title substring")
archived: bool | None = Field(default=None, strict=True, description="Archive filter; omitted includes all, false includes legacy unarchived threads") archived: bool | None = Field(default=None, strict=True, description="Archive filter; omitted includes all, false includes legacy unarchived threads")
metadata: dict[str, Any] = Field(default_factory=dict, description="Metadata filter (exact match)") metadata: dict[str, Any] = Field(default_factory=dict, description="Metadata filter (exact match)")
project_id: str | None = Field(default=None, description="Filter by project; explicit null = unassigned threads; omit key for all") project_id: str | None = Field(default=None, description="Filter by project; explicit null = unassigned threads; omit key for all")
@ -591,7 +569,7 @@ class ThreadPatchRequest(BaseModel):
class ThreadStateUpdateRequest(BaseModel): class ThreadStateUpdateRequest(BaseModel):
"""Request body for updating thread state (checkpoint mutation, not a resume API).""" """Request body for updating thread state (human-in-the-loop resume)."""
values: dict[str, Any] | None = Field(default=None, description="Channel values to merge") values: dict[str, Any] | None = Field(default=None, description="Channel values to merge")
checkpoint_id: str | None = Field(default=None, description="Checkpoint to branch from") checkpoint_id: str | None = Field(default=None, description="Checkpoint to branch from")
@ -617,12 +595,6 @@ class ThreadGoalResponse(BaseModel):
goal: dict[str, Any] | None = Field(default=None, description="Current goal state, or null when no goal is active") goal: dict[str, Any] | None = Field(default=None, description="Current goal state, or null when no goal is active")
class ThreadGoalFromChatRequest(BaseModel):
"""Request body for synthesizing a goal from the recent conversation."""
guidance: str | None = Field(default=None, max_length=500, description="Optional user hint steering the synthesized objective")
class ThreadCompactRequest(BaseModel): class ThreadCompactRequest(BaseModel):
"""Request body for manually compacting a thread's active context.""" """Request body for manually compacting a thread's active context."""
@ -650,13 +622,10 @@ class HistoryEntry(_MetadataRedactingResponse):
checkpoint_id: str checkpoint_id: str
parent_checkpoint_id: str | None = None parent_checkpoint_id: str | None = None
created_at: str | None = Field(default=None, description="Checkpoint timestamp")
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
values: dict[str, Any] = Field(default_factory=dict) values: dict[str, Any] = Field(default_factory=dict)
created_at: str | None = None
next: list[str] = Field(default_factory=list) next: list[str] = Field(default_factory=list)
# Only the head entry carries pending interrupts; older entries are history,
# never a pending-approval signal. Shape: {id, name, interrupts} only.
tasks: list[dict[str, Any]] = Field(default_factory=list)
class ThreadHistoryRequest(BaseModel): class ThreadHistoryRequest(BaseModel):
@ -835,13 +804,6 @@ async def _delete_thread_data_with_reservation(thread_id: str, request: Request)
# persisted runs/events/feedback and the thread_meta row all belong to the # persisted runs/events/feedback and the thread_meta row all belong to the
# same owner, so they must not resolve their scope independently. # same owner, so they must not resolve their scope independently.
user_id = get_effective_user_id() user_id = get_effective_user_id()
run_manager = get_run_manager(request)
batch_repo = getattr(request.app.state, "subagent_batch_repo", None)
approval_store = getattr(request.app.state, "approval_store", None)
if approval_store is not None:
await await_drained(approval_store.delete_thread(thread_id, user_id=user_id))
if batch_repo is not None:
await batch_repo.suppress_thread_continuations(thread_id)
# Legacy IDs may predate the canonical filesystem-safe contract. They can # Legacy IDs may predate the canonical filesystem-safe contract. They can
# still be removed from metadata/checkpoint stores, but must never be # still be removed from metadata/checkpoint stores, but must never be
@ -860,15 +822,14 @@ async def _delete_thread_data_with_reservation(thread_id: str, request: Request)
# being deleted under a run that has just been admitted. # being deleted under a run that has just been admitted.
response = await await_drained(run_file_io(_delete_thread_data, thread_id, user_id=user_id)) response = await await_drained(run_file_io(_delete_thread_data, thread_id, user_id=user_id))
# Retain ownership if a configured checkpointer cannot delete the thread. # Remove checkpoints (best-effort)
# Read routes treat rowless checkpoints as untracked legacy threads.
checkpointer = getattr(request.app.state, "checkpointer", None) checkpointer = getattr(request.app.state, "checkpointer", None)
if checkpointer is not None: if checkpointer is not None:
try: try:
if hasattr(checkpointer, "adelete_thread"):
await checkpointer.adelete_thread(thread_id) await checkpointer.adelete_thread(thread_id)
except Exception as exc: except Exception:
logger.exception("Failed to delete checkpoints for thread %s", sanitize_log_param(thread_id)) logger.debug("Could not delete checkpoints for thread %s (not critical)", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to delete thread checkpoints.") from exc
# Remove historical runs (best-effort). Only ``operation_kind == "run"`` rows # Remove historical runs (best-effort). Only ``operation_kind == "run"`` rows
# are deleted, so the durable thread-operation reservation protecting this # are deleted, so the durable thread-operation reservation protecting this
@ -881,16 +842,6 @@ async def _delete_thread_data_with_reservation(thread_id: str, request: Request)
except Exception: except Exception:
logger.debug("Could not delete run records for thread %s (not critical)", sanitize_log_param(thread_id)) logger.debug("Could not delete run records for thread %s (not critical)", sanitize_log_param(thread_id))
# Drop this worker's in-memory copies too. ``RunManager.get()`` and
# ``list_by_thread()`` answer from the process-local registry before the
# store, so without this the deleted thread's runs — including ``kwargs``
# with the user's prompt — stay readable through ``GET /threads/{id}/runs``
# until the Gateway restarts, while the thread itself already 404s.
try:
await run_manager.discard_thread_runs(thread_id)
except Exception:
logger.debug("Could not evict in-memory runs for thread %s (not critical)", sanitize_log_param(thread_id))
# Remove persisted run events (best-effort). These are the user-visible # Remove persisted run events (best-effort). These are the user-visible
# conversation history, not a cache: leaving them behind makes a deleted # conversation history, not a cache: leaving them behind makes a deleted
# thread's feed readable again through GET /threads/{id}/messages. A legacy # thread's feed readable again through GET /threads/{id}/messages. A legacy
@ -1014,17 +965,11 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
and an empty checkpoint (so state endpoints work immediately). and an empty checkpoint (so state endpoints work immediately).
Idempotent: returns the existing record when ``thread_id`` already exists. Idempotent: returns the existing record when ``thread_id`` already exists.
""" """
thread_id = resolve_thread_id(body.thread_id)
async with goal_thread_lock(thread_id):
return await _create_thread_locked(body, request, thread_id)
async def _create_thread_locked(body: ThreadCreateRequest, request: Request, thread_id: str) -> ThreadResponse:
"""Keep the idempotency read and initial writes in one lifecycle lock."""
from app.gateway.deps import get_thread_store from app.gateway.deps import get_thread_store
checkpointer = get_checkpointer(request) checkpointer = get_checkpointer(request)
thread_store = get_thread_store(request) thread_store = get_thread_store(request)
thread_id = resolve_thread_id(body.thread_id)
now = now_iso() now = now_iso()
thread_owner_user_id = get_trusted_internal_owner_user_id(request) thread_owner_user_id = get_trusted_internal_owner_user_id(request)
thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {} thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {}
@ -1061,25 +1006,17 @@ async def _create_thread_locked(body: ThreadCreateRequest, request: Request, thr
raise HTTPException(status_code=404, detail="Thread not found") from None raise HTTPException(status_code=404, detail="Thread not found") from None
except IntegrityError: except IntegrityError:
# The idempotency read above and this insert are not atomic: a # The idempotency read above and this insert are not atomic: a
# another worker's request for the same thread_id can commit in between, so # concurrent request for the same thread_id can commit in between, so
# the SQL-backed store rejects ours on the duplicate primary key. # the SQL-backed store rejects ours on the duplicate primary key.
# Honour the documented idempotency contract by resolving the # Honour the documented idempotency contract by resolving the
# now-existing record — running the same owner reconciliation the fast # now-existing record — running the same owner reconciliation the fast
# path does — instead of surfacing the conflict as a 500. Same-process # path does — instead of surfacing the conflict as a 500. The memory
# HTTP creates share the lifecycle lock; the memory store retains its # store serializes same-id creates under its per-thread lock and keeps
# historical overwrite behavior for direct callers. # its historical overwrite behavior, so it does not reach this branch.
existing_record = await _resolve_existing_thread(thread_store, thread_id, thread_owner_user_id, thread_owner_kwargs) existing_record = await _resolve_existing_thread(thread_store, thread_id, thread_owner_user_id, thread_owner_kwargs)
if existing_record is not None: if existing_record is not None:
return _existing_thread_response(thread_id, existing_record) return _existing_thread_response(thread_id, existing_record)
# The same-owner reconciliation above found nothing, yet the insert hit # A duplicate-key error with no row we can read back is a real failure.
# a duplicate primary key — so the surviving row belongs to *another*
# user. That is the ownership-conflict case, which the route already
# maps ``ThreadOwnershipConflictError`` to: a non-disclosing 404. Read
# the row back bypassing the owner scope to confirm a foreign row
# exists before collapsing to a 500.
if await thread_store.get(thread_id, user_id=None) is not None:
raise HTTPException(status_code=404, detail="Thread not found") from None
# A duplicate-key error with no surviving row is a real failure.
logger.exception("Failed to write thread_meta for %s", sanitize_log_param(thread_id)) logger.exception("Failed to write thread_meta for %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to create thread") raise HTTPException(status_code=500, detail="Failed to create thread")
except Exception: except Exception:
@ -1090,14 +1027,12 @@ async def _create_thread_locked(body: ThreadCreateRequest, request: Request, thr
# Write an empty checkpoint so state endpoints work immediately # Write an empty checkpoint so state endpoints work immediately
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try: try:
# Client metadata cannot supply checkpoint-owned keys; see
# ``_CHECKPOINT_OWNED_METADATA_KEYS``.
ckpt_metadata = { ckpt_metadata = {
**{key: value for key, value in metadata.items() if key not in _CHECKPOINT_OWNED_METADATA_KEYS},
"step": -1, "step": -1,
"source": "input", "source": "input",
"writes": None, "writes": None,
"parents": {}, "parents": {},
**metadata,
"created_at": now, "created_at": now,
} }
await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {}) await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {})
@ -1286,9 +1221,9 @@ async def _branch_thread_with_reservation(
# into a new thread. Preserve every valid spelling as create does. # into a new thread. Preserve every valid spelling as create does.
branch_assistant_id = None branch_assistant_id = None
async def _write_branch_row(project_id: str | None) -> dict: async def _write_branch_row(project_id: str | None) -> None:
try: try:
return await thread_store.create( await thread_store.create(
new_thread_id, new_thread_id,
assistant_id=branch_assistant_id, assistant_id=branch_assistant_id,
display_name=display_name, display_name=display_name,
@ -1303,13 +1238,13 @@ async def _branch_thread_with_reservation(
raise HTTPException(status_code=500, detail="Failed to create branch") from None raise HTTPException(status_code=500, detail="Failed to create branch") from None
try: try:
branch_record = await _write_branch_row(source_project_id) await _write_branch_row(source_project_id)
except ProjectNotAssignableError: except ProjectNotAssignableError:
# The source project became unassignable (archived, or deleted in a # The source project became unassignable (archived, or deleted in a
# race that cleared the source row's membership after our read): keep # race that cleared the source row's membership after our read): keep
# the branch usable as an unassigned thread — the pre-inheritance # the branch usable as an unassigned thread — the pre-inheritance
# behavior for sources without an active project. # behavior for sources without an active project.
branch_record = await _write_branch_row(None) await _write_branch_row(None)
# The thread feed (GET /messages, /messages/page) reads the run-event # The thread feed (GET /messages, /messages/page) reads the run-event
# store, not checkpoints, and a fresh branch has no run_events — so the # store, not checkpoints, and a fresh branch has no run_events — so the
@ -1319,7 +1254,6 @@ async def _branch_thread_with_reservation(
# from. Best-effort: on failure the branch stays usable, with history # from. Best-effort: on failure the branch stays usable, with history
# visible only through the checkpoint overlay until it is re-branched. # visible only through the checkpoint overlay until it is re-branched.
try: try:
async with reserve_history_seed(request, new_thread_id) as reservation:
seed_events = build_branch_history_seed_events( seed_events = build_branch_history_seed_events(
_checkpoint_messages(snapshot), _checkpoint_messages(snapshot),
thread_id=new_thread_id, thread_id=new_thread_id,
@ -1327,9 +1261,7 @@ async def _branch_thread_with_reservation(
parent_thread_id=thread_id, parent_thread_id=thread_id,
) )
if seed_events: if seed_events:
event_store = get_run_event_store(request) await get_run_event_store(request).put_batch(seed_events)
if reservation is None or not await event_store.list_messages(new_thread_id, limit=1, user_id=None):
await write_history_seed(request, seed_events, reservation=reservation, seed_kind="branch", thread=branch_record)
history_seed_mode = "seeded" history_seed_mode = "seeded"
else: else:
history_seed_mode = "skipped_empty" history_seed_mode = "skipped_empty"
@ -1369,7 +1301,6 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th
try: try:
rows = await repo.search( rows = await repo.search(
metadata=body.metadata or None, metadata=body.metadata or None,
**({"query": body.query} if body.query is not None else {}),
status=body.status, status=body.status,
**({"archived": body.archived} if body.archived is not None else {}), **({"archived": body.archived} if body.archived is not None else {}),
project_id=project_filter, project_id=project_filter,
@ -1541,7 +1472,6 @@ async def get_thread(thread_id: ThreadId, request: Request) -> ThreadResponse:
updated_at=coerce_iso(record.get("updated_at", "")), updated_at=coerce_iso(record.get("updated_at", "")),
metadata=record.get("metadata", {}), metadata=record.get("metadata", {}),
values=serialize_channel_values_for_api(snapshot.values), values=serialize_channel_values_for_api(snapshot.values),
interrupts={task_id: [serialize_lc_object(interrupt) for interrupt in interrupts] for task_id, interrupts in (await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=config, snapshot=snapshot)).items()},
) )
@ -1567,21 +1497,11 @@ async def set_thread_goal(thread_id: ThreadId, body: ThreadGoalRequest, request:
this endpoint creates the missing thread checkpoint on demand. this endpoint creates the missing thread checkpoint on demand.
""" """
checkpointer = get_checkpointer(request) checkpointer = get_checkpointer(request)
try:
accessor, read_config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
from app.gateway.hitl_approval import reject_new_run_when_approval_pending
await reject_new_run_when_approval_pending(request, thread_id, accessor=accessor, read_config=read_config)
except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc
try: try:
goal = build_goal_state(body.objective, max_continuations=body.max_continuations) goal = build_goal_state(body.objective, max_continuations=body.max_continuations)
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()): async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
await _ensure_thread_for_goal(thread_id, request) await _ensure_thread_for_goal(thread_id, request)
await write_thread_goal(checkpointer, thread_id, goal, as_node="goal", create_if_missing=True) await write_thread_goal(checkpointer, thread_id, goal, as_node="goal", create_if_missing=True)
batch_repo = getattr(request.app.state, "subagent_batch_repo", None)
if batch_repo is not None:
await batch_repo.suppress_thread_continuations(thread_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
except ConflictError: except ConflictError:
@ -1594,154 +1514,14 @@ async def set_thread_goal(thread_id: ThreadId, body: ThreadGoalRequest, request:
return ThreadGoalResponse(goal=goal) return ThreadGoalResponse(goal=goal)
@router.post("/{thread_id}/goal/from-chat", response_model=ThreadGoalResponse)
@require_permission("threads", "write", owner_check=True)
async def synthesize_thread_goal_from_chat(thread_id: ThreadId, body: ThreadGoalFromChatRequest, request: Request, app_config: AppConfig = Depends(get_config)) -> ThreadGoalResponse:
"""Synthesize a goal objective from the thread's recent conversation and save it.
The flow the #7050 first slice agreed on: a busy pre-check runs **before**
the synthesis LLM call (a busy thread must not spend a model call it could
not save under anyway), the LLM composes an objective preserving the agreed
constraints and stopping condition, and the save reuses ``PUT /goal``'s
reservation path — its ``ConflictError`` stays the authoritative busy
guard, because another run can start between the pre-check and the save.
Unlike ``PUT /goal`` (which mints a missing checkpoint for ``/chats/new``
pages), from-chat synthesizes *from* an existing conversation: no
checkpoint or no visible context is a 422 ``no_context``, never a new
thread. A conversation whose last turn is an unanswered human-input
request (a clarification, a sandbox-network approval, an extension prompt)
is a 422 ``pending_clarification``: the goal is not agreed yet, and
synthesizing anyway lets the model invent the missing decision. A pending
tool approval is a 409 like the other goal mutations, and the save
suppresses subagent-batch continuations queued under the previous goal.
The save is a compare-and-swap against the checkpoint the synthesis read:
a goal or checkpoint write that landed during the model call rejects the
stale objective with a 409 ``goal_changed`` instead of clobbering it.
The synthesis is read-only until the save; the caller starts the run through
its own existing run path.
"""
# Best-effort pre-check: has_inflight reads this worker's registry, so a
# multi-worker deployment can miss a peer's run. Missing it costs one
# wasted synthesis; the save-time reservation below still rejects. A
# failing pre-check degrades the same way rather than blocking the user.
try:
if await get_run_manager(request).has_inflight(thread_id):
raise HTTPException(status_code=409, detail="Thread has a run in flight. Set the goal after the run finishes.")
except HTTPException:
raise
except Exception:
logger.warning("Goal from-chat busy pre-check failed; falling through to synthesis", exc_info=True)
try:
accessor, config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
# Same gate as PUT/DELETE /goal: a pending tool approval must not be
# silently discarded by changing the goal underneath it.
from app.gateway.hitl_approval import reject_new_run_when_approval_pending
await reject_new_run_when_approval_pending(request, thread_id, accessor=accessor, read_config=config)
snapshot = await accessor.aget(config)
except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc
except HTTPException:
raise
except Exception:
logger.exception("Failed to read thread %s state for goal synthesis", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to read thread state") from None
source_checkpoint_id = (snapshot.config or {}).get("configurable", {}).get("checkpoint_id")
if not source_checkpoint_id:
raise HTTPException(status_code=422, detail={"code": "no_context"})
values = snapshot.values or {}
messages = values.get("messages")
messages_list = messages if isinstance(messages, list) else []
if extract_unanswered_human_input_request(messages_list) is not None:
# The agreement is incomplete: the user still owes the assistant an
# answer. Running now would force the model to guess that decision.
raise HTTPException(status_code=422, detail={"code": "pending_clarification"})
summary_text = values.get("summary_text")
# Redaction is part of the builder so it runs before the budget can split
# an identifier; synthesize_goal_objective redacts the composed prompt once
# more as defense in depth.
context = build_synthesis_context(
messages_list,
summary_text if isinstance(summary_text, str) else None,
pii_redaction=getattr(app_config, "pii_redaction", None),
)
if not context.strip():
raise HTTPException(status_code=422, detail={"code": "no_context"})
# Model admission before the synthesis call: the route sends thread
# context to the deployment's default model (the same factory default
# run_oneshot_llm resolves when model_name is None), so a role denied
# that model must get the same 403 the suggestions route returns.
# Checked ahead of the model call, not at save time: a denied profile
# must never receive the conversation even transiently.
user = await get_current_user_from_request(request)
await asyncio.to_thread(authorize_model_use, user, None, is_internal=_is_internal_caller(request, user), app_config=app_config)
# The synthesis call runs outside every lock/reservation: a model call can
# take tens of seconds and must not hold the thread's write admission.
try:
objective = await synthesize_goal_objective(
context=context,
guidance=body.guidance or "",
app_config=app_config,
thread_id=thread_id,
)
except GoalSynthesisUnclear:
# The model's reason is derived from conversation content: it is never
# echoed to the user, and it is not logged verbatim either.
logger.info("Goal from-chat found no clear goal in thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=422, detail={"code": "unclear"}) from None
except GoalSynthesisError:
logger.warning("Goal from-chat synthesis failed for thread %s", sanitize_log_param(thread_id), exc_info=True)
raise HTTPException(status_code=502, detail={"code": "synthesis_failed"}) from None
checkpointer = get_checkpointer(request)
goal = build_goal_state(objective)
try:
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
# Compare-and-swap against the head the synthesis read: a goal (or
# any checkpoint write) that landed during the model call means the
# objective was composed from a conversation that is no longer
# current, and saving it would silently clobber the newer state.
await write_thread_goal(checkpointer, thread_id, goal, as_node="goal", expected_checkpoint_id=source_checkpoint_id)
# Match PUT/DELETE /goal: a new goal supersedes queued subagent-batch
# continuations that were scheduled under the previous one.
batch_repo = getattr(request.app.state, "subagent_batch_repo", None)
if batch_repo is not None:
await batch_repo.suppress_thread_continuations(thread_id)
except GoalWriteConflict:
raise HTTPException(status_code=409, detail={"code": "goal_changed"}) from None
except ConflictError:
raise HTTPException(status_code=409, detail="Thread has a run in flight. Set the goal after the run finishes.") from None
except HTTPException:
raise
except Exception:
logger.exception("Failed to save from-chat goal for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to set thread goal") from None
return ThreadGoalResponse(goal=goal)
@router.delete("/{thread_id}/goal", response_model=ThreadGoalResponse) @router.delete("/{thread_id}/goal", response_model=ThreadGoalResponse)
@require_permission("threads", "write", owner_check=True) @require_permission("threads", "write", owner_check=True)
async def clear_thread_goal(thread_id: ThreadId, request: Request) -> ThreadGoalResponse: async def clear_thread_goal(thread_id: ThreadId, request: Request) -> ThreadGoalResponse:
"""Clear the active goal for a thread.""" """Clear the active goal for a thread."""
checkpointer = get_checkpointer(request) checkpointer = get_checkpointer(request)
try:
accessor, read_config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
from app.gateway.hitl_approval import reject_new_run_when_approval_pending
await reject_new_run_when_approval_pending(request, thread_id, accessor=accessor, read_config=read_config)
except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc
try: try:
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()): async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
await write_thread_goal(checkpointer, thread_id, None, as_node="goal") await write_thread_goal(checkpointer, thread_id, None, as_node="goal")
batch_repo = getattr(request.app.state, "subagent_batch_repo", None)
if batch_repo is not None:
await batch_repo.suppress_thread_continuations(thread_id)
except ConflictError: except ConflictError:
raise HTTPException(status_code=409, detail="Thread has a run in flight. Clear the goal after the run finishes.") from None raise HTTPException(status_code=409, detail="Thread has a run in flight. Clear the goal after the run finishes.") from None
except LookupError: except LookupError:
@ -1774,14 +1554,11 @@ async def compact_thread(thread_id: ThreadId, body: ThreadCompactRequest, reques
# forward by checkpoint fork inheritance, so the base-schema mutation # forward by checkpoint fork inheritance, so the base-schema mutation
# graph is sufficient (and avoids building the full lead graph per call). # graph is sufficient (and avoids building the full lead graph per call).
try: try:
accessor, read_config = build_checkpoint_state_mutation_accessor( accessor, _ = build_checkpoint_state_mutation_accessor(
request, request,
thread_id=thread_id, thread_id=thread_id,
as_node="manual_compaction", as_node="manual_compaction",
) )
from app.gateway.hitl_approval import reject_new_run_when_approval_pending
await reject_new_run_when_approval_pending(request, thread_id)
except _CHECKPOINT_MODE_ERRORS as exc: except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc raise _checkpoint_mode_http_error(exc, thread_id) from exc
keep = body.keep.to_tuple() if body.keep is not None else None keep = body.keep.to_tuple() if body.keep is not None else None
@ -1816,32 +1593,6 @@ async def compact_thread(thread_id: ThreadId, body: ThreadCompactRequest, reques
return _thread_compact_response(result) return _thread_compact_response(result)
def _pending_interrupt_tasks(tasks_raw: Any, pending_interrupts: dict[str, list[Any]]) -> list[dict[str, Any]]:
"""Project pending interrupts without dropping run-store-only identities."""
tasks: list[dict[str, Any]] = []
matched_task_ids: set[str] = set()
for task in tasks_raw:
task_id = getattr(task, "id", "")
matched_task_ids.add(task_id)
tasks.append(
{
"id": task_id,
"name": getattr(task, "name", ""),
"interrupts": [serialize_lc_object(interrupt) for interrupt in pending_interrupts.get(task_id, [])],
}
)
for task_id, interrupts in pending_interrupts.items():
if task_id not in matched_task_ids:
tasks.append(
{
"id": task_id,
"name": "",
"interrupts": [serialize_lc_object(interrupt) for interrupt in interrupts],
}
)
return tasks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/{thread_id}/state", response_model=ThreadStateResponse) @router.get("/{thread_id}/state", response_model=ThreadStateResponse)
@require_permission("threads", "read", owner_check=True) @require_permission("threads", "read", owner_check=True)
@ -1865,12 +1616,13 @@ async def get_thread_state(thread_id: ThreadId, request: Request) -> ThreadState
checkpoint_id = snapshot_config.get("configurable", {}).get("checkpoint_id") checkpoint_id = snapshot_config.get("configurable", {}).get("checkpoint_id")
if not checkpoint_id: if not checkpoint_id:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
parent_config = snapshot.parent_config or {} parent_config = snapshot.parent_config or {}
pending_interrupts = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=config, snapshot=snapshot)
parent_checkpoint_id = parent_config.get("configurable", {}).get("checkpoint_id") parent_checkpoint_id = parent_config.get("configurable", {}).get("checkpoint_id")
metadata = snapshot.metadata or {} metadata = snapshot.metadata or {}
created_at = snapshot.created_at or metadata.get("created_at", "") created_at = snapshot.created_at or metadata.get("created_at", "")
tasks = _pending_interrupt_tasks(snapshot.tasks or (), pending_interrupts) tasks_raw = snapshot.tasks or ()
tasks = [{"id": getattr(task, "id", ""), "name": getattr(task, "name", "")} for task in tasks_raw]
values = serialize_channel_values_for_api(snapshot.values) values = serialize_channel_values_for_api(snapshot.values)
messages = values.get("messages") messages = values.get("messages")
@ -1896,9 +1648,7 @@ async def get_thread_state(thread_id: ThreadId, request: Request) -> ThreadState
async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse: async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse:
"""Replace selected thread-state fields through the materialized graph.""" """Replace selected thread-state fields through the materialized graph."""
from app.gateway.deps import get_thread_store from app.gateway.deps import get_thread_store
from app.gateway.hitl_approval import reject_new_run_when_approval_pending
await reject_new_run_when_approval_pending(request, thread_id)
thread_store = get_thread_store(request) thread_store = get_thread_store(request)
# Validate external roles before materializing a graph or reserving a write. # Validate external roles before materializing a graph or reserving a write.
values = strip_server_owned_state_metadata(dict(body.values or {})) values = strip_server_owned_state_metadata(dict(body.values or {}))
@ -1962,7 +1712,6 @@ async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateReques
updates[GOAL_OUTCOME_CHANNEL] = None updates[GOAL_OUTCOME_CHANNEL] = None
try: try:
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()): async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
await reject_new_run_when_approval_pending(request, thread_id)
source_metadata = await accessor.aget_metadata(read_config) source_metadata = await accessor.aget_metadata(read_config)
update_config = { update_config = {
**read_config, **read_config,
@ -1979,8 +1728,6 @@ async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateReques
raise HTTPException(status_code=409, detail="Thread has a run in flight. Update state after the run finishes.") from None raise HTTPException(status_code=409, detail="Thread has a run in flight. Update state after the run finishes.") from None
except _CHECKPOINT_MODE_ERRORS as exc: except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc raise _checkpoint_mode_http_error(exc, thread_id) from exc
except HTTPException:
raise
except Exception: except Exception:
logger.exception("Failed to update state for thread %s", sanitize_log_param(thread_id)) logger.exception("Failed to update state for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to update thread state") raise HTTPException(status_code=500, detail="Failed to update thread state")
@ -2249,7 +1996,6 @@ async def get_thread_history(
stamp_turn_duration_on_last_ai( stamp_turn_duration_on_last_ai(
serialized_msgs, serialized_msgs,
checkpoint_run_durations, checkpoint_run_durations,
thread_id=thread_id,
) )
raise raise
@ -2300,7 +2046,6 @@ async def get_thread_history(
stamp_turn_duration_on_last_ai( stamp_turn_duration_on_last_ai(
serialized_msgs, serialized_msgs,
resolved_run_durations, resolved_run_durations,
thread_id=thread_id,
) )
except Exception: except Exception:
@ -2316,14 +2061,10 @@ async def get_thread_history(
serialized_msgs, serialized_msgs,
) )
next_tasks = list(snapshot.next or ())
if is_latest_checkpoint:
pending_interrupts = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=config, snapshot=snapshot)
head_tasks = _pending_interrupt_tasks(getattr(snapshot, "tasks", None) or (), pending_interrupts)
else:
head_tasks = [{"id": getattr(task, "id", ""), "name": getattr(task, "name", "")} for task in (getattr(snapshot, "tasks", None) or ())]
is_latest_checkpoint = False is_latest_checkpoint = False
next_tasks = list(snapshot.next or ())
# Strip LangGraph internal keys from metadata # Strip LangGraph internal keys from metadata
user_meta = {k: v for k, v in metadata.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents", "run_durations", RUN_MESSAGE_IDS_METADATA_KEY)} user_meta = {k: v for k, v in metadata.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents", "run_durations", RUN_MESSAGE_IDS_METADATA_KEY)}
# Keep step for ordering context # Keep step for ordering context
@ -2338,7 +2079,6 @@ async def get_thread_history(
values=values, values=values,
created_at=coerce_iso(snapshot.created_at or metadata.get("created_at", "")), created_at=coerce_iso(snapshot.created_at or metadata.get("created_at", "")),
next=next_tasks, next=next_tasks,
tasks=head_tasks,
) )
) )
except _CHECKPOINT_MODE_ERRORS as exc: except _CHECKPOINT_MODE_ERRORS as exc:

View File

@ -14,7 +14,6 @@ not a server-enforced handshake (§11).
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import time import time
from typing import Literal from typing import Literal
@ -104,10 +103,13 @@ class PurgeResponse(BaseModel):
def _projects_config() -> ProjectsConfig: def _projects_config() -> ProjectsConfig:
"""Read the retention policy; an unavailable config must not shorten it.""" """Projects config, falling back to defaults when the app config is unavailable."""
from deerflow.config.app_config import get_app_config from deerflow.config.app_config import get_app_config
try:
return get_app_config().projects return get_app_config().projects
except Exception:
return ProjectsConfig()
def _not_found() -> HTTPException: def _not_found() -> HTTPException:
@ -147,11 +149,10 @@ async def list_trash_documents(
repo = get_project_document_repo(request) repo = get_project_document_repo(request)
user_id = get_effective_user_id() user_id = get_effective_user_id()
try: try:
config = await asyncio.to_thread(_projects_config)
await run_trash_retention_sweep( await run_trash_retention_sweep(
repo, repo,
get_paths(), get_paths(),
retention_days=config.trash_retention_days, retention_days=_projects_config().trash_retention_days,
user_id=user_id, user_id=user_id,
include_reconciliation=_reconciliation_due(user_id), include_reconciliation=_reconciliation_due(user_id),
) )

View File

@ -1,193 +0,0 @@
"""HTTP admission and ordinary AgentRuns dispatch for in-flight user inputs."""
import copy
import json
import logging
from typing import Literal
from uuid import NAMESPACE_URL, uuid5
from fastapi import HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.gateway.deps import get_agent_runs, get_current_user, get_run_manager
from deerflow.runtime.runs.inputs import InputCancelled, InputConflict, RunInputs
logger = logging.getLogger(__name__)
class RunInputRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
request_id: str = Field(min_length=1, max_length=96)
text: str = Field(min_length=1, max_length=16 * 1024)
mode: Literal["queue", "steer"]
context: dict = Field(default_factory=dict)
@model_validator(mode="after")
def bounded_context(self):
if len(json.dumps(self.context, ensure_ascii=True, allow_nan=False).encode()) > 32 * 1024:
raise ValueError("Input context exceeds 32 KiB")
return self
def _store(request):
store = get_run_manager(request).input_store
if store is None:
raise HTTPException(503, "Run inputs are unavailable")
return store
async def submit_run_input(thread_id: str, run_id: str, body: RunInputRequest, request: Request):
manager = get_run_manager(request)
user_id = await get_current_user(request)
if user_id is None:
raise HTTPException(401, "Authentication required")
run = await manager.get(run_id, user_id=user_id)
if run is None or run.thread_id != thread_id:
raise HTTPException(404, "Run not found")
if get_agent_runs(request) is None:
raise HTTPException(503, "Run inputs require an authenticated session")
row = await _store(request).get(run_id, user_id=user_id)
if row is None or row.get("run_inputs") is None:
raise HTTPException(409, "This Run has not enabled interactive inputs")
try:
from app.gateway.services import _CONTEXT_CONFIGURABLE_KEYS
from deerflow.runtime.run_origin import admit_origin
context = {key: value for key, value in body.context.items() if key in _CONTEXT_CONFIGURABLE_KEYS and key not in ("agent_name", "is_bootstrap")}
if isinstance(body.context.get("locale"), str):
context["locale"] = body.context["locale"]
source = "extension" if admit_origin(getattr(request.state, "run_origin", None)) is not None else "user"
item = await RunInputs(_store(request), run_id, user_id).submit(body.request_id, body.text, body.mode, context=context, source=source)
except InputConflict as exc:
raise HTTPException(409, str(exc)) from exc
return {**item, "target_run_id": run_id}
async def list_thread_inputs(thread_id: str, request: Request):
user_id = await get_current_user(request)
if user_id is None:
raise HTTPException(401, "Authentication required")
rows = await _store(request).list_input_runs(thread_id, user_id=user_id)
items = []
for row in reversed(rows):
for entry in (row.get("run_inputs") or {}).get("items", []):
status = entry["status"]
if status in ("pending", "applying") and (row["status"] not in ("pending", "running", "success") or row.get("stop_reason")):
status = "not_applied"
items.append({key: value for key, value in {**entry, "status": status, "target_run_id": row["run_id"]}.items() if key != "context"})
# Rank unresolved inputs (pending / not_applied / ...) ahead of terminal
# applied/cancelled receipts, prefer the newest entry within each group,
# cap at 32 records, then restore chronological order. A plain items[-32:]
# slice lets two full batches of receipts hide an older retryable input
# (issue #7972). If more than 32 inputs are unresolved, this deliberately
# returns 32 unresolved rows and no receipts for submission reconciliation;
# that trade-off follows the priority the issue asks for.
keep = sorted(range(len(items)), key=lambda i: (items[i]["status"] in ("applied", "cancelled"), -i))[:32]
return {"items": [items[i] for i in sorted(keep)], "active_run_id": await _store(request).active_input_run(thread_id, user_id=user_id)}
async def dispatch_queued_inputs(inputs: RunInputs, *, agent_runs, thread_id: str):
"""Use normal admission/authorization and its stable idempotency key.
This runs after the previous worker has fully finalized. No second main
Agent, task queue service or recovery scheduler is introduced.
"""
row = await inputs.store.get(inputs.run_id, user_id=inputs.user_id)
if row is None or row["status"] != "success" or row.get("stop_reason"):
return
state = await inputs.read()
for entry in state["items"]:
if entry["mode"] != "queue" or entry["status"] != "pending":
continue
try:
try:
run = await agent_runs.dispatch_input(thread_id=thread_id, run_id=inputs.run_id, input_id=entry["id"])
except InputCancelled:
# Cancellation can win after the snapshot, even during
# admission. The atomic claim proves no Run was started.
logger.info("Skipping cancelled queued input %s for Run %s", entry["id"], inputs.run_id)
continue
while run.status in ("pending", "running"):
# AgentRuns.wait retains its capped backoff and revocation
# checks; a long task exceeding a wait window is still live.
try:
run = await agent_runs.wait(thread_id=thread_id, run_id=run.run_id, timeout=60)
except TimeoutError:
continue
if run.status != "success" or run.stop_reason:
await inputs.pause()
return
except Exception:
# Unknown admission outcomes retain the original key and input;
# retrying can reconcile the same Run instead of running it twice.
logger.warning("Queued input remains undelivered for Run %s", inputs.run_id, exc_info=True)
return
async def dispatch_one_input(thread_id, run_id, input_id, request, *, retry=False):
from deerflow_extension_api.agent_runs import AgentRun
from app.gateway.routers.thread_runs import create_run, get_run
from app.gateway.run_models import RunCreateRequest
from app.gateway.services import resolve_thread_assistant_id
user_id = await get_current_user(request)
if user_id is None:
raise HTTPException(401, "Authentication required")
source = await _store(request).get(run_id, user_id=user_id)
if source is None or source["thread_id"] != thread_id:
raise HTTPException(404, "Run not found")
inputs = RunInputs(_store(request), run_id, user_id)
entry = await inputs.claim(input_id, retry=retry)
if entry.get("run_id"):
run = await get_run(thread_id, entry["run_id"], request)
return AgentRun(thread_id, run.run_id, run.status, run.assistant_id, run.stop_reason)
message_id = str(uuid5(NAMESPACE_URL, f"deerflow-dispatch:{inputs.run_id}:{input_id}"))
context = entry.get("context", {})
source_config = (source.get("kwargs") or {}).get("config") or {}
limit = source_config.get("recursion_limit")
config = {"recursion_limit": limit} if type(limit) is int else None
# Carry only the parent's admitted retrieval scope, never its files,
# credentials or framework markers. Normal admission rechecks access.
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_KEY
scope = None
for message in ((source.get("kwargs") or {}).get("input") or {}).get("messages", []):
if message.get("type", message.get("role")) in ("human", "user"):
scope = message.get("additional_kwargs", {}).get(KNOWLEDGE_SCOPE_KEY)
message = {"role": "user", "content": entry["text"], "id": message_id}
if scope is not None:
message["additional_kwargs"] = {KNOWLEDGE_SCOPE_KEY: copy.deepcopy(scope)}
if entry.get("source", "user") == "user":
# This fact comes from the stored, authenticated input, not from text
# or an arbitrary extension-supplied metadata flag.
request.state.run_origin = None
body = RunCreateRequest(assistant_id=await resolve_thread_assistant_id(request, thread_id, fail_closed=True), input={"messages": [message]}, context=context, config=config, on_disconnect="continue")
run = await create_run(thread_id, body, request, idempotency_key=f"run-input:{run_id}:{input_id}")
await inputs.dispatched(entry["id"], run.run_id)
return AgentRun(thread_id, run.run_id, run.status, run.assistant_id, run.stop_reason)
async def change_run_input(thread_id: str, run_id: str, input_id: str, request: Request, *, retry=False):
user_id = await get_current_user(request)
if user_id is None:
raise HTTPException(401, "Authentication required")
run = await get_run_manager(request).get(run_id, user_id=user_id)
if run is None or run.thread_id != thread_id:
raise HTTPException(404, "Run not found")
inputs = RunInputs(_store(request), run_id, user_id)
try:
if retry:
handle = get_agent_runs(request)
if handle is None:
raise HTTPException(503, "Run inputs require an authenticated session")
from deerflow.runtime.run_origin import admit_origin
if admit_origin(getattr(request.state, "run_origin", None)) is not None:
raise HTTPException(403, "An unapplied input requires an explicit user retry")
dispatched = await dispatch_one_input(thread_id, run_id, input_id, request, retry=True)
return {"run_id": dispatched.run_id}
await inputs.cancel(input_id)
return {"removed": True}
except InputConflict as exc:
raise HTTPException(409, str(exc)) from exc

View File

@ -40,7 +40,7 @@ class RunCreateRequest(BaseModel):
conversation_references: list[ConversationReference] = Field( conversation_references: list[ConversationReference] = Field(
default_factory=list, default_factory=list,
max_length=MAX_CONVERSATION_REFERENCES, max_length=MAX_CONVERSATION_REFERENCES,
description="Explicit thread IDs or chat URLs on this DeerFlow host readable only during this run (opt-in read_conversation tool); SDK clients may send the same list as context.conversation_references", description="Explicit thread IDs or same-origin chat URLs readable only during this run (opt-in read_conversation tool); SDK clients may send the same list as context.conversation_references",
) )
webhook: None = Field(default=None, description="Compatibility placeholder; completion callbacks are not supported") webhook: None = Field(default=None, description="Compatibility placeholder; completion callbacks are not supported")
checkpoint_id: str | None = Field(default=None, description="Resume from checkpoint") checkpoint_id: str | None = Field(default=None, description="Resume from checkpoint")

View File

@ -26,6 +26,7 @@ from app.gateway.deps import get_config, get_local_provider, get_scheduled_task_
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
from app.gateway.scheduled_task_errors import active_occurrence_error, repository_error, scheduler_error, tool_error_payload from app.gateway.scheduled_task_errors import active_occurrence_error, repository_error, scheduler_error, tool_error_payload
from app.gateway.scheduled_task_validation import prepare_resume_updates, validate_scheduled_task_create, validate_scheduled_task_update from app.gateway.scheduled_task_validation import prepare_resume_updates, validate_scheduled_task_create, validate_scheduled_task_update
from app.scheduler.service import TASK_CHANGED_ERROR
from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy
from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict
from deerflow.scheduler import host_notes from deerflow.scheduler import host_notes
@ -61,6 +62,99 @@ _HOST_RUN_ERROR_KEYS = {
_REASON_CODE = re.compile(r"[a-z_]+(:[a-z_]+)?") _REASON_CODE = re.compile(r"[a-z_]+(:[a-z_]+)?")
_ZONE_ERRORS = (ValueError, KeyError, OSError, TypeError) _ZONE_ERRORS = (ValueError, KeyError, OSError, TypeError)
# --- Trial consent -----------------------------------------------------------
_TRIAL_TRAILING_PUNCTUATION = ".!。!~~…"
_TRIAL_ACK_SEPARATORS = ",,、 "
_TRIAL_ACKS = tuple(sorted(("yes please", "yes", "sure", "ok", "okay", "please", "go ahead and", "alright", "好的", "好", "可以", "行", "嗯", "那就", "请", "麻烦"), key=len, reverse=True))
_TRIAL_PARTICLES = (" please", "吧", "呗", "啊", "呀", "哈", "看看")
_TRIAL_VERBS = ("run", "try", "test")
_TRIAL_TARGETS = ("it", "this task", "the task", "this")
_TRIAL_TIMINGS = ("now", "once", "once now", "right now", "now please")
_TRIAL_PHRASES = frozenset(
{f"{verb} {target} {timing}" for verb in _TRIAL_VERBS for target in _TRIAL_TARGETS for timing in _TRIAL_TIMINGS}
| {"run it", "try it", "run a trial", "run a trial now", "do a trial run", "do a test run", "do a trial run now", "do a test run now", "try it out", "test it now"}
| {
"先跑一次",
"现在跑一次",
"先试跑一次",
"现在试跑一次",
"先运行一次",
"现在运行一次",
"试跑一下",
"试跑一次",
"先试一下",
"先试试",
"试一下",
"试试",
"跑一次",
"跑一下",
"先跑一下",
"现在跑一下",
"马上跑一次",
"立即运行一次",
"运行一次",
"执行一次",
"先执行一次",
"现在执行一次",
"试运行一次",
"先试运行一次",
"现在试运行",
"立即试运行",
}
)
def _strip_trailing_punctuation(text: str) -> str:
return text.rstrip(_TRIAL_TRAILING_PUNCTUATION).strip()
def _strip_acknowledgement(command: str) -> str | None:
"""Remove one leading "yes"/"好的"-style word; None when there is none."""
for ack in _TRIAL_ACKS:
if not command.startswith(ack):
continue
rest = command[len(ack) :]
# An English word must end at a separator ("okay" is not "ok" + "ay").
if ack.isascii() and rest and rest[0] not in _TRIAL_ACK_SEPARATORS:
continue
return rest.lstrip(_TRIAL_ACK_SEPARATORS)
return None
def _has_explicit_trial_request(user_text: str, task: Mapping[str, Any]) -> bool:
"""Accept a bounded direct-run request in the current turn, never a keyword in arbitrary text.
This is deliberately not a general intent parser: after removing up to
two leading acknowledgements and one trailing particle, the whole turn
must be one of the known direct requests. Bare confirmations, quoted,
conditional or compound turns, other tasks and task titles are rejected.
"""
if not isinstance(user_text, str) or not user_text.strip() or len(user_text) > 500:
return False
command = _strip_trailing_punctuation(" ".join(user_text.casefold().split()))
for _ in range(2):
rest = _strip_acknowledgement(command)
if rest is None:
break
command = rest
if not command:
return False
for particle in _TRIAL_PARTICLES:
if command.endswith(particle) and len(command) > len(particle):
command = _strip_trailing_punctuation(command[: -len(particle)])
break
phrases = set(_TRIAL_PHRASES)
# Titles are arbitrary prose and may contain quoted/conditional clauses.
# Only an opaque selector can safely be interpolated into a direct request.
task_id = task.get("id")
if isinstance(task_id, str) and 5 < len(task_id) <= 64 and task_id.startswith("task-") and task_id.isascii() and task_id.replace("-", "").replace("_", "").isalnum():
target = task_id.casefold()
phrases.update(f"{verb} {target} {timing}" for verb in _TRIAL_VERBS for timing in _TRIAL_TIMINGS)
phrases.update({f"先试跑{target}", f"现在试跑{target}", f"先运行{target}一次", f"现在运行{target}一次"})
return command in phrases
# --- Projections --------------------------------------------------------------- # --- Projections ---------------------------------------------------------------
@ -451,15 +545,21 @@ class _SchedulerCapability:
return self._result("note", task=await self._view(updated)) return self._result("note", task=await self._view(updated))
async def _trial(self, task: dict[str, Any]) -> dict[str, Any]: async def _trial(self, task: dict[str, Any]) -> dict[str, Any]:
# Natural-language wording is not an execution authorization protocol. if not _has_explicit_trial_request(self._original_user_text, task):
# The authenticated task-card/page button owns manual dispatch. This raise scheduler_error(403, "trial_requires_direct_request", "A trial requires a direct request in the current user turn, such as 'Run this task now' or '先跑一次'; a task mention or bare confirmation is insufficient")
# capability has already checked owner, conversation and permissions. result = await self._service.dispatch_task(task, now=datetime.now(UTC), trigger="manual")
return self._result( outcome = result.get("outcome")
"trial", error = result.get("error") or "Scheduled task trial could not be dispatched"
task=await self._view(task), if outcome == "not_found":
requires_user_action="run_once_now", raise scheduler_error(404, "task_not_found", error)
instruction="No trial has started. Use the task card's Run once now button, or open /workspace/scheduled-tasks and run this task there.", if outcome == "conflict":
) raise scheduler_error(409, "task_changed" if error == TASK_CHANGED_ERROR else "task_running", error)
if outcome == "failed":
raise scheduler_error(502, "trigger_failed", error)
current = await self._task_repo.get(task["id"], user_id=self._owner_id) or task
thread_id = result.get("thread_id")
trial = {"outcome": outcome, "existing": bool(result.get("existing")), "thread_id": thread_id if isinstance(thread_id, str) else None}
return self._result("trial", task=await self._view(current), trial=trial)
async def _pause_or_delete(self, action: str, task: dict[str, Any]) -> dict[str, Any]: async def _pause_or_delete(self, action: str, task: dict[str, Any]) -> dict[str, Any]:
task_id = task["id"] task_id = task["id"]

View File

@ -147,17 +147,10 @@ def validate_task_bounds(
upcoming = _as_datetime(next_run_at) upcoming = _as_datetime(next_run_at)
if end > now and upcoming is not None and end <= upcoming: if end > now and upcoming is not None and end <= upcoming:
raise scheduler_error(422, "end_at_before_first_run", "end_at must allow at least one scheduled occurrence") raise scheduler_error(422, "end_at_before_first_run", "end_at must allow at least one scheduled occurrence")
if require_limit_for_frequent and max_runs is None and end_at is None: if require_limit_for_frequent and max_runs is None and end_at is None and is_frequent_schedule(schedule_type, schedule_spec):
if is_frequent_schedule(schedule_type, _require_schedule_object(schedule_spec)):
raise scheduler_error(422, "frequent_requires_limit", "Schedules more frequent than hourly require max_runs or end_at") raise scheduler_error(422, "frequent_requires_limit", "Schedules more frequent than hourly require max_runs or end_at")
def _require_schedule_object(spec: Any) -> dict[str, Any]:
if not isinstance(spec, dict):
raise scheduler_error(422, "invalid_schedule", "schedule_spec must be an object")
return spec
def _normalized_stop_condition(value: Any) -> str | None: def _normalized_stop_condition(value: Any) -> str | None:
if value is not None and not isinstance(value, str): if value is not None and not isinstance(value, str):
raise scheduler_error(422, "invalid_stop_condition", "stop_condition must be a string", max_chars=MAX_STOP_CONDITION_CHARS) raise scheduler_error(422, "invalid_stop_condition", "stop_condition must be a string", max_chars=MAX_STOP_CONDITION_CHARS)
@ -264,8 +257,7 @@ def _recompute_schedule(existing: dict[str, Any], updates: dict[str, Any], *, sc
schedule_type = str(updates.get("schedule_type", existing["schedule_type"])) schedule_type = str(updates.get("schedule_type", existing["schedule_type"]))
if schedule_type != existing["schedule_type"] and not isinstance(updates.get("schedule_spec"), dict): if schedule_type != existing["schedule_type"] and not isinstance(updates.get("schedule_spec"), dict):
raise scheduler_error(422, "invalid_schedule", "changing schedule_type requires schedule_spec") raise scheduler_error(422, "invalid_schedule", "changing schedule_type requires schedule_spec")
previous_spec = existing["schedule_spec"] schedule_spec = dict(updates["schedule_spec"]) if isinstance(updates.get("schedule_spec"), dict) else dict(existing["schedule_spec"])
schedule_spec = dict(_require_schedule_object(updates.get("schedule_spec", previous_spec)))
timezone = str(updates.get("timezone", existing["timezone"])) timezone = str(updates.get("timezone", existing["timezone"]))
try: try:
if schedule_type == "once": if schedule_type == "once":
@ -273,8 +265,7 @@ def _recompute_schedule(existing: dict[str, Any], updates: dict[str, Any], *, sc
# definitions and terminal reactivation must pass today's validation. # definitions and terminal reactivation must pass today's validation.
unchanged_definition = ( unchanged_definition = (
existing["schedule_type"] == "once" existing["schedule_type"] == "once"
and isinstance(previous_spec, dict) and schedule_spec.get("run_at") == existing["schedule_spec"].get("run_at")
and schedule_spec.get("run_at") == previous_spec.get("run_at")
and timezone == existing["timezone"] and timezone == existing["timezone"]
and existing["status"] not in TERMINAL_TASK_STATUSES and existing["status"] not in TERMINAL_TASK_STATUSES
and existing.get("next_run_at") is not None and existing.get("next_run_at") is not None
@ -288,12 +279,10 @@ def _recompute_schedule(existing: dict[str, Any], updates: dict[str, Any], *, sc
schedule_spec["cron"] = normalize_cron_expression(raw_cron) schedule_spec["cron"] = normalize_cron_expression(raw_cron)
if schedule_type == "interval": if schedule_type == "interval":
every_seconds = validate_interval_seconds(schedule_spec, scheduler_config.min_once_delay_seconds) every_seconds = validate_interval_seconds(schedule_spec, scheduler_config.min_once_delay_seconds)
previous_seconds = None
if existing["schedule_type"] == "interval" and isinstance(previous_spec, dict):
try: try:
previous_seconds = parse_interval_seconds(previous_spec) previous_seconds = parse_interval_seconds(dict(existing["schedule_spec"])) if existing["schedule_type"] == "interval" else None
except ValueError: except ValueError:
pass previous_seconds = None
# Re-saving an unchanged cadence keeps the next occurrence. # Re-saving an unchanged cadence keeps the next occurrence.
if previous_seconds == every_seconds and existing.get("next_run_at") is not None: if previous_seconds == every_seconds and existing.get("next_run_at") is not None:
upcoming = existing["next_run_at"] upcoming = existing["next_run_at"]
@ -390,11 +379,10 @@ def prepare_resume_updates(existing: dict[str, Any], renewal: dict[str, Any], *,
if existing.get("status") == "enabled": if existing.get("status") == "enabled":
return {} return {}
renewal = {key: value for key, value in renewal.items() if key in {"max_runs", "end_at"}} renewal = {key: value for key, value in renewal.items() if key in {"max_runs", "end_at"}}
schedule_spec = _require_schedule_object(existing["schedule_spec"])
upcoming = _as_datetime(existing.get("next_run_at")) upcoming = _as_datetime(existing.get("next_run_at"))
if upcoming is None or upcoming <= now: if upcoming is None or upcoming <= now:
try: try:
upcoming = next_run_at(existing["schedule_type"], schedule_spec, existing["timezone"], now=now) upcoming = next_run_at(existing["schedule_type"], existing["schedule_spec"], existing["timezone"], now=now)
except (ValueError, OverflowError) as exc: except (ValueError, OverflowError) as exc:
raise scheduler_error(422, "invalid_schedule", str(exc)) from exc raise scheduler_error(422, "invalid_schedule", str(exc)) from exc
if upcoming is None: if upcoming is None:

View File

@ -1,172 +0,0 @@
"""Permission-scoped transcript projection shared by search and message navigation."""
from __future__ import annotations
import hashlib
import json
import re
from deerflow_extension_api.search import SearchMessage, SearchMessagePage, SearchThread
from app.gateway.conversation_access import _visible_text
from app.gateway.conversation_reader import default_history_hidden_run_ids, read_visible_message_page
from deerflow.runtime.events.message_identity import message_identity
from deerflow.utils.time import coerce_iso
def visible_search_text(row: dict, *, thread_id: str):
"""Use the transcript reader and the chat UI's display-only exclusions."""
content = row.get("content")
if isinstance(content, dict) and content.get("name") in {"summary", "loop_warning", "todo_reminder", "todo_completion_reminder"}:
return None
visible = _visible_text(row, thread_id=thread_id)
if visible and visible[0] == "user":
text = re.sub(r"<(current_uploads|uploaded_files|slash_skill_activation)>[\s\S]*?</\1>", "", visible[1]).strip()
return ("user", text) if text else None
return visible
class GatewaySearchCorpus:
def __init__(self, thread_store, event_store, run_manager, user_id: str, *, request=None):
self.thread_store = thread_store
self.event_store = event_store
self.run_manager = run_manager
self.user_id = user_id
self.request = request
async def threads(self) -> tuple[SearchThread, ...]:
result = []
offset = 0
while True:
rows = await self.thread_store.search(user_id=self.user_id, limit=200, offset=offset)
for row in rows:
metadata = row.get("metadata") or {}
if metadata.get("deerflow_sidecar") is True:
continue
result.append(self.project_thread(row))
offset += len(rows)
if len(rows) < 200:
return tuple(result)
@staticmethod
def project_thread(row) -> SearchThread:
from app.gateway.services import custom_agent_name
try:
agent_name = custom_agent_name(row.get("assistant_id"))
except (TypeError, ValueError):
agent_name = None
metadata = row.get("metadata") or {}
return SearchThread(
thread_id=row["thread_id"],
incarnation=str(row.get("incarnation") or row.get("created_at") or ""),
title=row.get("display_name") or "",
updated_at=coerce_iso(row.get("updated_at", "")),
agent_name=agent_name,
project_id=metadata.get("deerflow_project_id"),
archived=metadata.get("deerflow_archived") is True,
)
async def thread(self, thread_id: str) -> SearchThread | None:
row = await self.thread_store.get(thread_id, user_id=self.user_id)
if row is None or (row.get("metadata") or {}).get("deerflow_sidecar") is True:
return None
return self.project_thread(row)
async def seed_legacy_history(self, thread: SearchThread) -> None:
if self.request is None or await self.event_store.list_messages(thread.thread_id, limit=1, user_id=None):
return
row = await self.thread_store.get(thread.thread_id, user_id=self.user_id)
if row is None or self.project_thread(row).incarnation != thread.incarnation:
return
from app.gateway.services import ensure_checkpoint_history_seeded
await ensure_checkpoint_history_seeded(self.request, thread_id=thread.thread_id, assistant_id=row.get("assistant_id"))
async def owns(self, thread: SearchThread) -> bool:
row = await self.thread_store.get(thread.thread_id, user_id=self.user_id)
return bool(row and str(row.get("incarnation") or row.get("created_at") or "") == thread.incarnation and (row.get("metadata") or {}).get("deerflow_sidecar") is not True)
async def revision(self, thread: SearchThread) -> str | None:
if not await self.owns(thread):
return None
await self.seed_legacy_history(thread)
latest = await self.event_store.list_messages(thread.thread_id, limit=1, user_id=self.user_id)
hidden = await default_history_hidden_run_ids(self.run_manager, thread.thread_id, user_id=self.user_id)
revision = [thread.incarnation, thread.updated_at, latest[-1]["seq"] if latest else 0, sorted(hidden)]
return hashlib.sha256(json.dumps(revision).encode()).hexdigest()
@staticmethod
def project(thread: SearchThread, row: dict) -> SearchMessage | None:
visible = visible_search_text(row, thread_id=thread.thread_id)
content = row.get("content")
identity = message_identity(content) if isinstance(content, dict) else None
if not visible or not identity:
return None
return SearchMessage(thread.thread_id, thread.incarnation, identity.removeprefix("message:"), row["run_id"], row["seq"], visible[0], visible[1], coerce_iso(row.get("created_at", "")))
async def messages(self, thread: SearchThread, *, before_seq: int | None) -> SearchMessagePage:
if not await self.owns(thread):
return SearchMessagePage()
rows, more = await self._page(thread.thread_id, limit=200, before_seq=before_seq)
items = tuple(message for row in rows if (message := self.project(thread, row)) is not None)
return SearchMessagePage(items, rows[0]["seq"] if more else None)
async def _page(self, thread_id: str, *, limit: int, before_seq=None, after_seq=None):
return await read_visible_message_page(
event_store=self.event_store,
run_manager=self.run_manager,
thread_id=thread_id,
user_id=self.user_id,
limit=limit,
before_seq=before_seq,
after_seq=after_seq,
message_filter=lambda row: visible_search_text(row, thread_id=thread_id) is not None and message_identity(row.get("content", {})) is not None,
)
async def around(self, thread_id: str, message_id: str, *, limit: int = 20, seq: int | None = None) -> dict | None:
row = await self.thread_store.get(thread_id, user_id=self.user_id)
if row is None or (row.get("metadata") or {}).get("deerflow_sidecar") is True:
return None
await self.seed_legacy_history(self.project_thread(row))
# Walk backward from the head so later versions win even when they
# live on another storage page. Sequence hints never select old text.
rows = await self.latest_rows(thread_id)
index = next((i for i, item in enumerate(rows) if message_identity(item["content"]) == f"message:{message_id}"), None)
if index is None or (seq is not None and rows[index]["seq"] != seq):
return None
target = rows[index]
seq = target["seq"]
left, right = max(0, index - limit), min(len(rows), index + limit + 1)
older, newer = rows[left:index], rows[index + 1 : right]
has_before, has_after = left > 0, right < len(rows)
# Recheck ownership/incarnation after the asynchronous history reads.
current = await self.thread_store.get(thread_id, user_id=self.user_id)
if current is None or current.get("incarnation") != row.get("incarnation"):
return None
return {"data": older + [target] + newer, "target_seq": seq, "has_before": has_before, "has_after": has_after}
async def latest_rows(self, thread_id: str) -> list[dict]:
latest = {}
before = None
while True:
rows, more = await self._page(thread_id, limit=200, before_seq=before)
for row in reversed(rows):
latest.setdefault(message_identity(row["content"]), row)
if not more:
break
before = rows[0]["seq"]
return sorted(latest.values(), key=lambda row: row["seq"])
async def latest_message(self, thread: SearchThread, message_id: str) -> SearchMessage | None:
if not await self.owns(thread):
return None
before = None
while True:
rows, more = await self._page(thread.thread_id, limit=200, before_seq=before)
for row in reversed(rows):
if message_identity(row["content"]) == f"message:{message_id}":
return self.project(thread, row)
if not more:
return None
before = rows[0]["seq"]

View File

@ -1,30 +0,0 @@
"""Shared masking for credential values that Gateway routes echo back from config."""
import re
from collections.abc import Callable
from typing import Any
MASKED_VALUE = "***"
def normalize_config_key(key: str) -> str:
"""Snake-case a config key so ``apiKey``, ``API-Key`` and ``api_key`` compare equal."""
with_boundaries = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", key)
with_boundaries = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", with_boundaries)
return re.sub(r"[^a-z0-9]+", "_", with_boundaries.lower()).strip("_")
def mask_secret_values(value: Any, is_secret_key: Callable[[str], bool], *, keep_unset: bool = False) -> Any:
"""Return a copy of ``value`` with every value under a secret-named key masked, at any depth.
A secret-named key masks its whole value, containers included: a
``credentials`` mapping can hold keys under arbitrary names, so masking only
its secret-named leaves could pass one through. With ``keep_unset``, a
``None`` or ``""`` value stays as it is, so a response still shows that no
secret is configured.
"""
if isinstance(value, dict):
return {key: MASKED_VALUE if is_secret_key(str(key)) and not (keep_unset and nested in (None, "")) else mask_secret_values(nested, is_secret_key, keep_unset=keep_unset) for key, nested in value.items()}
if isinstance(value, list):
return [mask_secret_values(item, is_secret_key, keep_unset=keep_unset) for item in value]
return value

View File

@ -30,8 +30,7 @@ from langgraph.types import Command
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, AUTH_SOURCE_INTERNAL, get_auth_disabled_user, is_auth_disabled from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, AUTH_SOURCE_INTERNAL, get_auth_disabled_user, is_auth_disabled
from app.gateway.authz import require_cancel_permission_if, resolve_route_permissions from app.gateway.authz import require_cancel_permission_if, resolve_route_permissions
from app.gateway.deps import get_checkpointer, get_local_provider, get_run_context, get_run_manager, get_run_store, get_stream_bridge from app.gateway.deps import get_checkpointer, get_local_provider, get_run_context, get_run_manager, get_stream_bridge
from app.gateway.history_seed import reserve_history_seed, write_history_seed
from app.gateway.internal_auth import ( from app.gateway.internal_auth import (
INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_OWNER_USER_ID_HEADER_NAME,
INTERNAL_SYSTEM_ROLE, INTERNAL_SYSTEM_ROLE,
@ -55,7 +54,7 @@ from deerflow.agents.middlewares.tool_receipt import TOOL_RECEIPT_KEY, TOOL_RECE
from deerflow.agents.middlewares.tool_transform_meta import TOOL_TRANSFORMS_KEY from deerflow.agents.middlewares.tool_transform_meta import TOOL_TRANSFORMS_KEY
from deerflow.agents.middlewares.view_image_middleware import _IMAGE_CONTEXT_MESSAGE_MARKER_KEY from deerflow.agents.middlewares.view_image_middleware import _IMAGE_CONTEXT_MESSAGE_MARKER_KEY
from deerflow.config.agents_config import load_agent_config from deerflow.config.agents_config import load_agent_config
from deerflow.config.app_config import AppConfig, get_app_config from deerflow.config.app_config import get_app_config
from deerflow.config.database_config import resolve_checkpoint_graph_cache_max from deerflow.config.database_config import resolve_checkpoint_graph_cache_max
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_KEY, KNOWLEDGE_SCOPE_RUNTIME_KEY from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_KEY, KNOWLEDGE_SCOPE_RUNTIME_KEY
from deerflow.mcp_scope import ( from deerflow.mcp_scope import (
@ -92,7 +91,7 @@ from deerflow.runtime.checkpoint_mode import (
) )
from deerflow.runtime.checkpoint_state import graph_state_schema from deerflow.runtime.checkpoint_state import graph_state_schema
from deerflow.runtime.client_context import sanitize_client_context from deerflow.runtime.client_context import sanitize_client_context
from deerflow.runtime.context_keys import AGENT_AUTH_CONTEXT_KEY, PROJECT_CONTEXT_KEY from deerflow.runtime.context_keys import PROJECT_CONTEXT_KEY
from deerflow.runtime.events.message_identity import MESSAGE_SEQ_KEY from deerflow.runtime.events.message_identity import MESSAGE_SEQ_KEY
from deerflow.runtime.goal import goal_thread_lock from deerflow.runtime.goal import goal_thread_lock
from deerflow.runtime.journal import build_checkpoint_history_seed_events from deerflow.runtime.journal import build_checkpoint_history_seed_events
@ -106,7 +105,7 @@ from deerflow.runtime.secret_context import (
validate_run_metadata_secrets, validate_run_metadata_secrets,
) )
from deerflow.runtime.stream_modes import normalize_stream_modes from deerflow.runtime.stream_modes import normalize_stream_modes
from deerflow.runtime.user_context import get_current_user, reset_current_user, set_current_user from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.sandbox.lease import SANDBOX_SERVER_OWNED_CONTEXT_KEYS from deerflow.sandbox.lease import SANDBOX_SERVER_OWNED_CONTEXT_KEYS
from deerflow.scheduler.runtime import scheduler_tools_enabled from deerflow.scheduler.runtime import scheduler_tools_enabled
from deerflow.scheduler.schedules import validate_timezone from deerflow.scheduler.schedules import validate_timezone
@ -158,7 +157,6 @@ async def reserve_checkpoint_write(
_TERMINAL_RUN_STATUSES = { _TERMINAL_RUN_STATUSES = {
RunStatus.success, RunStatus.success,
RunStatus.waiting_for_input,
RunStatus.error, RunStatus.error,
RunStatus.timeout, RunStatus.timeout,
RunStatus.interrupted, RunStatus.interrupted,
@ -166,8 +164,6 @@ _TERMINAL_RUN_STATUSES = {
_TERMINAL_STREAM_RECOVERED = "recovered" _TERMINAL_STREAM_RECOVERED = "recovered"
_TERMINAL_STREAM_PRODUCER_EXPIRED = "producer_expired" _TERMINAL_STREAM_PRODUCER_EXPIRED = "producer_expired"
# Includes same-thread create admission and metadata I/O; timeout rejects strict
# starts and leaves non-strict starts without verified metadata (MCP fails closed).
_THREAD_METADATA_SETUP_TIMEOUT_SECONDS = 5.0 _THREAD_METADATA_SETUP_TIMEOUT_SECONDS = 5.0
# Message metadata a scheduled launch puts on its prompt: the task, run and the # Message metadata a scheduled launch puts on its prompt: the task, run and the
@ -204,10 +200,6 @@ _SERVER_OWNED_MESSAGE_METADATA_KEYS = (
SUBAGENT_RECEIPT_VERDICT_KEY, SUBAGENT_RECEIPT_VERDICT_KEY,
SUBAGENT_ACCEPTANCE_VERDICT_KEY, SUBAGENT_ACCEPTANCE_VERDICT_KEY,
UNTRUSTED_INPUT_KEY, UNTRUSTED_INPUT_KEY,
"run_input_id",
"run_input_source",
"origin_task_id",
"subagent_pending",
} }
) )
| PROVENANCE_KEYS | PROVENANCE_KEYS
@ -269,18 +261,6 @@ async def _ensure_thread_metadata(
require_existing_thread: bool = False, require_existing_thread: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Ensure an admitted run's thread exists without delaying task attachment.""" """Ensure an admitted run's thread exists without delaying task attachment."""
async with goal_thread_lock(record.thread_id):
return await _ensure_thread_metadata_locked(run_ctx, record, owner_user_id=owner_user_id, require_existing_thread=require_existing_thread)
async def _ensure_thread_metadata_locked(
run_ctx: RunContext,
record: RunRecord,
*,
owner_user_id: str | None,
require_existing_thread: bool,
) -> dict[str, Any]:
"""Share create admission with HTTP while retaining detached task ownership."""
thread_store = run_ctx.thread_store thread_store = run_ctx.thread_store
existing = await thread_store.get(record.thread_id) existing = await thread_store.get(record.thread_id)
if existing is None and owner_user_id: if existing is None and owner_user_id:
@ -570,17 +550,15 @@ def _strip_external_metadata_from_message_like(item: Any) -> Any:
# unmarked ``name="summary"`` would reach the model on the guardrail's # unmarked ``name="summary"`` would reach the model on the guardrail's
# genuine-user fallback. Treat it as empty for both steps rather than # genuine-user fallback. Treat it as empty for both steps rather than
# returning early. # returning early.
raw_kwargs = item.get("additional_kwargs") source_kwargs = item.get("additional_kwargs")
source_kwargs = raw_kwargs if isinstance(raw_kwargs, dict) else {} source_kwargs = source_kwargs if isinstance(source_kwargs, dict) else {}
additional_kwargs = {key: value for key, value in source_kwargs.items() if key not in _SERVER_OWNED_MESSAGE_METADATA_KEYS and key != ORIGINAL_USER_CONTENT_KEY} additional_kwargs = {key: value for key, value in source_kwargs.items() if key not in _SERVER_OWNED_MESSAGE_METADATA_KEYS and key != ORIGINAL_USER_CONTENT_KEY}
if _is_human_message_like(item): if _is_human_message_like(item):
additional_kwargs = _mark_untrusted_framework_markers(additional_kwargs, item.get("name")) additional_kwargs = _mark_untrusted_framework_markers(additional_kwargs, item.get("name"))
if additional_kwargs == raw_kwargs: if additional_kwargs == source_kwargs:
# Nothing to change — including the ordinary key-omitted message, which # Nothing to change — including the ordinary key-omitted message, which
# must not gain an empty dict just by passing through here. # must not gain an empty dict just by passing through here.
return item return item
if "additional_kwargs" not in item and not additional_kwargs:
return item
return {**item, "additional_kwargs": additional_kwargs} return {**item, "additional_kwargs": additional_kwargs}
@ -643,132 +621,7 @@ def _normalize_input_messages(
#: State channels only the server writes. ``goal_outcome`` is the "goal met" #: State channels only the server writes. ``goal_outcome`` is the "goal met"
#: record that clearing a satisfied goal writes; a caller copy would forge one. #: record that clearing a satisfied goal writes; a caller copy would forge one.
SERVER_OWNED_STATE_CHANNELS = frozenset({"sandbox", "thread_data", "viewed_images", "goal_outcome", "applied_run_inputs"}) SERVER_OWNED_STATE_CHANNELS = frozenset({"sandbox", "thread_data", "viewed_images", "goal_outcome"})
#: The ``write_todos`` status contract the todo middleware renders.
_TODO_STATUSES = frozenset({"pending", "in_progress", "completed"})
def _normalize_external_todos(value: Any, *, location: str) -> list[dict[str, Any]] | None:
"""Reject todo lists the middleware cannot render.
``todos`` is a reducer channel a caller may Overwrite directly, and the todo
middleware reads it on every model call. A value that is not a list of
objects with string content would fail those calls on every turn instead of
being refused here, so validate the shape at the boundary the same way
``goal`` is.
"""
if value is None:
return None
if not isinstance(value, list):
raise HTTPException(status_code=400, detail=f"{location} must be a list of todo objects")
normalized: list[dict[str, Any]] = []
for index, entry in enumerate(value):
if not isinstance(entry, Mapping):
raise HTTPException(status_code=400, detail=f"{location}[{index}] must be an object")
content = entry.get("content")
if not isinstance(content, str):
raise HTTPException(status_code=400, detail=f"{location}[{index}].content must be a string")
status = entry.get("status", "pending")
if not isinstance(status, str) or status not in _TODO_STATUSES:
raise HTTPException(status_code=400, detail=f"{location}[{index}].status must be pending, in_progress, or completed")
normalized.append({**entry, "content": content, "status": status})
return normalized
def _normalize_external_skill_context(value: Any, *, location: str) -> list[dict[str, Any]] | None:
"""Reject skill references the durable-context reducer cannot read.
``skill_context`` is a checkpointed reducer channel a caller may Overwrite.
``merge_skill_context`` normalizes each entry with ``entry.get(...)`` and
``str(entry["path"])``, so a non-object entry or a missing path raises while
the state update merges — failing the run with an unhandled
``AttributeError``/``KeyError`` instead of a 400 at this boundary. The other
fields are optional: the reducer coerces ``name``/``description`` and
``loaded_at`` itself, so only the path needs to be usable here.
"""
if value is None:
return None
if not isinstance(value, list):
raise HTTPException(status_code=400, detail=f"{location} must be a list of skill reference objects")
normalized: list[dict[str, Any]] = []
for index, entry in enumerate(value):
if not isinstance(entry, Mapping):
raise HTTPException(status_code=400, detail=f"{location}[{index}] must be an object")
path = entry.get("path")
if not isinstance(path, str) or not path:
raise HTTPException(status_code=400, detail=f"{location}[{index}].path must be a non-empty string")
normalized.append({**entry, "name": str(entry.get("name") or "")})
return normalized
def _reject_overwrite_marker(value: Mapping[str, Any], *, location: str) -> None:
"""Refuse a value LangGraph would apply with ``Overwrite`` semantics.
``Overwrite`` reaches the validator in two dict spellings: the sentinel form
``{"__overwrite__": value}`` and the dataclass-erased form an orjson round
trip produces, ``{"type": "__overwrite__", "value": value}``. Either one
bypasses the channel reducer and stores the caller's value directly on an
initialized channel, so a shape check alone cannot catch it.
"""
if "__overwrite__" in value or value.get("type") == "__overwrite__":
raise HTTPException(status_code=400, detail=f"{location} cannot contain an Overwrite marker")
def _normalize_external_promoted(value: Any, *, location: str) -> dict[str, Any] | None:
"""Reject deferred-tool promotion records the reducer cannot merge.
``promoted`` is a checkpointed reducer channel a caller may Overwrite even
though only the server writes it. ``merge_promoted`` calls ``new.get(...)``
and indexes ``new["catalog_hash"]``/``new["names"]``, so a string, a missing
key or a non-list ``names`` raises while the state update merges — failing
the run with an unhandled ``AttributeError``/``KeyError``/``TypeError``
instead of a 400 at this boundary. On the thread-state route the bad shape
would also be persisted, so every later promotion write on that thread would
keep failing on it.
"""
if value is None:
return None
if not isinstance(value, Mapping):
raise HTTPException(status_code=400, detail=f"{location} must be an object with a catalog_hash and a names list")
_reject_overwrite_marker(value, location=location)
catalog_hash = value.get("catalog_hash")
if not isinstance(catalog_hash, str) or not catalog_hash:
raise HTTPException(status_code=400, detail=f"{location}.catalog_hash must be a non-empty string")
names = value.get("names")
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
raise HTTPException(status_code=400, detail=f"{location}.names must be a list of strings")
turn_id = value.get("turn_id")
if turn_id is not None and not isinstance(turn_id, str):
raise HTTPException(status_code=400, detail=f"{location}.turn_id must be a string")
return {**value, "catalog_hash": catalog_hash, "names": names}
def _normalize_external_tool_artifacts(value: Any, *, location: str) -> list[dict[str, Any]] | None:
"""Reject artifact-registry entries the reducer cannot key.
``tool_artifacts`` is a checkpointed reducer channel a caller may Overwrite.
``merge_tool_artifacts`` keys each entry by its ``handle``, so an entry whose
handle is missing or unhashable raises while the state update merges — a 500
instead of a 400 at this boundary. The thread-state route persists the value
with ``Overwrite`` before any reducer sees it, so the thread stays broken for
every later artifact capture. Require the registry's list-of-objects shape
with a non-empty string handle; the reducer also tolerates an already-poisoned
checkpoint.
"""
if value is None:
return None
if not isinstance(value, list):
raise HTTPException(status_code=400, detail=f"{location} must be a list of artifact objects")
normalized: list[dict[str, Any]] = []
for index, entry in enumerate(value):
if not isinstance(entry, Mapping):
raise HTTPException(status_code=400, detail=f"{location}[{index}] must be an object")
handle = entry.get("handle")
if not isinstance(handle, str) or not handle:
raise HTTPException(status_code=400, detail=f"{location}[{index}].handle must be a non-empty string")
normalized.append(dict(entry))
return normalized
def _normalize_external_goal(value: Any, *, location: str) -> dict[str, Any] | None: def _normalize_external_goal(value: Any, *, location: str) -> dict[str, Any] | None:
@ -777,7 +630,8 @@ def _normalize_external_goal(value: Any, *, location: str) -> dict[str, Any] | N
return None return None
if not isinstance(value, Mapping) or not isinstance(value.get("objective"), str): if not isinstance(value, Mapping) or not isinstance(value.get("objective"), str):
raise HTTPException(status_code=400, detail=f"{location} must be an object with a string objective") raise HTTPException(status_code=400, detail=f"{location} must be an object with a string objective")
_reject_overwrite_marker(value, location=location) if "__overwrite__" in value or value.get("type") == "__overwrite__":
raise HTTPException(status_code=400, detail=f"{location} cannot contain an Overwrite marker")
try: try:
objective = normalize_goal_objective(value["objective"]) objective = normalize_goal_objective(value["objective"])
except ValueError as exc: except ValueError as exc:
@ -785,7 +639,7 @@ def _normalize_external_goal(value: Any, *, location: str) -> dict[str, Any] | N
for field in ("continuation_count", "max_continuations", "no_progress_count", "max_no_progress_continuations"): for field in ("continuation_count", "max_continuations", "no_progress_count", "max_no_progress_continuations"):
if field in value and (type(value[field]) is not int or value[field] < 0): if field in value and (type(value[field]) is not int or value[field] < 0):
raise HTTPException(status_code=400, detail=f"{location}.{field} must be a non-negative integer") raise HTTPException(status_code=400, detail=f"{location}.{field} must be a non-negative integer")
return {**{key: item for key, item in value.items() if key != "last_batch_wakeup_run_id"}, "objective": objective} return {**value, "objective": objective}
def strip_server_owned_state_metadata(values: Mapping[str, Any]) -> dict[str, Any]: def strip_server_owned_state_metadata(values: Mapping[str, Any]) -> dict[str, Any]:
@ -814,29 +668,10 @@ def strip_server_owned_state_metadata(values: Mapping[str, Any]) -> dict[str, An
for channel, value in values.items(): for channel, value in values.items():
if channel == "goal": if channel == "goal":
stripped[channel] = _strip_external_metadata_from_message_like(_normalize_external_goal(value, location="values.goal")) stripped[channel] = _strip_external_metadata_from_message_like(_normalize_external_goal(value, location="values.goal"))
elif channel == "todos":
normalized_todos = _normalize_external_todos(value, location="values.todos")
# Todos keep their extra fields, but they are still caller-supplied
# channel values: a dedicated validator must not bypass the
# per-entry metadata strip the generic list branch applies.
stripped[channel] = None if normalized_todos is None else [_strip_external_metadata_from_message_like(item) for item in normalized_todos]
elif channel == "messages" and value is not None: elif channel == "messages" and value is not None:
stripped[channel] = _normalize_input_messages(value, location="values.messages") stripped[channel] = _normalize_input_messages(value, location="values.messages")
elif channel == "delegations" and isinstance(value, list): elif channel == "delegations" and isinstance(value, list):
stripped[channel] = [_strip_external_delegation_verdict(item) for item in value] stripped[channel] = [_strip_external_delegation_verdict(item) for item in value]
elif channel == "skill_context":
normalized_skill_context = _normalize_external_skill_context(value, location="values.skill_context")
stripped[channel] = None if normalized_skill_context is None else [_strip_external_metadata_from_message_like(item) for item in normalized_skill_context]
elif channel == "promoted":
# A dedicated validator must not bypass the metadata strip the
# generic branches apply to caller-supplied channel values.
stripped[channel] = _strip_external_metadata_from_message_like(_normalize_external_promoted(value, location="values.promoted"))
elif channel == "tool_artifacts":
normalized_tool_artifacts = _normalize_external_tool_artifacts(value, location="values.tool_artifacts")
# Registry entries are rich dicts, so keep the per-entry metadata
# strip the generic list branch applied: a caller-crafted entry must
# not smuggle server-owned message metadata into the checkpoint.
stripped[channel] = None if normalized_tool_artifacts is None else [_strip_external_metadata_from_message_like(item) for item in normalized_tool_artifacts]
elif isinstance(value, list): elif isinstance(value, list):
stripped[channel] = [_strip_external_metadata_from_message_like(item) for item in value] stripped[channel] = [_strip_external_metadata_from_message_like(item) for item in value]
else: else:
@ -905,26 +740,9 @@ def normalize_input(raw_input: dict[str, Any] | None, *, trusted_internal: bool
cleaned = [_strip_external_delegation_verdict(entry) for entry in delegations] cleaned = [_strip_external_delegation_verdict(entry) for entry in delegations]
if cleaned != delegations: if cleaned != delegations:
result = {**result, "delegations": cleaned} result = {**result, "delegations": cleaned}
if "skill_context" in result:
result = {**result, "skill_context": _normalize_external_skill_context(result["skill_context"], location="input.skill_context")}
if "promoted" in result:
result = {**result, "promoted": _normalize_external_promoted(result["promoted"], location="input.promoted")}
if "tool_artifacts" in result:
result = {**result, "tool_artifacts": _normalize_external_tool_artifacts(result["tool_artifacts"], location="input.tool_artifacts")}
# merge_goal ignores None, so only a goal value replaces the goal. # merge_goal ignores None, so only a goal value replaces the goal.
if result.get("goal") is not None: if result.get("goal") is not None:
normalized_goal = _normalize_external_goal(result["goal"], location="input.goal") result = {**result, "goal": _normalize_external_goal(result["goal"], location="input.goal"), "goal_outcome": None}
result = {
**result,
"goal": _strip_external_metadata_from_message_like(normalized_goal),
"goal_outcome": None,
}
if "todos" in result:
normalized_todos = _normalize_external_todos(result["todos"], location="input.todos")
result = {
**result,
"todos": None if normalized_todos is None else [_strip_external_metadata_from_message_like(item) for item in normalized_todos],
}
return result return result
@ -966,11 +784,11 @@ def _canonical_run_record_input(
raw_input: dict[str, Any] | None, raw_input: dict[str, Any] | None,
graph_input: object, graph_input: object,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Persist the same normalized messages, goal, and todos that cross run admission. """Persist the same normalized messages that cross run admission.
The run record is a client-visible audit surface. Keeping the original raw The run record is a client-visible audit surface. Keeping the original raw
message or state metadata there would preserve a non-canonical scope even message there would preserve a non-canonical scope even though the graph
though the graph receives the validated form. receives the validated form.
""" """
if not isinstance(graph_input, dict): if not isinstance(graph_input, dict):
return raw_input return raw_input
@ -978,9 +796,6 @@ def _canonical_run_record_input(
messages = graph_input.get("messages") messages = graph_input.get("messages")
if isinstance(messages, list): if isinstance(messages, list):
canonical["messages"] = [message.model_dump(mode="json") if isinstance(message, BaseMessage) else message for message in messages] canonical["messages"] = [message.model_dump(mode="json") if isinstance(message, BaseMessage) else message for message in messages]
for key in ("goal", "goal_outcome", "todos", "delegations"):
if key in graph_input:
canonical[key] = graph_input[key]
return canonical return canonical
@ -1053,45 +868,6 @@ def _legacy_stored_idempotency_request(kwargs: dict[str, Any]) -> dict[str, Any]
return identity return identity
async def _find_admitted_run_for_idempotency_key(
run_mgr: RunManager,
thread_id: str,
idempotency_key: str,
*,
user_id: str | None,
) -> RunRecord | None:
"""Resolve an existing key on this thread and owner before pause checks."""
before_created_at: str | None = None
before_run_id: str | None = None
while True:
records = await run_mgr.list_by_thread(
thread_id,
user_id=user_id,
limit=100,
before_created_at=before_created_at,
before_run_id=before_run_id,
)
for record in records:
if record.idempotency_key == idempotency_key and record.user_id == user_id:
return record
if len(records) < 100:
return None
cursor = records[-1]
before_created_at = cursor.created_at
before_run_id = cursor.run_id
def _is_retryable_resume_start_failure(record: RunRecord | None) -> bool:
"""Whether admission failed before a resume worker could attach."""
return record is not None and record.status == RunStatus.error and isinstance(record.error, str) and record.error.startswith("Failed to attach run worker:")
async def _discard_failed_resume_admission(request: Request, run_mgr: RunManager, record: RunRecord) -> None:
"""Release a failed admission's key so an identical retry can start work."""
await get_run_store(request).delete(record.run_id, user_id=record.user_id)
await run_mgr.cleanup(record.run_id, delay=0)
_DEFAULT_ASSISTANT_ID = "lead_agent" _DEFAULT_ASSISTANT_ID = "lead_agent"
@ -1104,7 +880,6 @@ _DEFAULT_ASSISTANT_ID = "lead_agent"
_CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset( _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
{ {
"model_name", "model_name",
"tool_approval_mode",
"mode", "mode",
"thinking_enabled", "thinking_enabled",
"reasoning_effort", "reasoning_effort",
@ -1143,8 +918,6 @@ _SERVER_OWNED_RUNTIME_CONTEXT_KEYS: frozenset[str] = (
"__run_loop_detection_recorder", "__run_loop_detection_recorder",
"__run_tool_promotion_recorder", "__run_tool_promotion_recorder",
"__run_tool_progress_recorder", "__run_tool_progress_recorder",
"__run_input_control",
"__tool_approval_runtime",
"langgraph_auth_user", "langgraph_auth_user",
"langgraph_auth_user_id", "langgraph_auth_user_id",
THREAD_INCARNATION_METADATA_GUARD_KEY, THREAD_INCARNATION_METADATA_GUARD_KEY,
@ -1152,7 +925,6 @@ _SERVER_OWNED_RUNTIME_CONTEXT_KEYS: frozenset[str] = (
# at admission from threads_meta; a client-supplied value must # at admission from threads_meta; a client-supplied value must
# never survive in either run-config section. # never survive in either run-config section.
PROJECT_CONTEXT_KEY, PROJECT_CONTEXT_KEY,
AGENT_AUTH_CONTEXT_KEY,
KNOWLEDGE_SCOPE_KEY, KNOWLEDGE_SCOPE_KEY,
KNOWLEDGE_SCOPE_RUNTIME_KEY, KNOWLEDGE_SCOPE_RUNTIME_KEY,
} }
@ -1451,10 +1223,9 @@ _DEFAULT_RECURSION_LIMIT = 100
_DEFAULT_MAX_RECURSION_LIMIT = 1000 _DEFAULT_MAX_RECURSION_LIMIT = 1000
def _resolve_gateway_recursion_limits(app_config: AppConfig | None = None) -> tuple[int, int]: def _resolve_gateway_recursion_limits() -> tuple[int, int]:
"""Resolve the run default and ceiling from one hot-reloaded snapshot.""" """Resolve the run default and ceiling from one hot-reloaded snapshot."""
try: try:
if app_config is None:
app_config = get_app_config() app_config = get_app_config()
raw = app_config.recursion_limit raw = app_config.recursion_limit
max_limit = app_config.max_recursion_limit max_limit = app_config.max_recursion_limit
@ -1537,17 +1308,11 @@ def _clamp_recursion_limit(value: Any, max_limit: int, default_limit: int) -> in
return min(value, max_limit) return min(value, max_limit)
# Error details echo caller-supplied agent names; bound them so an oversized def custom_agent_name(assistant_id: str | None) -> str | None:
# value cannot bloat the response or any log line that records the detail.
_MAX_ECHOED_AGENT_NAME_CHARS = 200
def custom_agent_name(assistant_id: str | None, *, field: str = "assistant_id") -> str | None:
"""Return the custom agent *assistant_id* selects, or ``None`` for the lead agent. """Return the custom agent *assistant_id* selects, or ``None`` for the lead agent.
Custom names are normalized (trimmed, lowercased, ``_`` to ``-``) and must Custom names are normalized (trimmed, lowercased, ``_`` to ``-``) and must
then contain only letters, digits and hyphens. *field* names the request then contain only letters, digits and hyphens.
field in the error message.
Raises: Raises:
ValueError: *assistant_id* cannot name an agent. ValueError: *assistant_id* cannot name an agent.
@ -1556,7 +1321,7 @@ def custom_agent_name(assistant_id: str | None, *, field: str = "assistant_id")
return None return None
normalized = assistant_id.strip().lower().replace("_", "-") normalized = assistant_id.strip().lower().replace("_", "-")
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized): if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
raise ValueError(f"Invalid {field} {assistant_id!r:.{_MAX_ECHOED_AGENT_NAME_CHARS}}: must contain only letters, digits, and hyphens after normalization.") raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.")
return normalized return normalized
@ -1566,7 +1331,6 @@ def build_run_config(
metadata: dict[str, Any] | None, metadata: dict[str, Any] | None,
*, *,
assistant_id: str | None = None, assistant_id: str | None = None,
app_config: AppConfig | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build a RunnableConfig dict for the agent. """Build a RunnableConfig dict for the agent.
@ -1584,16 +1348,13 @@ def build_run_config(
This mirrors the channel manager's ``_resolve_run_params`` logic. IM This mirrors the channel manager's ``_resolve_run_params`` logic. IM
channel runs send no ``recursion_limit`` unless their session config or channel runs send no ``recursion_limit`` unless their session config or
channel policy sets one, so they get the same configured default here. channel policy sets one, so they get the same configured default here.
*app_config* is the snapshot the recursion limits come from; without it
they are read through ``get_app_config()``, which does file IO.
""" """
# Lead-agent recursion budget (LangGraph super-steps for the lead graph # Lead-agent recursion budget (LangGraph super-steps for the lead graph
# only). Independent of subagent depth: a `task()` dispatch runs the whole # only). Independent of subagent depth: a `task()` dispatch runs the whole
# subagent inside ONE lead tools-node step, and subagents enforce their own # subagent inside ONE lead tools-node step, and subagents enforce their own
# limit via `subagents.max_turns`. Do not conflate this budget with the # limit via `subagents.max_turns`. Do not conflate this budget with the
# general-purpose subagent's max_turns. # general-purpose subagent's max_turns.
default_recursion_limit, max_recursion_limit = _resolve_gateway_recursion_limits(app_config) default_recursion_limit, max_recursion_limit = _resolve_gateway_recursion_limits()
config: dict[str, Any] = {"recursion_limit": default_recursion_limit} config: dict[str, Any] = {"recursion_limit": default_recursion_limit}
if request_config: if request_config:
caller_configurable = request_config.get("configurable") caller_configurable = request_config.get("configurable")
@ -2145,30 +1906,24 @@ async def ensure_checkpoint_history_seeded(
if await event_store.list_messages(thread_id, limit=1, user_id=None): if await event_store.list_messages(thread_id, limit=1, user_id=None):
return return
checkpoint_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} checkpoint_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
if await get_checkpointer(request).aget_tuple(checkpoint_config) is None: if await get_checkpointer(request).aget_tuple(checkpoint_config) is None:
return return
async def checkpoint_messages() -> list: accessor, config = await abuild_checkpoint_state_accessor(
accessor, config = await abuild_checkpoint_state_accessor(request, thread_id=thread_id, assistant_id=assistant_id) request,
thread_id=thread_id,
assistant_id=assistant_id,
)
snapshot = await accessor.aget(config) snapshot = await accessor.aget(config)
values = getattr(snapshot, "values", None) values = getattr(snapshot, "values", None)
messages = values.get("messages") if isinstance(values, dict) else None messages = values.get("messages") if isinstance(values, dict) else None
return messages if isinstance(messages, list) else [] if not isinstance(messages, list) or not messages:
# Eligibility is advisory; an empty head needs no migration reservation.
# Discard this preview and reread under admission before any publication.
if not await checkpoint_messages():
return
async with reserve_history_seed(request, thread_id) as reservation:
if reservation is not None and await event_store.list_messages(thread_id, limit=1, user_id=None):
return
if await get_checkpointer(request).aget_tuple(checkpoint_config) is None:
return
messages = await checkpoint_messages()
if not messages:
return return
events = build_checkpoint_history_seed_events( events = build_checkpoint_history_seed_events(
@ -2178,7 +1933,7 @@ async def ensure_checkpoint_history_seeded(
) )
if not events: if not events:
return return
await write_history_seed(request, events, reservation=reservation, seed_kind="checkpoint", assistant_id=assistant_id) await event_store.put_batch(events)
logger.info("Seeded %d checkpoint-history events for thread %s", len(events), thread_id) logger.info("Seeded %d checkpoint-history events for thread %s", len(events), thread_id)
@ -2293,30 +2048,20 @@ async def _load_scope_agent_config(
) -> Any | None: ) -> Any | None:
if not assistant_id or assistant_id == _DEFAULT_ASSISTANT_ID: if not assistant_id or assistant_id == _DEFAULT_ASSISTANT_ID:
return None return None
# The caller's config/context ``agent_name`` arrives here unvalidated, and try:
# every run that names a custom agent passes through, with or without a # The caller's config/context ``agent_name`` arrives here unvalidated.
# knowledge scope, so each rejection says what is wrong with the agent.
if not isinstance(assistant_id, str): if not isinstance(assistant_id, str):
raise HTTPException( raise ValueError("agent_name must be a string")
status_code=422, normalized = assistant_id.strip().lower().replace("_", "-")
detail=f"Invalid agent_name: expected a string, got {type(assistant_id).__name__}.",
)
try:
normalized = custom_agent_name(assistant_id, field="agent_name")
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
try:
return await asyncio.to_thread( return await asyncio.to_thread(
load_agent_config, load_agent_config,
normalized, normalized,
user_id=user_id, user_id=user_id,
) )
except FileNotFoundError as exc: except (FileNotFoundError, ValueError) as exc:
raise HTTPException(status_code=422, detail=f"Agent '{normalized:.{_MAX_ECHOED_AGENT_NAME_CHARS}}' not found") from exc
except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=f"Agent '{normalized:.{_MAX_ECHOED_AGENT_NAME_CHARS}}' configuration could not be loaded", detail="knowledge_scope assistant configuration could not be resolved",
) from exc ) from exc
@ -2436,15 +2181,10 @@ async def start_run(
stream_modes = normalize_stream_modes(body.stream_mode) stream_modes = normalize_stream_modes(body.stream_mode)
bridge = get_stream_bridge(request) bridge = get_stream_bridge(request)
run_mgr = get_run_manager(request) run_mgr = get_run_manager(request)
# Building the context loads the live AppConfig, which stats and hashes run_ctx = get_run_context(request)
# config.yaml and reparses it after an edit, so it runs in a worker. The
# rest of the run start reuses that snapshot.
run_ctx = await asyncio.to_thread(get_run_context, request)
app_config = run_ctx.app_config
admission_run_id: str | None = None admission_run_id: str | None = None
producer_lease_started = False producer_lease_started = False
producer_lease_handed_off = False producer_lease_handed_off = False
validated_approval_request_ids: dict[str, str] | None = None
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_ disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
@ -2456,6 +2196,7 @@ async def start_run(
# Validate model against the allowlist when a model_name is provided. # Validate model against the allowlist when a model_name is provided.
if model_name: if model_name:
app_config = get_app_config()
resolved = app_config.get_model_config(model_name) resolved = app_config.get_model_config(model_name)
if resolved is None: if resolved is None:
raise HTTPException( raise HTTPException(
@ -2516,43 +2257,10 @@ async def start_run(
if admitted_origin not in EVIDENCE_ORIGINS: if admitted_origin not in EVIDENCE_ORIGINS:
raise ValueError("Unsupported host evidence origin") raise ValueError("Unsupported host evidence origin")
command = getattr(body, "command", None) command = getattr(body, "command", None)
from app.gateway.hitl_approval import reject_new_run_when_approval_pending, validate_resume_request
current_user = get_current_user()
run_user_id = str(current_user.id) if current_user is not None else None
admitted_run = (
await _find_admitted_run_for_idempotency_key(
run_mgr,
thread_id,
idempotency_key,
user_id=run_user_id,
)
if idempotency_key is not None
else None
)
if command and command.get("resume") is not None: if command and command.get("resume") is not None:
graph_input = Command(resume=command["resume"]) graph_input = Command(resume=command["resume"])
stored_idempotency_request = (admitted_run.idempotency_request or _legacy_stored_idempotency_request(admitted_run.kwargs or {})) if admitted_run is not None else None
retry_failed_resume = _is_retryable_resume_start_failure(admitted_run) and stored_idempotency_request == idempotency_request
if admitted_run is None or retry_failed_resume:
accessor, read_config = await build_thread_checkpoint_state_accessor(
request,
thread_id=thread_id,
)
validated_approval_request_ids = await validate_resume_request(
request=request,
thread_id=thread_id,
accessor=accessor,
read_config=read_config,
enabled=app_config.hitl_approval.enabled,
body=body,
)
resume_map = getattr(validated_approval_request_ids, "resume_map", None)
if isinstance(resume_map, dict):
graph_input = Command(resume=resume_map)
else: else:
if admitted_run is None: # LangGraph continues pending work with None; {} starts new input.
await reject_new_run_when_approval_pending(request, thread_id)
graph_input = normalized_input if body.input is not None else None graph_input = normalized_input if body.input is not None else None
# Keep the idempotency identity in the caller's pre-stamp shape. A # Keep the idempotency identity in the caller's pre-stamp shape. A
# keyed retry can then reuse a run even though its stored run input has # keyed retry can then reuse a run even though its stored run input has
@ -2582,7 +2290,7 @@ async def start_run(
run_metadata[DEERFLOW_TRACE_METADATA_KEY] = ensure_trace_id() run_metadata[DEERFLOW_TRACE_METADATA_KEY] = ensure_trace_id()
try: try:
config = build_run_config(thread_id, body.config, run_metadata, assistant_id=body.assistant_id, app_config=app_config) config = build_run_config(thread_id, body.config, run_metadata, assistant_id=body.assistant_id)
except ValueError as exc: except ValueError as exc:
# Raised only for the caller's own config sections or assistant_id. # Raised only for the caller's own config sections or assistant_id.
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
@ -2598,12 +2306,6 @@ async def start_run(
# that carries agent configuration (model_name, thinking_enabled, etc.). # that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored. # Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
merge_run_context_overrides(config, getattr(body, "context", None), internal=is_internal_caller) merge_run_context_overrides(config, getattr(body, "context", None), internal=is_internal_caller)
from deerflow.approvals.policy import resolve_mode
try:
resolve_mode(app_config.hitl_approval, config.get("context") or {})
except (ValueError, TypeError) as exc:
raise HTTPException(422, detail={"code": "invalid_approval_mode", "message": "The selected tool approval mode is not allowed"}) from exc
if not is_internal_caller: if not is_internal_caller:
# ``body.config`` is free-form and copied verbatim by # ``body.config`` is free-form and copied verbatim by
# ``build_run_config``; scrub internal-only keys smuggled there. # ``build_run_config``; scrub internal-only keys smuggled there.
@ -2697,20 +2399,6 @@ async def start_run(
request_context=getattr(body, "context", None), request_context=getattr(body, "context", None),
) )
if agent_config is not None and getattr(run_ctx.app_config, "mcp_agent_auth", None):
from deerflow.mcp.agent_auth import McpAgentAuthorizationError, capture_agent_authority
try:
agent_authority = await run_assembly(
capture_agent_authority,
run_ctx.app_config,
user_id=owner_user_id or str(user.id),
agent_config=agent_config,
)
except McpAgentAuthorizationError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from None
run_ctx = replace(run_ctx, agent_mcp_authority=agent_authority)
if scheduled_task_runtime is not None: if scheduled_task_runtime is not None:
run_ctx = replace(run_ctx, scheduled_task_runtime=dict(scheduled_task_runtime)) run_ctx = replace(run_ctx, scheduled_task_runtime=dict(scheduled_task_runtime))
conversation_references = list(getattr(body, "conversation_references", None) or []) conversation_references = list(getattr(body, "conversation_references", None) or [])
@ -2723,7 +2411,7 @@ async def start_run(
user_id=owner_user_id or (str(user.id) if user is not None else None), user_id=owner_user_id or (str(user.id) if user is not None else None),
run_context=run_ctx, run_context=run_ctx,
run_manager=run_mgr, run_manager=run_mgr,
app_config=app_config, app_config=get_app_config(),
) )
reader, source_ids = prepared reader, source_ids = prepared
run_ctx = replace(run_ctx, conversation_reader=reader) run_ctx = replace(run_ctx, conversation_reader=reader)
@ -2911,28 +2599,6 @@ async def start_run(
except Exception: except Exception:
logger.warning("Scheduler tool capability is unavailable for this run", exc_info=True) logger.warning("Scheduler tool capability is unavailable for this run", exc_info=True)
execution_context = replace(run_ctx, scheduler_capability=scheduler_capability) if scheduler_capability is not None else run_ctx execution_context = replace(run_ctx, scheduler_capability=scheduler_capability) if scheduler_capability is not None else run_ctx
artifact_imports = getattr(request.state, "extension_artifact_imports", None)
artifact_lifetime = None
if artifact_imports:
from contextlib import AsyncExitStack
from deerflow_extension_api import AgentRunError
from app.gateway.extension_artifacts import verify_imports
artifact_lifetime = AsyncExitStack()
async def verify_artifacts():
# RunContext invokes this after durable admission and before
# Agent construction. A reused key never launches a worker.
try:
await await_drained(verify_imports(request, record.user_id, thread_id, artifact_imports, lifetime=artifact_lifetime))
except AgentRunError:
record.stop_reason = "artifact_conflict"
raise
execution_context = replace(execution_context, verify_artifacts=verify_artifacts)
try:
await run_agent( await run_agent(
bridge, bridge,
run_mgr, run_mgr,
@ -2948,14 +2614,6 @@ async def start_run(
knowledge_scope=admitted_knowledge_scope, knowledge_scope=admitted_knowledge_scope,
**incarnation_kwargs, **incarnation_kwargs,
) )
finally:
if artifact_lifetime is not None:
await await_drained(artifact_lifetime.aclose())
if execution_context.agent_runs is not None and run_mgr.input_store is not None:
from app.gateway.run_inputs import dispatch_queued_inputs
from deerflow.runtime.runs.inputs import RunInputs
await dispatch_queued_inputs(RunInputs(run_mgr.input_store, record.run_id, record.user_id), agent_runs=execution_context.agent_runs, thread_id=thread_id)
try: try:
async with goal_thread_lock(thread_id): async with goal_thread_lock(thread_id):
@ -2993,15 +2651,8 @@ async def start_run(
"Stream producer tracking unavailable for run %s; admitting it with untracked (conservative) stream recovery", "Stream producer tracking unavailable for run %s; admitting it with untracked (conservative) stream recovery",
sanitize_log_param(admission_run_id), sanitize_log_param(admission_run_id),
) )
async def admit_run() -> RunRecord:
try: try:
if admitted_run is None and not (command and command.get("resume") is not None): record = await run_mgr.create_or_reject(
# Validation above is intentionally early, but a pause can
# appear while this request prepares its admission. Recheck
# under the thread lock so a fresh input cannot replace it.
await reject_new_run_when_approval_pending(request, thread_id)
return await run_mgr.create_or_reject(
thread_id, thread_id,
body.assistant_id, body.assistant_id,
on_disconnect=disconnect, on_disconnect=disconnect,
@ -3036,8 +2687,6 @@ async def start_run(
# a different request for the run already admitted. # a different request for the run already admitted.
raise HTTPException(status_code=409, detail="Idempotency-Key already used with a different request") from exc raise HTTPException(status_code=409, detail="Idempotency-Key already used with a different request") from exc
record = await admit_run()
if record.idempotency_reused: if record.idempotency_reused:
stored = record.kwargs or {} stored = record.kwargs or {}
stored_input = stored.get("input") stored_input = stored.get("input")
@ -3064,48 +2713,13 @@ async def start_run(
status_code=409, status_code=409,
detail="Idempotency-Key already used with a different request", detail="Idempotency-Key already used with a different request",
) )
if request_identity["kind"] == "resume" and _is_retryable_resume_start_failure(record):
# The original request never attached a worker. Preserve
# idempotency for real execution outcomes, but let this
# identical retry replace only that failed admission.
await _discard_failed_resume_admission(request, run_mgr, record)
record = await admit_run()
if record.idempotency_reused:
# A concurrent identical retry won the replacement.
return record return record
else:
return record
worker = run_after_metadata(record)
answered_approval = None
try:
if command and command.get("resume") is not None:
from app.gateway.hitl_approval import _all_reject, _responses_of, current_approval_request_ids, mark_approval_answered
responses = _responses_of(command) worker = run_after_metadata(record)
answered_approval = await mark_approval_answered( try:
request,
thread_id,
expected_request_ids=(validated_approval_request_ids if responses is not None else None),
)
if answered_approval is None and responses is not None:
if not _all_reject(responses):
# Competing or stale approvals fail closed.
raise HTTPException(status_code=409, detail={"code": "no_pending_approval", "message": "No resumable pause exists for this thread"})
# A crashed attempt may have consumed the run row while
# leaving the same checkpoint pause live. Only that exact
# pause may use checkpoint-only recovery; a stale reject
# must never dismiss a newer approval.
current_request_ids = await current_approval_request_ids(
request,
thread_id,
accessor=accessor,
read_config=read_config,
)
if current_request_ids != validated_approval_request_ids:
raise HTTPException(status_code=409, detail={"code": "no_pending_approval", "message": "No resumable pause exists for this thread"})
# The stream lease was established before durable admission. # The stream lease was established before durable admission.
# Once resume persistence succeeds, keep task attachment # Keep the admission-to-task-attachment window await-free so
# await-free so cancellation cannot strand a pending row. # cancellation cannot strand a pending row.
record.task = asyncio.create_task(worker) record.task = asyncio.create_task(worker)
if producer_lease_started: if producer_lease_started:
transfer_producer_lease = _declared_bridge_method( transfer_producer_lease = _declared_bridge_method(
@ -3118,28 +2732,12 @@ async def start_run(
record.task, record.task,
) )
producer_lease_handed_off = True producer_lease_handed_off = True
except BaseException as exc: except Exception as exc:
attachment_error = f"Failed to attach run worker: {exc}"
task = record.task
if task is None:
worker.close() worker.close()
async def cleanup_failed_attachment() -> None:
if task is not None:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
if record.task is task:
record.task = None
await run_mgr.fail_start_if_pending( await run_mgr.fail_start_if_pending(
record.run_id, record.run_id,
error=attachment_error, error=f"Failed to attach run worker: {exc}",
) )
if answered_approval is not None:
restored = await run_mgr.restore_approval_answer(answered_approval)
if not restored:
raise RuntimeError(f"Failed to restore approval run {answered_approval.run_id} after worker attachment failed")
await asyncio.shield(cleanup_failed_attachment())
raise raise
except ConflictError as exc: except ConflictError as exc:
raise BusyThreadConflict(str(exc)) from exc raise BusyThreadConflict(str(exc)) from exc
@ -3216,7 +2814,7 @@ async def launch_scheduled_thread_run(
input=graph_input, input=graph_input,
command=None, command=None,
metadata=metadata or {}, metadata=metadata or {},
config={"recursion_limit": await asyncio.to_thread(_resolve_scheduler_recursion_limit)}, config={"recursion_limit": _resolve_scheduler_recursion_limit()},
# ``user_id`` mirrors what IM channels put in ``body.context`` so # ``user_id`` mirrors what IM channels put in ``body.context`` so
# runtime-context consumers without a ContextVar fallback (e.g. # runtime-context consumers without a ContextVar fallback (e.g.
# user-scoped GuardrailMiddleware providers) see the owning user; # user-scoped GuardrailMiddleware providers) see the owning user;
@ -3251,7 +2849,6 @@ async def launch_scheduled_thread_run(
# scheduler service's own per-occurrence scope -- ensure_trace_context # scheduler service's own per-occurrence scope -- ensure_trace_context
# keeps that trace instead of minting a competing one. # keeps that trace instead of minting a competing one.
with ensure_trace_context(): with ensure_trace_context():
try:
record = await start_run( record = await start_run(
body, body,
thread_id, thread_id,
@ -3260,14 +2857,6 @@ async def launch_scheduled_thread_run(
evidence_origin="scheduled", evidence_origin="scheduled",
scheduled_task_runtime=scheduled_task_runtime, scheduled_task_runtime=scheduled_task_runtime,
) )
except HTTPException as exc:
if isinstance(exc.detail, dict) and exc.detail.get("code") == "approval_pending":
# Skip-and-retry: an approval pause on the thread is temporary,
# not a permanent launch failure. The scheduler treats this as
# an overlap conflict and retries on its normal cadence.
logger.info("Scheduled launch skipped: thread %s has a pending approval", thread_id)
raise ConflictError(str(exc.detail)) from exc
raise
return {"run_id": record.run_id, "thread_id": record.thread_id} return {"run_id": record.run_id, "thread_id": record.thread_id}
@ -3352,9 +2941,6 @@ async def launch_mcp_task_notification_run(
require_existing_thread=True, require_existing_thread=True,
) )
except HTTPException as exc: except HTTPException as exc:
if isinstance(exc.detail, dict) and exc.detail.get("code") == "approval_pending":
# Temporary condition, not a permanent notification failure.
raise ConflictError(str(exc.detail)) from exc
if isinstance(exc, BusyThreadConflict): if isinstance(exc, BusyThreadConflict):
raise ConflictError(str(exc.detail)) from exc raise ConflictError(str(exc.detail)) from exc
if exc.status_code in {400, 401, 403, 404, 409, 422, 501}: if exc.status_code in {400, 401, 403, 404, 409, 422, 501}:

View File

@ -1,135 +0,0 @@
"""Import a share into a fresh owner-scoped default-agent conversation."""
import os
import uuid
from contextlib import asynccontextmanager
from fastapi import HTTPException, Request
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.types import Overwrite
from app.gateway.deps import get_checkpointer, get_run_event_store, get_run_manager, get_thread_store
from app.gateway.extension_artifacts import snapshot_sandbox
from app.gateway.history_seed import reserve_history_seed, write_history_seed
from app.gateway.persistent_writes import run_drained_write
from app.gateway.services import build_checkpoint_state_mutation_accessor
from app.gateway.share_snapshot import MAX_SHARE_FILE_BYTES, OUTPUT_PREFIX, artifact_path
from deerflow.config.paths import get_paths
from deerflow.persistence.thread_shares.sql import ThreadShareRepository
from deerflow.runtime import ThreadOperationKind
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.goal import goal_thread_lock
from deerflow.runtime.journal import build_branch_history_seed_events
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.artifact_snapshot import publish_snapshot
from deerflow.utils.file_io import run_file_io
def _write_fork_files(owner: str, thread_id: str, artifacts: list[dict]) -> None:
"""A fresh UUID directory, descriptor-confined descendants and exclusive files."""
root = get_paths().sandbox_outputs_dir(thread_id, user_id=owner)
root.mkdir(mode=0o700, parents=True, exist_ok=False)
directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
root_fd = os.open(root, directory_flags)
try:
for artifact in artifacts:
parts = artifact_path(artifact["path"]).split("/")
directory = os.dup(root_fd)
try:
for part in parts[:-1]:
try:
os.mkdir(part, 0o700, dir_fd=directory)
except FileExistsError:
pass
following = os.open(part, directory_flags, dir_fd=directory)
os.close(directory)
directory = following
os.fchmod(directory, 0o777)
fd = os.open(parts[-1], os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
with os.fdopen(fd, "wb") as target:
target.write(artifact["content"])
target.flush()
os.fchmod(target.fileno(), 0o666)
os.fsync(target.fileno())
finally:
os.close(directory)
# The bind mount is writable by the sandbox UID even when Gateway runs
# under a different UID or a restrictive umask.
os.fchmod(root_fd, 0o777)
finally:
os.close(root_fd)
@asynccontextmanager
async def _reserve_import(request: Request, thread_id: str, owner: str):
if isinstance(get_run_event_store(request), DbRunEventStore):
async with reserve_history_seed(request, thread_id) as reservation:
yield reservation
else:
async with get_run_manager(request).reserve_thread_operation(thread_id, kind=ThreadOperationKind.checkpoint_write, user_id=owner):
yield None
async def fork_share(request: Request, repo: ThreadShareRepository, token: str, request_id: str) -> dict:
share = await repo.get_public(token)
if share is None:
raise HTTPException(404, "Share unavailable")
owner = get_effective_user_id()
thread_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"deerflow-share:{owner}:{share['share_id']}:{request_id}"))
threads = get_thread_store(request)
async with goal_thread_lock(thread_id):
existing = await threads.get(thread_id, user_id=owner)
if existing is not None:
if existing.get("metadata", {}).get("share_import_complete") is True:
return {"thread_id": thread_id}
raise HTTPException(409, "The previous import did not finish; create a new copy")
artifacts = []
for descriptor in share["artifacts"]:
artifact = await repo.get_artifact(token, descriptor["path"])
if artifact is None:
raise HTTPException(404, "Share unavailable")
artifacts.append(artifact)
# Tool results become ordinary historical assistant text. The new thread
# inherits no tool execution, system prompt, Agent, project or auth grants.
messages = []
for item in share["snapshot"]["messages"]:
content = item["content"]
if item["type"] == "tool":
content = f"{item['name']}:\n{content}"
cls = HumanMessage if item["type"] == "human" else AIMessage
messages.append(cls(content=content, id=str(uuid.uuid4())))
title = str(share["snapshot"]["title"])[:256]
# Recheck after loading all bytes, before creating any new workspace state.
if await repo.get_public(token) is None:
raise HTTPException(404, "Share unavailable")
async with _reserve_import(request, thread_id, owner) as reservation:
existing = await threads.get(thread_id, user_id=owner)
if existing is not None:
if existing.get("metadata", {}).get("share_import_complete") is True:
return {"thread_id": thread_id}
raise HTTPException(409, "The previous import did not finish; create a new copy")
created = False
try:
record = await threads.create(thread_id, user_id=owner, display_name=title)
created = True
if artifacts:
await run_drained_write("Import shared artifacts", _write_fork_files, (), owner, thread_id, artifacts)
async with snapshot_sandbox(request, owner, thread_id) as sandbox:
if sandbox is not None:
for artifact in artifacts:
await run_file_io(publish_snapshot, sandbox, artifact["path"], artifact["content"], area="outputs", max_bytes=MAX_SHARE_FILE_BYTES)
accessor, config = build_checkpoint_state_mutation_accessor(request, thread_id=thread_id, as_node="branch")
await accessor.aupdate(config, {"messages": Overwrite(messages), "title": title, "artifacts": Overwrite([OUTPUT_PREFIX + a["path"] for a in artifacts])}, as_node="branch")
events = build_branch_history_seed_events(messages, thread_id=thread_id, run_id_prefix=f"branch-seed-{thread_id}", parent_thread_id=thread_id)
await write_history_seed(request, events, reservation=reservation, seed_kind="branch", thread=record)
await threads.update_metadata(thread_id, {"share_import_complete": True}, user_id=owner)
except Exception:
if created:
# Keep admission until rollback finishes, so a failed import
# cannot be continued while its files/checkpoints are removed.
await get_checkpointer(request).adelete_thread(thread_id)
await get_run_event_store(request).delete_by_thread(thread_id, user_id=owner)
await run_drained_write("Remove failed share import", get_paths().delete_thread_dir, (), thread_id, user_id=owner)
await threads.delete(thread_id, user_id=owner)
raise
return {"thread_id": thread_id}

View File

@ -1,119 +0,0 @@
"""Build a portable transcript under the caller's exclusive thread reservation."""
import json
import mimetypes
from pathlib import PurePosixPath
from fastapi import HTTPException, Request
from app.gateway.conversation_access import _visible_text
from app.gateway.conversation_reader import read_visible_message_page
from app.gateway.deps import get_run_event_store, get_run_manager, get_thread_store
from app.gateway.extension_artifacts import read_snapshot
from app.gateway.services import build_thread_checkpoint_state_accessor
from deerflow.runtime import serialize_channel_values_for_api
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.file_io import run_file_io
MAX_SHARE_MESSAGES = 10_000
MAX_SHARE_JSON_BYTES = 10 * 1024 * 1024
MAX_SHARE_ARTIFACTS = 64
MAX_SHARE_FILE_BYTES = 20 * 1024 * 1024
MAX_SHARE_TOTAL_BYTES = 100 * 1024 * 1024
OUTPUT_PREFIX = "/mnt/user-data/outputs/"
def artifact_path(value: str) -> str:
"""Only canonical relative output paths are ever persisted or imported."""
path = value.removeprefix(OUTPUT_PREFIX)
if not path or len(path) > 1024 or path.startswith("/") or "\\" in path or any(ord(c) < 32 for c in path) or any(p in {"", ".", ".."} for p in path.split("/")) or str(PurePosixPath(path)) != path:
raise HTTPException(422, "Invalid shared artifact path")
return path
def project_message(message: dict, index: int) -> dict | None:
"""Share visible text, not execution metadata, prompts or private attachments."""
extra = message.get("additional_kwargs") or {}
if not isinstance(extra, dict) or extra.get("hide_from_ui") or message.get("name") == "summary":
return None
visible = _visible_text({"content": message})
identity = str(message.get("id") or f"message-{index}")
if visible:
role, content = visible
return {"id": identity, "type": "human" if role == "user" else "ai", "content": content}
if message.get("type") == "tool":
content = message.get("content")
if not isinstance(content, str):
content = json.dumps(content, ensure_ascii=False)
return {"id": identity, "type": "tool", "name": str(message.get("name") or "tool"), "content": content}
return None
def project_transcript(messages: list[dict]) -> list[dict]:
projected: list[dict] = []
positions: dict[str, int] = {}
for index, message in enumerate(messages):
if not isinstance(message, dict):
continue
item = project_message(message, index)
if item is None:
continue
identity = item["id"].removesuffix("__user") if item["type"] == "human" else item["id"]
if identity in positions:
projected[positions[identity]] = item
else:
positions[identity] = len(projected)
projected.append(item)
return projected
async def capture_share(request: Request, thread_id: str, expected_checkpoint: str | None) -> dict:
owner = get_effective_user_id()
record = await get_thread_store(request).get(thread_id, user_id=owner)
if record is None:
raise HTTPException(404, "Thread not found")
accessor, config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id, fail_closed=True)
state = await accessor.aget(config)
checkpoint_id = (state.config or {}).get("configurable", {}).get("checkpoint_id")
if not checkpoint_id or state.next:
raise HTTPException(409, "Finish the current turn before sharing")
if expected_checkpoint is not None and checkpoint_id != expected_checkpoint:
raise HTTPException(409, "Thread changed; refresh before sharing")
rows: list[dict] = []
cursor = None
while True:
page, more = await read_visible_message_page(event_store=get_run_event_store(request), run_manager=get_run_manager(request), thread_id=thread_id, user_id=owner, limit=200, before_seq=cursor)
rows = page + rows
if len(rows) > MAX_SHARE_MESSAGES:
raise HTTPException(413, "Conversation exceeds the share limit")
if not more:
break
if not page or (cursor is not None and page[0]["seq"] >= cursor):
raise HTTPException(500, "Unable to capture complete conversation history")
cursor = page[0]["seq"]
values = serialize_channel_values_for_api(state.values)
source_messages = [row["content"] for row in rows] if rows else values.get("messages", [])
if len(source_messages) > MAX_SHARE_MESSAGES:
raise HTTPException(413, "Conversation exceeds the share limit")
messages = await run_file_io(project_transcript, source_messages)
if not messages:
raise HTTPException(409, "There are no visible messages to share")
snapshot = {"title": record.get("display_name") or values.get("title") or "Shared conversation", "messages": messages}
if len(json.dumps(snapshot, ensure_ascii=False).encode()) > MAX_SHARE_JSON_BYTES:
raise HTTPException(413, "Conversation exceeds the share limit")
paths = list(dict.fromkeys(values.get("artifacts") or []))
if len(paths) > MAX_SHARE_ARTIFACTS:
raise HTTPException(413, "Too many artifacts to share")
artifacts: list[dict] = []
total = 0
for path in paths:
if not isinstance(path, str) or not path.startswith(OUTPUT_PREFIX):
raise HTTPException(422, "Only output artifacts can be shared")
relative = artifact_path(path)
content = await read_snapshot(request, owner, thread_id, path, max_bytes=MAX_SHARE_FILE_BYTES)
total += len(content)
if total > MAX_SHARE_TOTAL_BYTES:
raise HTTPException(413, "Artifacts exceed the total share limit")
mime = await run_file_io(lambda: mimetypes.guess_type(relative)[0] or "application/octet-stream")
artifacts.append({"path": relative, "content": content, "mime_type": mime})
return {"thread_id": thread_id, "owner_id": owner, "incarnation": record.get("incarnation"), "checkpoint_id": checkpoint_id, "seq": max((row["seq"] for row in rows), default=0), "snapshot": snapshot, "artifacts": artifacts}

View File

@ -1,105 +0,0 @@
"""Owner-scoped HTTP decisions for live delegated calls and thread grants."""
import time
from fastapi import HTTPException, Request
from jsonschema import Draft202012Validator
from pydantic import BaseModel, ConfigDict, Field
from app.gateway.deps import get_current_user, get_run_manager, get_thread_store
from deerflow.approvals.policy import canonical_json
from deerflow.approvals.store import ApprovalConflict
from deerflow.utils.file_io import await_drained
class ApprovalResponseBody(BaseModel):
model_config = ConfigDict(extra="forbid")
responses: list[dict] = Field(min_length=1, max_length=32)
async def _scope(request, thread_id):
owner = await get_current_user(request)
if not owner:
raise HTTPException(401, "Authentication required")
thread = await get_thread_store(request).get(thread_id, user_id=owner)
if thread is None:
raise HTTPException(404, "Thread not found")
incarnation = thread.get("incarnation")
if not isinstance(incarnation, str):
raise HTTPException(409, "Thread approval identity is unavailable")
return (owner, thread_id, incarnation)
def _store(request):
store = getattr(request.app.state, "approval_store", None)
if store is None:
raise HTTPException(503, "Approval storage is unavailable")
return store
async def _active(request, row, owner):
if row["expires_at"] is not None and row["expires_at"] <= time.time():
return False
run = await get_run_manager(request).get(row["run_id"], user_id=owner)
return run is not None and run.thread_id == row["thread_id"] and run.status in ("running", "pending") and not run.ownership_lost and not run.abort_event.is_set()
async def list_approvals(thread_id, request):
scope = await _scope(request, thread_id)
rows = await _store(request).list(scope=scope, status="pending")
requests = [row["payload"] for row in rows if row["kind"] == "live" and await _active(request, row, scope[0])]
grants = await _store(request).list(scope=scope, grants=True)
return {"requests": requests, "grants": [{"request_id": row["request_id"], "operation": row["payload"]} for row in grants]}
async def answer_approval(thread_id, run_id, request_id, body, request: Request):
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL, AUTH_SOURCE_PAT
if getattr(request.state, "auth_source", None) in (AUTH_SOURCE_INTERNAL, AUTH_SOURCE_PAT):
raise HTTPException(403, "Tool approval requires an interactive user session")
scope = await _scope(request, thread_id)
store = _store(request)
row = await store.get(request_id, scope=scope)
if row is None or row["run_id"] != run_id or row["kind"] != "live":
raise HTTPException(404, "Approval not found")
if not await _active(request, row, scope[0]):
raise HTTPException(409, "The originating run stopped or the approval expired")
actions = row["payload"]["actions"]
if len(body.responses) != len(actions):
raise HTTPException(422, "An answer is required for each operation")
by_id = {action["request_id"]: action for action in actions}
seen = set()
for response in body.responses:
response_id = response.get("request_id")
if not isinstance(response_id, str):
raise HTTPException(422, "Invalid approval request identity")
if not isinstance(response.get("value", ""), str) or len(response.get("value", "")) > 2000:
raise HTTPException(422, "Approval reason must be text of at most 2000 characters")
action = by_id.get(response_id)
if action is None or action["request_id"] in seen or response.get("tool_call_id") != action["tool_call_id"]:
raise HTTPException(422, "Stale or mismatched approval response")
seen.add(action["request_id"])
decision = response.get("option_id")
if decision not in row["payload"]["allowed_decisions"]:
raise HTTPException(422, "Unsupported approval decision")
if decision == "edit":
args, schema = response.get("args"), action.get("args_schema")
try:
if not isinstance(args, dict) or len(canonical_json(args).encode()) > row["payload"].get("max_payload_bytes", 512 * 1024):
raise ValueError("Invalid arguments")
Draft202012Validator(schema).validate(args)
if isinstance(schema.get("properties"), dict) and set(args) - set(schema["properties"]):
raise ValueError("Unknown or injected arguments")
except Exception as exc:
raise HTTPException(422, "Edited arguments do not match the tool schema") from exc
try:
await await_drained(store.decide(request_id, {"responses": body.responses}, scope=scope, fingerprint=row["fingerprint"]))
except ApprovalConflict as exc:
raise HTTPException(409, str(exc)) from exc
return {"accepted": True, "request_id": request_id}
async def revoke_approval_grants(thread_id, request):
scope = await _scope(request, thread_id)
await await_drained(_store(request).revoke(scope=scope))
return {"revoked": True}

View File

@ -1689,60 +1689,11 @@ class McpTaskService:
_CANCELLATION_DRAIN_TIMEOUT_SECONDS, _CANCELLATION_DRAIN_TIMEOUT_SECONDS,
) )
def _log_pending_compensation_tasks(self, *, reason: str) -> None:
pending_count = sum(not task.done() for task in self._compensation_tasks)
if pending_count:
logger.warning(
"%s; %d MCP compensation task(s) remain in the background",
reason,
pending_count,
)
async def _drain_compensation_tasks(self, *, deadline: float) -> None:
"""Drain service-owned compensation work before Gateway dependencies close."""
loop = asyncio.get_running_loop()
while self._compensation_tasks:
tasks = tuple(self._compensation_tasks)
remaining = deadline - loop.time()
if remaining <= 0:
break
done, _ = await asyncio.wait(
tasks,
timeout=remaining,
return_when=asyncio.FIRST_COMPLETED,
)
if not done:
break
# Run completion callbacks before taking the next snapshot. A
# handoff may finish one task while registering another.
await asyncio.sleep(0)
self._log_pending_compensation_tasks(
reason="MCP compensation drain reached its deadline",
)
async def _drain_compensation_tasks_preserving_cancellation(self, *, deadline: float) -> None:
"""Finish the bounded drain before propagating caller cancellation."""
drain_task = asyncio.create_task(
self._drain_compensation_tasks(deadline=deadline),
name="deerflow-mcp-compensation-drain",
)
try:
await asyncio.shield(drain_task)
except asyncio.CancelledError:
await wait_for_task_until(drain_task, deadline=deadline)
if drain_task.done():
drain_task.result()
raise
async def stop(self) -> None: async def stop(self) -> None:
loop = asyncio.get_running_loop()
task = self._task task = self._task
if task is None: if task is None:
await self._drain_compensation_tasks_preserving_cancellation(
deadline=loop.time() + _CANCELLATION_DRAIN_TIMEOUT_SECONDS,
)
return return
loop = asyncio.get_running_loop()
if self._stopping_task is not task: if self._stopping_task is not task:
self._stopping_task = task self._stopping_task = task
self._stop_deadline = loop.time() + _CANCELLATION_DRAIN_TIMEOUT_SECONDS self._stop_deadline = loop.time() + _CANCELLATION_DRAIN_TIMEOUT_SECONDS
@ -1759,19 +1710,9 @@ class McpTaskService:
except asyncio.CancelledError: except asyncio.CancelledError:
if not await wait_for_task_until(task, deadline=deadline): if not await wait_for_task_until(task, deadline=deadline):
self._log_stop_timeout(task) self._log_stop_timeout(task)
self._log_pending_compensation_tasks(
reason="MCP task poller cleanup reached its deadline before compensation drain could start",
)
else:
await self._drain_compensation_tasks_preserving_cancellation(deadline=deadline)
raise raise
if task not in done: if task not in done:
self._log_stop_timeout(task) self._log_stop_timeout(task)
self._log_pending_compensation_tasks(
reason="MCP task poller cleanup reached its deadline before compensation drain could start",
)
return
await self._drain_compensation_tasks_preserving_cancellation(deadline=deadline)
async def _run_loop(self) -> None: async def _run_loop(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():

View File

@ -1,26 +0,0 @@
# Scheduler failure isolation
Scheduled work shares one polling cycle. Treat an individual occurrence as the
failure boundary so one damaged record cannot repeatedly prevent unrelated
owners' work from progressing. Apply this convention to maintenance passes as
well as launches: failing to clean up an older occurrence is not a reason to
withhold admission from a healthy task.
An exception during dispatch does not establish whether Gateway accepted the
run. Leave that uncertainty to durable queue and lease recovery. Eagerly
clearing a lease or starting a replacement from the exception handler can
duplicate work that already started. Cleanup is appropriate only when the
operation's outcome and ownership are known.
Containment preserves progress, not success. Keep failures observable and let
normal recovery retry the affected occurrence. Apply queue age before building
the fair drain batch: expired rows must not consume owner ranks or batch slots,
or a failed timeout sweep can starve healthy work indefinitely. Queue timeout is
enforced again when a waiting row is claimed for launch. If timeout cleanup
fails, the row remains queued for the next recovery pass but is no longer
eligible to start; cleanup failure must never turn expired work into a live run.
Failure to read the queue or claim due work still prevents that stage from
progressing; the next poll retries it.
For user-visible behavior and deployment requirements, see the
[scheduled tasks guide](../../../../docs/guide/scheduled-tasks.md).

View File

@ -29,7 +29,7 @@ from deerflow.persistence.user.model import UserPreferenceRow
from deerflow.runtime import ConflictError, RunRecord from deerflow.runtime import ConflictError, RunRecord
from deerflow.runtime.run_origin import origin_kind_of from deerflow.runtime.run_origin import origin_kind_of
from deerflow.scheduler.host_notes import RUN_ERROR_DELETED_WHILE_QUEUED, RUN_ERROR_INTERRUPTED, RUN_ERROR_LEASE_LOST, RUN_ERROR_PAUSED_WHILE_QUEUED, RUN_ERROR_QUEUE_TIMEOUT, RUN_ERROR_RESTARTED from deerflow.scheduler.host_notes import RUN_ERROR_DELETED_WHILE_QUEUED, RUN_ERROR_INTERRUPTED, RUN_ERROR_LEASE_LOST, RUN_ERROR_PAUSED_WHILE_QUEUED, RUN_ERROR_QUEUE_TIMEOUT, RUN_ERROR_RESTARTED
from deerflow.scheduler.schedules import pauses_unschedulable, should_advance_schedule_cursor, stored_next_run_at from deerflow.scheduler.schedules import next_run_at, should_advance_schedule_cursor
from deerflow.scheduler.stop_rule import launch_prompt from deerflow.scheduler.stop_rule import launch_prompt
from deerflow.trace_context import ensure_trace_context from deerflow.trace_context import ensure_trace_context
from deerflow.utils.thread_id import validate_thread_id from deerflow.utils.thread_id import validate_thread_id
@ -450,15 +450,10 @@ class ScheduledTaskService:
else: else:
await self._reconcile_active_state(now=now) await self._reconcile_active_state(now=now)
else: else:
try:
await self._task_run_repo.recover_expired_launch_claims( await self._task_run_repo.recover_expired_launch_claims(
error=_LEASE_RECOVERY_ERROR, error=_LEASE_RECOVERY_ERROR,
now=now, now=now,
) )
except Exception:
# Like the reconciliation below: a failed recovery pass must
# not keep every other due task from being admitted.
logger.exception("Failed to recover expired scheduled launch claims")
try: try:
finalized = await self._task_repo.reconcile_terminal_runs(now=now) finalized = await self._task_repo.reconcile_terminal_runs(now=now)
if finalized: if finalized:
@ -468,13 +463,7 @@ class ScheduledTaskService:
# failure (e.g. the candidate query) must not skip admission or # failure (e.g. the candidate query) must not skip admission or
# queue draining for this poll. # queue draining for this poll.
logger.exception("Failed to reconcile terminal scheduled runs") logger.exception("Failed to reconcile terminal scheduled runs")
try:
await self._expire_waiting_runs(now=now) await self._expire_waiting_runs(now=now)
except Exception:
# A failed timeout sweep must not skip queue draining or this
# poll's due-task admission. Individual rows are also isolated by
# expire_queued_runs; this guard covers the candidate query/session.
logger.exception("Failed to expire queued scheduled runs")
await self._drain_queue(now=now) await self._drain_queue(now=now)
# Admission and execution capacity are separate. Due occurrences are # Admission and execution capacity are separate. Due occurrences are
# persisted even when all execution slots are busy; claim_queued_run() # persisted even when all execution slots are busy; claim_queued_run()
@ -486,17 +475,7 @@ class ScheduledTaskService:
limit=self._max_concurrent_runs, limit=self._max_concurrent_runs,
) )
for task in claimed: for task in claimed:
try:
await self.dispatch_task(task, now=now, trigger="scheduled") await self.dispatch_task(task, now=now, trigger="scheduled")
except Exception:
# A per-record failure inside one task's dispatch must not abort
# the rest of this poll's claimed batch: every claimed task
# already holds a lease, and the next tick would skip them the
# same way. Claim-level store failures stay loud above the loop.
logger.exception(
"Scheduled task %s: dispatch failed; continuing with the claimed batch",
task.get("id"),
)
@staticmethod @staticmethod
def _is_overlap_conflict(exc: Exception) -> bool: def _is_overlap_conflict(exc: Exception) -> bool:
@ -551,39 +530,22 @@ class ScheduledTaskService:
*, *,
trigger: str, trigger: str,
now: datetime, now: datetime,
) -> tuple[datetime | str | None, str | None]: ) -> datetime | str | None:
"""Keep a recurring task's cadence when a manual trial launches. """Keep a recurring task's cadence when a manual trial launches.
A manual run is an extra trial and must not consume the task's next A manual run is an extra trial and must not consume the task's next
scheduled occurrence. One-time tasks retain their existing boundary scheduled occurrence. One-time tasks retain their existing boundary
semantics, so only recurring manual launches keep the stored value. semantics, so only recurring manual launches keep the stored value.
The second value is why a stored schedule no longer computes; the
launch bookkeeping then pauses the task instead of raising out of the
poll (see ``_unschedulable_outcome``).
""" """
if not should_advance_schedule_cursor(task["schedule_type"], trigger=trigger): if not should_advance_schedule_cursor(task["schedule_type"], trigger=trigger):
return task.get("next_run_at"), None return task.get("next_run_at")
return stored_next_run_at( return next_run_at(
task["schedule_type"], task["schedule_type"],
task["schedule_spec"], task["schedule_spec"],
task["timezone"], task["timezone"],
now=now, now=now,
) )
@staticmethod
def _unschedulable_outcome(
task: dict[str, Any],
*,
status: str,
last_error: str | None,
schedule_error: str | None,
) -> tuple[str, str | None]:
"""The status and ``last_error`` to write, pausing an unschedulable task."""
if not pauses_unschedulable(task["schedule_type"], status, schedule_error):
return status, last_error
logger.warning("Paused scheduled task %s: %s", task["id"], schedule_error)
return "paused", schedule_error
async def dispatch_task( async def dispatch_task(
self, self,
task: dict[str, Any], task: dict[str, Any],
@ -608,21 +570,15 @@ class ScheduledTaskService:
# surface as HTTP 500 on manual trigger and, in the poller, abort # surface as HTTP 500 on manual trigger and, in the poller, abort
# the rest of the claimed batch every cycle while the task itself # the rest of the claimed batch every cycle while the task itself
# is never marked with last_error. # is never marked with last_error.
next_at, schedule_error = self._next_run_at_after_launch(task, trigger=trigger, now=now) task_status = self._task_status_for_failure(task, trigger=trigger)
task_status, last_error = self._unschedulable_outcome(
task,
status=self._task_status_for_failure(task, trigger=trigger),
last_error=str(exc),
schedule_error=schedule_error,
)
await self._task_repo.update_after_launch( await self._task_repo.update_after_launch(
task["id"], task["id"],
status=task_status, status=task_status,
next_run_at=next_at, next_run_at=self._next_run_at_after_launch(task, trigger=trigger, now=now),
last_run_at=now, last_run_at=now,
last_run_id=None, last_run_id=None,
last_thread_id=execution_thread_id, last_thread_id=execution_thread_id,
last_error=last_error, last_error=str(exc),
increment_run_count=False, increment_run_count=False,
expected_lease_owner=expected_lease_owner, expected_lease_owner=expected_lease_owner,
) )
@ -710,7 +666,6 @@ class ScheduledTaskService:
queued: dict[str, Any], queued: dict[str, Any],
*, *,
now: datetime, now: datetime,
created_after: datetime | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Turn one queued occurrence into a live run under its own trace scope. """Turn one queued occurrence into a live run under its own trace scope.
@ -719,17 +674,9 @@ class ScheduledTaskService:
sharing one id across a whole poll cycle. A manual trigger arrives sharing one id across a whole poll cycle. A manual trigger arrives
inside a Gateway request and keeps that request's trace instead, so inside a Gateway request and keeps that request's trace instead, so
the launched run stays correlated with the call that asked for it. the launched run stays correlated with the call that asked for it.
``created_after`` applies the queue-age cutoff when retrying a row
from the waiting queue. A freshly admitted row has not waited.
""" """
with ensure_trace_context(): with ensure_trace_context():
return await self._launch_queued_occurrence( return await self._launch_queued_occurrence(task, queued, now=now)
task,
queued,
now=now,
created_after=created_after,
)
async def _launch_queued_occurrence( async def _launch_queued_occurrence(
self, self,
@ -737,7 +684,6 @@ class ScheduledTaskService:
queued: dict[str, Any], queued: dict[str, Any],
*, *,
now: datetime, now: datetime,
created_after: datetime | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
task_run_id = queued["id"] task_run_id = queued["id"]
execution_thread_id = queued["thread_id"] execution_thread_id = queued["thread_id"]
@ -749,7 +695,6 @@ class ScheduledTaskService:
lease_seconds=self._lease_seconds, lease_seconds=self._lease_seconds,
global_max_concurrent_runs=self._max_concurrent_runs, global_max_concurrent_runs=self._max_concurrent_runs,
per_user_max_concurrent_runs=self._max_concurrent_runs_per_user, per_user_max_concurrent_runs=self._max_concurrent_runs_per_user,
created_after=created_after,
) )
if claimed is None: if claimed is None:
return self._queued_result(task_run_id, execution_thread_id) return self._queued_result(task_run_id, execution_thread_id)
@ -791,13 +736,8 @@ class ScheduledTaskService:
launch_succeeded = True launch_succeeded = True
launched_run_id = result["run_id"] launched_run_id = result["run_id"]
launched_thread_id = result["thread_id"] launched_thread_id = result["thread_id"]
next_at, schedule_error = self._next_run_at_after_launch(task, trigger=trigger, now=now) next_at = self._next_run_at_after_launch(task, trigger=trigger, now=now)
task_status, last_error = self._unschedulable_outcome( task_status = self._task_status_for_launch(task, trigger=trigger, next_at=next_at)
task,
status=self._task_status_for_launch(task, trigger=trigger, next_at=next_at),
last_error=None,
schedule_error=schedule_error,
)
await self._record_launched_run( await self._record_launched_run(
task_run_id=task_run_id, task_run_id=task_run_id,
task_id=task["id"], task_id=task["id"],
@ -811,7 +751,7 @@ class ScheduledTaskService:
last_run_at=now, last_run_at=now,
last_run_id=launched_run_id, last_run_id=launched_run_id,
last_thread_id=launched_thread_id, last_thread_id=launched_thread_id,
last_error=last_error, last_error=None,
increment_run_count=True, increment_run_count=True,
task_run_id=task_run_id, task_run_id=task_run_id,
# Same race as the run-row write above: a fast-failing run's # Same race as the run-row write above: a fast-failing run's
@ -834,6 +774,8 @@ class ScheduledTaskService:
) )
return self._queued_result(task_run_id, execution_thread_id, error=str(exc)) return self._queued_result(task_run_id, execution_thread_id, error=str(exc))
next_at = self._next_run_at_after_launch(task, trigger=trigger, now=now)
if launch_succeeded: if launch_succeeded:
# _launch_run succeeded, so a run is live even though # _launch_run succeeded, so a run is live even though
# post-launch bookkeeping raised. Keep the task-run row # post-launch bookkeeping raised. Keep the task-run row
@ -844,13 +786,7 @@ class ScheduledTaskService:
# if the DB is still down the row stays "queued" -- still # if the DB is still down the row stays "queued" -- still
# active, still holding the slot -- so we log and still report # active, still holding the slot -- so we log and still report
# the run as launched so callers know a run is in flight. # the run as launched so callers know a run is in flight.
next_at, schedule_error = self._next_run_at_after_launch(task, trigger=trigger, now=now) task_status = self._task_status_for_launch(task, trigger=trigger, next_at=next_at)
task_status, last_error = self._unschedulable_outcome(
task,
status=self._task_status_for_launch(task, trigger=trigger, next_at=next_at),
last_error=None,
schedule_error=schedule_error,
)
try: try:
await self._record_launched_run( await self._record_launched_run(
task_run_id=task_run_id, task_run_id=task_run_id,
@ -880,7 +816,7 @@ class ScheduledTaskService:
# on a task whose run is actively running; the real # on a task whose run is actively running; the real
# terminal outcome is written by handle_run_completion. # terminal outcome is written by handle_run_completion.
# The transient itself is logged above. # The transient itself is logged above.
last_error=last_error, last_error=None,
increment_run_count=True, increment_run_count=True,
task_run_id=task_run_id, task_run_id=task_run_id,
protect_terminal=True, protect_terminal=True,
@ -1043,22 +979,8 @@ class ScheduledTaskService:
queued_rows = await self._task_run_repo.list_queued_runs( queued_rows = await self._task_run_repo.list_queued_runs(
limit=max(16, self._max_concurrent_runs * 4), limit=max(16, self._max_concurrent_runs * 4),
per_user_max_concurrent_runs=self._max_concurrent_runs_per_user, per_user_max_concurrent_runs=self._max_concurrent_runs_per_user,
created_after=now - timedelta(seconds=self._queue_timeout_seconds),
) )
for queued in queued_rows: for queued in queued_rows:
try:
await self._drain_queued_run(queued, now=now)
except Exception:
# A storage failure on one occurrence must not leave every
# later queued row and this poll's due tasks undispatched. The
# failed row keeps its queue/lease state for the next recovery
# pass, and queue_timeout still bounds how long it can wait.
logger.exception(
"Failed to drain scheduled occurrence %s; continuing with the remaining queue",
queued.get("id"),
)
async def _drain_queued_run(self, queued: dict[str, Any], *, now: datetime) -> None:
await self._task_repo.release_queued_admission_lease(queued["task_id"]) await self._task_repo.release_queued_admission_lease(queued["task_id"])
task = await self._task_repo.get_internal(queued["task_id"]) task = await self._task_repo.get_internal(queued["task_id"])
if task is None: if task is None:
@ -1068,7 +990,7 @@ class ScheduledTaskService:
error=RUN_ERROR_DELETED_WHILE_QUEUED, error=RUN_ERROR_DELETED_WHILE_QUEUED,
finished_at=now, finished_at=now,
) )
return continue
# Pausing suppresses automatic occurrences, but a manual trigger is # Pausing suppresses automatic occurrences, but a manual trigger is
# an explicit request and has always been allowed to run without # an explicit request and has always been allowed to run without
# resuming the schedule. A later pause still cancels an already # resuming the schedule. A later pause still cancels an already
@ -1080,15 +1002,8 @@ class ScheduledTaskService:
error=RUN_ERROR_PAUSED_WHILE_QUEUED, error=RUN_ERROR_PAUSED_WHILE_QUEUED,
finished_at=now, finished_at=now,
) )
return continue
# The timeout sweep may have failed for this row. Enforce the same await self._attempt_queued_run(task, queued, now=now)
# cutoff in the atomic claim so cleanup retries cannot start it.
await self._attempt_queued_run(
task,
queued,
now=now,
created_after=now - timedelta(seconds=self._queue_timeout_seconds),
)
async def _expire_waiting_runs(self, *, now: datetime) -> None: async def _expire_waiting_runs(self, *, now: datetime) -> None:
await self._task_run_repo.expire_queued_runs( await self._task_run_repo.expire_queued_runs(

View File

@ -1,144 +0,0 @@
"""Native batch follow-ups through the existing revocable AgentRuns host."""
import copy
import json
import logging
from deerflow_extension_api.agent_runs import AgentRunError, ExecutionFence
from deerflow.agents.human_input import extract_unanswered_human_input_request
from deerflow.mcp_scope import THREAD_INCARNATION_CONTEXT_KEY
from deerflow.runtime.goal import goal_waits_for_external_result, read_thread_goal
from deerflow.runtime.runs.schemas import GOAL_RUN_HARD_STOP_REASONS
logger = logging.getLogger(__name__)
_TRUSTED = object()
class BatchContinuations:
def __init__(self, app):
self.app = app
async def prepare(self, request):
from app.gateway.services import _CONTEXT_CONFIGURABLE_KEYS, _MODEL_SELECTION_CONTEXT_KEYS
host = self.app.state.agent_runs_host
grant = host.capture_batch_authority(request.continuation_authority, user_id=request.user_id)
source = await request.continuation_authority.get(thread_id=request.thread_id, run_id=request.run_id)
if source.status not in ("pending", "running"):
raise AgentRunError(409, "The originating Run has already stopped")
record = await self.app.state.run_manager.get(request.run_id, user_id=request.user_id)
if record is None or record.user_id != request.user_id or record.thread_id != request.thread_id:
raise AgentRunError(403, "Batch follow-up must belong to its originating Run")
captured = request.continuation_context or {}
if (record.metadata or {}).get("deerflow_origin", {}).get("kind") == "subagent_batch" and (captured.get("goal") or {}).get("status") != "active":
raise AgentRunError(409, "A batch follow-up cannot register another automatic follow-up")
context = {key: value for key, value in captured.get("context", {}).items() if key in _CONTEXT_CONFIGURABLE_KEYS | _MODEL_SELECTION_CONTEXT_KEYS and key not in ("agent_name", "is_bootstrap")}
if isinstance(captured.get("context", {}).get("locale"), str):
context["locale"] = captured["context"]["locale"]
scope = request.execution_spec.get("knowledge_scope")
return {
"prompt": request.continuation,
"grant": grant,
"thread_incarnation": request.execution_spec.get(THREAD_INCARNATION_CONTEXT_KEY),
"goal": copy.deepcopy(captured.get("goal")),
"context": copy.deepcopy(context),
"config": copy.deepcopy(captured.get("config", {})),
"knowledge_scope": copy.deepcopy(scope),
}
async def dispatch(self, batch):
continuation = batch["continuation"]
try:
runs = await self.app.state.agent_runs_host.restore_batch_authority(user_id=batch["user_id"], grant=continuation["grant"])
existing_id = await self.app.state.subagent_batch_repo.continuation_run_id(batch["id"], user_id=batch["user_id"])
if existing_id is not None:
existing = await runs.get(thread_id=batch["thread_id"], run_id=existing_id)
return {"status": "dispatched", "run_id": existing.run_id}
source = await runs.get(thread_id=batch["thread_id"], run_id=batch["run_id"])
if source.status in ("pending", "running"):
return None
if source.status != "success" or source.stop_reason in GOAL_RUN_HARD_STOP_REASONS:
return {"status": "suppressed"}
thread = await self.app.state.thread_store.get(batch["thread_id"], user_id=batch["user_id"])
if thread is None or thread.get("incarnation") != continuation.get("thread_incarnation"):
return {"status": "suppressed"}
state = await runs.get_state(thread_id=batch["thread_id"])
values = state.get("values", {})
if extract_unanswered_human_input_request(values.get("messages")) is not None:
return {"status": "waiting"}
goal = values.get("goal")
goal_identity = {key: goal.get(key) for key in ("created_at", "status", "objective")} if isinstance(goal, dict) else None
expected_goal = continuation.get("goal")
if goal_identity != expected_goal:
return {"status": "suppressed"}
goal_wakeup = None
if expected_goal is not None:
latest = await self.app.state.subagent_batch_repo.latest_run_state(batch["thread_id"], user_id=batch["user_id"])
if latest and latest["status"] in ("pending", "running"):
return None
if latest and (latest["status"] != "success" or latest.get("stop_reason") in GOAL_RUN_HARD_STOP_REASONS):
return {"status": "suppressed"}
if not goal_waits_for_external_result(goal, expected_goal):
return None if not (goal.get("last_evaluation") or {}).get("stand_down_reason") else {"status": "suppressed"}
goal_wakeup = expected_goal
public = await self.app.state.subagent_batch_repo.get_batch(batch["id"], user_id=batch["user_id"])
if public is None or public.get("continuation_status") == "cancelled" or public["status"] == "cancelled":
return {"status": "suppressed"}
if public["status"] not in ("completed", "failed"):
return None
# Stable input is essential after an unknown admission outcome.
# Counts/results stay in the existing owner-scoped result reader.
content = "The requested background batch has finished. Continue the agreed follow-up using its stored results; preserve any failures or missing information.\n" + json.dumps(
{"batch_id": batch["id"], "follow_up": continuation["prompt"]}, ensure_ascii=False
)
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_KEY
metadata = {"hide_from_ui": True, "untrusted_input": True}
if continuation.get("knowledge_scope") is not None:
metadata[KNOWLEDGE_SCOPE_KEY] = continuation["knowledge_scope"]
result = await runs.start_batch_followup(
thread_id=batch["thread_id"],
batch_id=batch["id"],
input={"messages": [{"role": "user", "content": content, "additional_kwargs": metadata}]},
context=continuation.get("context"),
config=continuation.get("config"),
execution_fence=ExecutionFence(**batch["execution_fence"]),
goal_wakeup=goal_wakeup,
)
return {"status": "dispatched", "run_id": result.run_id}
except AgentRunError as exc:
if exc.status_code == 409:
return None
if exc.status_code in (400, 401, 403, 404, 422):
return {"status": "suppressed"}
raise
async def cancel(self, batch_id, user_id, *, thread_id=None, authority=_TRUSTED, authorize_only=False):
if authority is not _TRUSTED:
self.app.state.agent_runs_host.capture_batch_authority(authority, user_id=user_id)
async with authority._request("runs:cancel", thread_id):
if authorize_only:
return
elif authorize_only:
return
run_id = await self.app.state.subagent_batch_repo.continuation_run_id(batch_id, user_id=user_id)
if run_id is None:
return
record = await self.app.state.run_manager.get(run_id, user_id=user_id)
if record is not None:
await self.app.state.run_manager.cancel(run_id)
async def source_finished(self, record):
if record.stop_reason == "goal_wakeup_superseded":
return
if record.status == "success" and record.stop_reason not in GOAL_RUN_HARD_STOP_REASONS:
return
for batch in await self.app.state.subagent_batch_repo.list_continuation_batches_for_run(record.run_id):
try:
await self.app.state.subagent_batch_service.cancel_batch(batch_id=batch["id"], user_id=batch["user_id"])
except Exception:
logger.warning("Batch %s cancellation failed for run %s (non-fatal)", batch["id"], record.run_id, exc_info=True)
goal = await read_thread_goal(self.app.state.checkpointer, record.thread_id)
if goal and goal.get("status") == "active" and goal.get("created_at"):
await self.app.state.subagent_batch_repo.suppress_thread_continuations(record.thread_id, goal_created_at=goal["created_at"])

View File

@ -188,11 +188,6 @@ only letters, digits and hyphens; any other value is rejected with `422`.
Branches preserve valid stored IDs, including their original spelling. If a Branches preserve valid stored IDs, including their original spelling. If a
legacy source has an invalid ID, its new branch stores `null` and uses the lead agent. legacy source has an invalid ID, its new branch stores `null` and uses the lead agent.
`metadata` is stored on the thread as sent and copied into its first
checkpoint, except for the keys the server owns there: `step`, `source`,
`writes`, `parents`, `created_at`, `counters_since_delta_snapshot` and
`deerflow_agent_name`.
**Response:** **Response:**
```json ```json
{ {
@ -356,7 +351,7 @@ does not accept this header and keeps the existing missing-stream close of
**Run Option Compatibility:** **Run Option Compatibility:**
- Supported concurrency strategies: `reject`, `rollback`, and `interrupt` - Supported concurrency strategies: `reject`, `rollback`, and `interrupt`
- Compatibility default: `if_not_exists="create"`; this matches DeerFlow's current behavior - Compatibility default: `if_not_exists="create"`; this matches DeerFlow's current behavior
- Artifact delivery is enforced automatically when a run creates or modifies regular files under `/mnt/user-data/outputs`. `present_files` or `present_shared_artifact` must present at least one path produced by the current run (or a directory containing it), and the terminal receipt must be persisted; presenting only an unrelated file does not satisfy delivery. Runs without changed outputs retain ordinary conversational behavior. `artifact_delivery` is not a client-settable run option. - Artifact delivery is enforced automatically when a run creates or modifies regular files under `/mnt/user-data/outputs`. `present_files` must present at least one path produced by the current run (or a directory containing it), and the terminal receipt must be persisted; presenting only an unrelated file does not satisfy delivery. Runs without changed outputs retain ordinary conversational behavior. `artifact_delivery` is not a client-settable run option.
- Unsupported options return `422`: `webhook`, `stream_resumable=true`, `after_seconds`, `feedback_keys`, any non-null `on_completion` value (including the SDK values `"complete"` and `"continue"`), `if_not_exists="reject"`, and `multitask_strategy="enqueue"` - Unsupported options return `422`: `webhook`, `stream_resumable=true`, `after_seconds`, `feedback_keys`, any non-null `on_completion` value (including the SDK values `"complete"` and `"continue"`), `if_not_exists="reject"`, and `multitask_strategy="enqueue"`
- `stream_resumable=false` is accepted: it is the LangGraph SDK's default and requests the non-resumable stream DeerFlow already serves - `stream_resumable=false` is accepted: it is the LangGraph SDK's default and requests the non-resumable stream DeerFlow already serves
- Undeclared SDK options, including `checkpoint_during` and `durability`, also return `422` instead of being silently discarded - Undeclared SDK options, including `checkpoint_during` and `durability`, also return `422` instead of being silently discarded
@ -510,15 +505,10 @@ Gateway API callers can attach up to three explicit references to create/stream/
A reference is a valid thread ID or an absolute `/workspace/chats/{thread_id}` URL A reference is a valid thread ID or an absolute `/workspace/chats/{thread_id}` URL
(also `/workspace/agents/{agent_name}/chats/{thread_id}` for custom agents) (also `/workspace/agents/{agent_name}/chats/{thread_id}` for custom agents)
on the host and port the client sent the run request to, without query or fragment. with the same scheme and authority as the run request, without query or fragment.
Gateway takes that host from `Forwarded` / `X-Forwarded-Host` / `Host`, as the CSRF URLs are parsed as local selectors and are never fetched. For split-origin clients
check does, and appends `X-Forwarded-Port` when that host carries no port of its own. or internal proxies, pass the thread ID. The field is separate from message text:
A stray `X-Forwarded-Port: 8443` therefore turns `deerflow.example` into links in pasted documents, tool results, or previous messages grant no access.
`deerflow.example:8443`, and a link without that port gets a 422. The scheme is
not compared, since behind a TLS proxy Gateway can see `http` for an `https` link. URLs are parsed as local selectors and are never fetched.
For split-origin clients or internal proxies, pass the thread ID. The field is
separate from message text: links in pasted documents, tool results, or previous
messages grant no access.
The server supplies source IDs to the model as background user-role data and The server supplies source IDs to the model as background user-role data and
binds the reader to this run's references and authenticated identity. binds the reader to this run's references and authenticated identity.
@ -1468,7 +1458,7 @@ DELETE /api/threads/{thread_id}/uploads/{filename}
### Thread Cleanup ### Thread Cleanup
Delete a thread's checkpoint data and DeerFlow-managed files and metadata. Remove DeerFlow-managed local thread files under `.deer-flow/threads/{thread_id}` after the LangGraph thread itself has been deleted.
```http ```http
DELETE /api/threads/{thread_id} DELETE /api/threads/{thread_id}
@ -1485,14 +1475,6 @@ DELETE /api/threads/{thread_id}
**Error behavior:** **Error behavior:**
- `422` for invalid thread IDs - `422` for invalid thread IDs
- `500` returns a generic `{"detail": "Failed to delete local thread data."}` response while full exception details stay in server logs - `500` returns a generic `{"detail": "Failed to delete local thread data."}` response while full exception details stay in server logs
- `500` with `{"detail": "Failed to delete thread checkpoints."}` when a configured
checkpointer cannot delete the thread's checkpoints. The owner row is retained
for a retry.
- `409` with `Thread has work in flight. Delete it after the work finishes.` when
thread work blocks deletion. After a checkpoint error, a retry can also return
this response until the durable delete reservation is released or reconciled.
In a single-worker deployment with ownership heartbeats disabled, a stranded
reservation is reconciled at Gateway startup.
Deleting a thread also removes every user's read marker for it. Deleting a thread also removes every user's read marker for it.
@ -2288,38 +2270,6 @@ curl -X POST http://localhost:2026/api/langgraph/threads/abc123/runs/stream \
> section for details. Scheduled-task launches use > section for details. Scheduled-task launches use
> `scheduler.recursion_limit` from `config.yaml` instead of a client body. > `scheduler.recursion_limit` from `config.yaml` instead of a client body.
## Workspace search
`POST /api/threads/search` additionally accepts an optional `query` (up to 256
characters). It matches a trimmed, case-insensitive literal substring of the
conversation title before pagination, preserving the existing array response.
`POST /api/search` requires `threads:read` and returns grouped title/message
matches for the current user. Message results additionally require `runs:read`
and an installed `workspace_search` extension provider.
```json
{"query":"报告","types":["thread","message"],"archived":null,"limit":20,"cursor":null}
```
`types` defaults to both kinds; `limit` is 1–100. `archived` is a nullable boolean;
`project_id` omitted includes every project, explicit null selects unassigned
threads. Blank queries return recent titles. The response is
`{items, cursor, indexing, messages_available}`. Each item carries `type`,
`thread_id`, `title`, `agent_name`, `archived`, `project_id`, `updated_at` and
`snippet` (plain-text `{text, matched?}` segments). Message hits also carry the
canonical `message_id`, thread-global `seq` and `role`. Preserve the query and
filters with each opaque cursor; invalid cursors return 400. When `indexing` is
true, retry the current page until historical backfill completes. Provider errors
return 503; without a provider, title search stays available.
`GET /api/threads/{thread_id}/messages/around?message_id=...&seq=...&limit=20`
requires `runs:read` and thread ownership. `seq` is an optional exact version hint;
`limit` (1–50) bounds each side of the target. The response contains `messages`,
`target_seq`, `has_before` and `has_after`. Hidden/superseded targets and stale
version hints return 404. Earlier/later navigation uses the boundary message as
the next anchor. The UI keeps these windows separate from live history.
## Chat archive and restore ## Chat archive and restore
`POST /api/threads/search` accepts `archived: true` for archived chats or `POST /api/threads/search` accepts `archived: true` for archived chats or

View File

@ -109,7 +109,7 @@ The web conversation delete flow first deletes Gateway-managed thread state thro
**Trash tier (spec §8).** Trashed rows are invisible to the shelf index, tools, and listing APIs; they surface only through the `/api/trash` routes with their `trash_origin` display snapshot. Restore is a database re-point — `stored_relpath` is projects-root-relative, so no file ever moves (spec §10.6) — inside one transaction that locks the target active project row, then the source and any merge-candidate rows in ID order; identical active bytes in the target merge (trash row deleted, discarded namespace unlinked best-effort after commit), and a read-only existence/size check under the document lock rejects restores of missing or size-mismatched content with `409 content_missing`, leaving the row trashed. Purge holds the document-row lock continuously across trashed-state revalidation, unlink of the original and `derived/converted.md`, row deletion, and commit: already-absent content counts as removed, any other unlink error rolls back and keeps the row retryable, and retention callers revalidate the selected trash timestamp and cutoff under the lock so a restored-and-re-trashed row is never purged on its former expiry. Empty trash (`POST /api/trash/purge`) runs that same guarded purge once per trashed row of the caller with no age filter — the confirmation covers the whole listing — so the retention cutoff gates only the sweep's candidate selection. The retention sweep runs lazily on `GET /api/trash/documents` and once at gateway startup (no daemon): it purges rows at or past `projects.trash_retention_days` through the same guarded path, then reconciles storage — `.staging/*` entries and unreferenced files older than 24 hours are removed (nothing younger is ever collected; any active or trashed row protects its whole namespace, even after restore to another project) — and detects rows with missing or size-mismatched content, logging them at warning and surfacing them as `content_missing` without ever deleting the row (the row is the user's only record; disposal always starts with the user trashing it). **Trash tier (spec §8).** Trashed rows are invisible to the shelf index, tools, and listing APIs; they surface only through the `/api/trash` routes with their `trash_origin` display snapshot. Restore is a database re-point — `stored_relpath` is projects-root-relative, so no file ever moves (spec §10.6) — inside one transaction that locks the target active project row, then the source and any merge-candidate rows in ID order; identical active bytes in the target merge (trash row deleted, discarded namespace unlinked best-effort after commit), and a read-only existence/size check under the document lock rejects restores of missing or size-mismatched content with `409 content_missing`, leaving the row trashed. Purge holds the document-row lock continuously across trashed-state revalidation, unlink of the original and `derived/converted.md`, row deletion, and commit: already-absent content counts as removed, any other unlink error rolls back and keeps the row retryable, and retention callers revalidate the selected trash timestamp and cutoff under the lock so a restored-and-re-trashed row is never purged on its former expiry. Empty trash (`POST /api/trash/purge`) runs that same guarded purge once per trashed row of the caller with no age filter — the confirmation covers the whole listing — so the retention cutoff gates only the sweep's candidate selection. The retention sweep runs lazily on `GET /api/trash/documents` and once at gateway startup (no daemon): it purges rows at or past `projects.trash_retention_days` through the same guarded path, then reconciles storage — `.staging/*` entries and unreferenced files older than 24 hours are removed (nothing younger is ever collected; any active or trashed row protects its whole namespace, even after restore to another project) — and detects rows with missing or size-mismatched content, logging them at warning and surfacing them as `content_missing` without ever deleting the row (the row is the user's only record; disposal always starts with the user trashing it).
**Document summaries (default on).** `project_documents.summary` is a nullable, best-effort LLM one-liner. The two shelf write routes enqueue a job after row creation (`projects/summaries.py`, enabled by default; disabled with `projects.summaries_enabled: false`); a bounded worker pool (`summary_concurrency` + bounded `summary_queue_size`, full queue drops) resolves text through `read_text_serving_path` (never re-deriving the convertible-first rule), calls the model via `run_oneshot_llm` with a caller-side wait limit (the worker slot is held until the provider call actually finishes; late results are discarded), rejects length-capped output through the shared provider-normalized detectors, and applies a write-time contract (unwrap → single line → empty is NULL → byte cap on a character boundary) before persisting with `updated_at` pinned so a landing summary never reorders the shelf. The summary rides the row's lifecycle (trash/restore/purge need no hooks), renders into the `<documents>` index and `list_project_documents` tag-neutralized at both exits, and is counted through `eligible/success/failed/skipped/queue_dropped/uncapped_accepted` metrics. All failure modes leave `summary = NULL`, which renders exactly like a shelf without the feature. **Document summaries (opt-in).** `project_documents.summary` is a nullable, best-effort LLM one-liner. The two shelf write routes enqueue a job after row creation (`projects/summaries.py`, off unless `projects.summaries_enabled`); a bounded worker pool (`summary_concurrency` + bounded `summary_queue_size`, full queue drops) resolves text through `read_text_serving_path` (never re-deriving the convertible-first rule), calls the model via `run_oneshot_llm` with a caller-side wait limit (the worker slot is held until the provider call actually finishes; late results are discarded), rejects length-capped output through the shared provider-normalized detectors, and applies a write-time contract (unwrap → single line → empty is NULL → byte cap on a character boundary) before persisting with `updated_at` pinned so a landing summary never reorders the shelf. The summary rides the row's lifecycle (trash/restore/purge need no hooks), renders into the `<documents>` index and `list_project_documents` tag-neutralized at both exits, and is counted through `eligible/success/failed/skipped/queue_dropped/uncapped_accepted` metrics. All failure modes leave `summary = NULL`, which renders exactly like a shelf without the feature.
### Agent Architecture ### Agent Architecture

View File

@ -1494,7 +1494,7 @@ done
docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10 docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10
``` ```
**预期:** 无 auth 相关错误。渠道不依赖浏览器 cookie;服务端通过内部认证头接受请求,run 归入绑定 owner 的用户桶(未绑定时为规范化后的平台用户 ID;仅 `DEER_FLOW_AUTH_DISABLED=1` 时为 `default`)。 **预期:** 无 auth 相关错误。渠道不依赖浏览器 cookie;服务端通过内部认证头把请求归入 `default` 用户桶。
#### TC-DOCKER-05: reset_admin 密码写入 0600 凭证文件(不再走日志) #### TC-DOCKER-05: reset_admin 密码写入 0600 凭证文件(不再走日志)

View File

@ -127,7 +127,7 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
- **本地开发**(`make dev`):Gateway embedded runtime 完全兼容;无 admin 时访问 `/setup` 初始化 - **本地开发**(`make dev`):Gateway embedded runtime 完全兼容;无 admin 时访问 `/setup` 初始化
- **Gateway embedded runtime**:标准脚本、Docker dev 和生产部署均通过 Gateway 提供认证与 LangGraph-compatible API - **Gateway embedded runtime**:标准脚本、Docker dev 和生产部署均通过 Gateway 提供认证与 LangGraph-compatible API
- **Docker 部署**:完全兼容,`.deer-flow/data/deerflow.db` 需持久化卷挂载 - **Docker 部署**:完全兼容,`.deer-flow/data/deerflow.db` 需持久化卷挂载
- **IM 渠道**(Feishu/Slack/Telegram):通过 Gateway 内部认证通信。已通过 `/connect` 绑定的发送者使用绑定 owner 的用户桶,未绑定的发送者使用规范化后的平台用户 ID;只有设置 `DEER_FLOW_AUTH_DISABLED=1` 的单用户模式才统一使用 `default` 用户桶。详见 [IM_CHANNEL_CONNECTIONS.md](IM_CHANNEL_CONNECTIONS.md) - **IM 渠道**(Feishu/Slack/Telegram):通过 Gateway 内部认证通信,使用 `default` 用户桶
- **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响 - **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响
## 故障排查 ## 故障排查

View File

@ -1,64 +0,0 @@
# Native batch dependencies and follow-up
Optional fields extend the existing SQL-backed batch service. Omitting them keeps
independent batch execution. Items remain read-only or idempotent under the existing
retry/recovery policy.
## Dependencies
Use `depends_on` to name prerequisite item keys in the same batch. Unknown keys,
self-references and cycles are rejected before saving. Dependent items consume no
worker slot until every prerequisite succeeds without a guardrail stop reason.
Retryable attempts keep dependents waiting; exhausted failures, cancellation or
terminal capped results fail unstarted descendants. Independent items continue.
Retrying a failed prerequisite by hand does not requeue the descendants it already
failed; retry each blocked item as well.
Item prompts must name their input/output paths. Ordering does not certify factual
accuracy or make advisory acceptance mandatory. Use existing result readers; full
reports are not copied into every dependent's context.
## Requested follow-up
`continuation` describes remaining work the user already requested. After terminal
batch results are saved, the lead performs that work in the original conversation
through ordinary Run admission. Preserve the originating model selection, reasoning,
limits and admitted retrieval scope. Busy conversations wait. While a question is
unanswered the claim is kept until its lease expires, so the conversation is not
reread on every poll.
A stable idempotency key reconciles lost admission replies without another Run.
Pending claims rotate so busy conversations do not starve others. Manual item retries
hold an unadmitted follow-up but do not re-arm one already dispatched. Without a Goal,
a follow-up cannot request another automatic follow-up.
An active Goal wakes only from its own `external_wait`, consuming one existing
continuation allowance, once even after recovery. Other blockers, no-progress limits
and hard stops remain effective. Goal replacement, clearing and thread deletion
suppress pending work; a failed, cancelled or hard-stopped source Run cannot restart.
## Authority, cancellation and recovery
Follow-up needs a session-authorized Gateway AgentRuns capability or local auth-disabled
mode. Internal/PAT callers and unsupported embedded runtimes retain ordinary batches.
Persist only the original permission ceiling, account token version and auth source;
no cookie, API key or Python capability. Restore checks current account/permissions,
conversation ownership and incarnation. Private continuation data is not returned.
Batch cancellation revokes the existing SQL execution fence and stops an admitted
follow-up, including after a lost launch reply. HTTP and tool cancellation of a batch
with a follow-up also require `runs:cancel`. Trusted internal cleanup retains its existing
path. Claim ownership/expiry lives only in the existing execution-lease table.
Completed item results remain available. The Web panel discovers follow-up Runs
through existing observers; refresh never launches work. Chat Stop remains available.
## Upgrade and verification
Migration `0052_batch_continuation` adds dependency edges and nullable follow-up data,
status and Run ID. Existing rows need no backfill. Upgrade all batch workers before
using dependencies. Do not downgrade with opted-in work outstanding; downgrade drops
new metadata while retaining items and reports.
Focused regressions cover dependency/retry ordering, SQL claims and recovery,
permission revocation, admission races, Goal limits and page reconnection. PostgreSQL
coverage uses `TEST_POSTGRES_URI`; browser backend timing is simulated.

View File

@ -323,7 +323,7 @@ are:
| Provider ID | Service / credential scope | Endpoint | Protocol / authentication | | Provider ID | Service / credential scope | Endpoint | Protocol / authentication |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `openai` | OpenAI API | `https://api.openai.com/v1` | Responses for the GPT-6 IDs below; otherwise Chat Completions / Bearer | | `openai` | OpenAI API | `https://api.openai.com/v1` | Chat Completions / Bearer |
| `anthropic` | Anthropic API | `https://api.anthropic.com` | Native Messages `/v1/messages` / `x-api-key` | | `anthropic` | Anthropic API | `https://api.anthropic.com` | Native Messages `/v1/messages` / `x-api-key` |
| `deepseek` | DeepSeek API | `https://api.deepseek.com` | Chat Completions / Bearer | | `deepseek` | DeepSeek API | `https://api.deepseek.com` | Chat Completions / Bearer |
| `qwen-cn` | Alibaba Cloud Bailian / Qwen, China | `https://dashscope.aliyuncs.com/compatible-mode/v1` | Chat Completions / Bearer | | `qwen-cn` | Alibaba Cloud Bailian / Qwen, China | `https://dashscope.aliyuncs.com/compatible-mode/v1` | Chat Completions / Bearer |
@ -334,7 +334,6 @@ are:
| `zai` | Z.AI, international ordinary API | `https://api.z.ai/api/paas/v4` | Chat Completions / Bearer | | `zai` | Z.AI, international ordinary API | `https://api.z.ai/api/paas/v4` | Chat Completions / Bearer |
| `minimax` | MiniMax, international API | `https://api.minimax.io/v1` | Chat Completions / Bearer | | `minimax` | MiniMax, international API | `https://api.minimax.io/v1` | Chat Completions / Bearer |
| `minimax-cn` | MiniMax, China API | `https://api.minimaxi.com/v1` | Chat Completions / Bearer | | `minimax-cn` | MiniMax, China API | `https://api.minimaxi.com/v1` | Chat Completions / Bearer |
| `stepfun` | StepFun, ordinary API | `https://api.stepfun.com/v1` | Chat Completions / Bearer |
| `openai-compatible` | Custom OpenAI-compatible, including unauthenticated local endpoints | Administrator-supplied HTTP(S) base URL | Chat Completions / optional key | | `openai-compatible` | Custom OpenAI-compatible, including unauthenticated local endpoints | Administrator-supplied HTTP(S) base URL | Chat Completions / optional key |
Keys for different regions and Coding Plans are not interchangeable. These Keys for different regions and Coding Plans are not interchangeable. These
@ -343,47 +342,6 @@ Plan from the model name. Plans/regions without a preset can use the custom
OpenAI-compatible option if their documented protocol is compatible; native OpenAI-compatible option if their documented protocol is compatible; native
protocols need a verified backend preset or operator YAML configuration. protocols need a verified backend preset or operator YAML configuration.
The StepFun preset uses the existing `PatchedChatStepFun` adapter to capture
`reasoning` / `reasoning_content` in responses and replay reasoning history.
Both connection probes and chat send the documented `max_tokens` output bound.
Model IDs remain free-form; this provider registration does not infer thinking,
vision or context-window capabilities from their names. The preset is for
ordinary API credentials. Step Plan has a separate `/step_plan/v1` endpoint;
use the custom OpenAI-compatible option for that channel. See the official
[OpenAI migration guide](https://platform.stepfun.com/docs/zh/guides/developer/openai)
and [Chat Completions reference](https://platform.stepfun.com/docs/zh/api-reference/chat/chat-completion-create).
Settings → Models → **Fetch and add models** reads the connection's model
catalog without changing configuration. Administrators can reuse an existing
saved connection without exposing its key, search IDs, select visible results,
and test/add the selection. Each selected new model must pass the existing
streaming tool-call probe before it is enabled. Tests can incur provider charges.
Failures are displayed individually; stop/retry preserves successful imports and
deduplicates by provider, endpoint, credential and model ID. Existing disabled
profiles remain disabled. Renaming and enabling/disabling stay in the model editor.
Discovery uses the provider's model-list endpoint (including Anthropic pagination)
and reports unsupported endpoints for manual entry. It follows no redirects and
caps reads at 20 seconds, 2 MiB and 2,000 model IDs/20 pages; truncated catalogs are
marked. IDs do not establish chat/tool capabilities. No background synchronization
or automatic enabling occurs just because a model appears in the catalog.
Chat model pickers search display names, configured names, IDs and provider labels,
with provider filtering and favorites/recent groups. The eight recent selections
are browser-local and user-scoped, and only currently available models appear.
Selecting a model continues to use the existing conversation/account preferences.
The `openai` preset recognizes the exact IDs `gpt-6-astra`, `gpt-6.1-sol`,
`gpt-6.1-terra`, `gpt-6-sol` and `gpt-6-luna`. They use the Responses API
for both the connection probe and chat, including tool calls and tool-result
follow-ups. Astra and GPT-6.1 Sol require thinking, default to `medium`, and
accept `low`, `medium`, `high`, `xhigh` and `max`. The other three also accept
`none`: disabling thinking uses `none`, while enabling it without an explicit
effort uses `medium`. Generic `minimal` maps to `low`. Image input is enabled.
Explicit capability overrides remain supported; routing stays provider-owned.
Unknown IDs, older OpenAI presets and custom endpoints keep their existing
behavior. See OpenAI's [GPT-6 protocol and parameter guide](https://developers.openai.com/api/docs/guides/latest-model).
The `moonshot-cn` preset recognizes the exact IDs `kimi-k3.1-pro` and The `moonshot-cn` preset recognizes the exact IDs `kimi-k3.1-pro` and
`kimi-k3.1-pro-highspeed`. Both require thinking, preserve reasoning history, `kimi-k3.1-pro-highspeed`. Both require thinking, preserve reasoning history,
and accept top-level `reasoning_effort` values `low`, `high` and `max`, with and accept top-level `reasoning_effort` values `low`, `high` and `max`, with
@ -855,7 +813,7 @@ Notes:
scripts/dev_multi_instance.sh up # containers, config, Gateway A, then B, then nginx scripts/dev_multi_instance.sh up # containers, config, Gateway A, then B, then nginx
scripts/dev_multi_instance.sh check # automated cross-instance checks (below) scripts/dev_multi_instance.sh check # automated cross-instance checks (below)
scripts/dev_multi_instance.sh stop b --kill # SIGKILL B to simulate a crash; `start b` brings it back scripts/dev_multi_instance.sh stop b --kill # SIGKILL B to simulate a crash; `start b` brings it back
scripts/dev_multi_instance.sh logs a -f # follow Gateway A; also b, nginx, or all (both Gateways and nginx) scripts/dev_multi_instance.sh logs a -f
scripts/dev_multi_instance.sh down scripts/dev_multi_instance.sh down
``` ```
@ -933,16 +891,6 @@ agent_storage:
- `agent_storage` is restart-required (the backend is captured at Gateway lifespan startup). - `agent_storage` is restart-required (the backend is captured at Gateway lifespan startup).
- In a multi-worker Postgres deployment (`GATEWAY_WORKERS > 1`), leaving `agent_storage.backend: file` logs a startup warning — agents written to one node's local disk are invisible to the others, which is exactly the divergence the `db` backend fixes. - In a multi-worker Postgres deployment (`GATEWAY_WORKERS > 1`), leaving `agent_storage.backend: file` logs a startup warning — agents written to one node's local disk are invisible to the others, which is exactly the divergence the `db` backend fixes.
Managed subagents also follow this backend selection. With `file`, their directory
must support hard links: creation atomically publishes a fully written temporary
file without replacing an existing name. Workers sharing that directory therefore
return one HTTP 201 and one HTTP 409 for concurrent same-name creates. Other
filesystem errors propagate rather than falling back to an overwrite.
Gateway startup probes hard-link creation once per worker in that directory,
off the event loop, and logs a warning if the probe fails. Startup continues;
use a volume with working hard links or migrate to `backend: db` before creating
managed subagents on an incompatible volume. Probe files are removed afterward.
Migrating an existing install from `file` to `db`: Migrating an existing install from `file` to `db`:
Run from the repository root using the backend's `uv` environment. The importer Run from the repository root using the backend's `uv` environment. The importer
@ -1314,8 +1262,8 @@ offline mocked tests verify request composition and local filtering, not provide
retrieval behavior. No paid API calls are needed for the regression suite. retrieval behavior. No paid API calls are needed for the regression suite.
**Built-in Tools**: **Built-in Tools**:
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Serper, Serply, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute, Sofya, SpecStream, You.com) - `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Serper, Serply, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute, Sofya, You.com)
- `web_fetch` - Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless, Sofya, SpecStream, Unbrowse) - `web_fetch` - Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless, Sofya, Unbrowse)
- `web_capture` - Capture rendered webpage screenshots as artifacts (Browserless) - `web_capture` - Capture rendered webpage screenshots as artifacts (Browserless)
- `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper, Brave) - `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper, Brave)
- `ls` - List directory contents - `ls` - List directory contents
@ -1382,14 +1330,6 @@ shared (`100.64.0.0/10`, used by CGNAT and Tailscale), other non-global, or
cloud-metadata addresses; set `allow_private_addresses: true` only when you cloud-metadata addresses; set `allow_private_addresses: true` only when you
intentionally point the tool at an internal target. intentionally point the tool at an internal target.
Each Browserless tool entry reads its request timeout from `timeout_s`, with
`timeout` as an alias when `timeout_s` is absent. The default is 30 seconds.
Zero, negative, and non-finite values fall back to the default with a warning.
Strings that cannot be parsed as numbers also trigger this fallback. HTTPX can
time out immediately for non-positive values. It raises an error for NaN and
applies no deadline for positive infinity. Browserless preserves positive finite
values in seconds, including very large values.
Both `web_fetch` (Browserless provider) and `web_capture` need a running Both `web_fetch` (Browserless provider) and `web_capture` need a running
Browserless instance. You can point `base_url` at [Browserless Cloud](https://www.browserless.io/) Browserless instance. You can point `base_url` at [Browserless Cloud](https://www.browserless.io/)
(set `BROWSERLESS_TOKEN`) or run one locally with Docker: (set `BROWSERLESS_TOKEN`) or run one locally with Docker:
@ -1656,7 +1596,6 @@ sandbox:
request_timeout: 30 # management request timeout seconds request_timeout: 30 # management request timeout seconds
ready_timeout: 30 # create/readiness timeout seconds ready_timeout: 30 # create/readiness timeout seconds
use_server_proxy: false # proxy execd/file traffic through server use_server_proxy: false # proxy execd/file traffic through server
secure_access: false # per-sandbox endpoint token; Kubernetes + gateway mode only
sandbox_timeout: 14400 # remote lifetime; 0 = explicit cleanup sandbox_timeout: 14400 # remote lifetime; 0 = explicit cleanup
bash_command_timeout: 600 # default remote command timeout seconds bash_command_timeout: 600 # default remote command timeout seconds
replicas: 3 # active + warm cap per gateway process replicas: 3 # active + warm cap per gateway process
@ -1665,10 +1604,6 @@ sandbox:
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
``` ```
`use_server_proxy` and `secure_access` accept YAML booleans. Omitted or `null`
values default to `false`. Quoted values such as `"false"` cause a provider
startup error.
Install the optional SDK before selecting this provider: Install the optional SDK before selecting this provider:
```bash ```bash
@ -1996,7 +1931,7 @@ while WSL resolves it to `/mnt/d/data/report.csv`. WSL-native files can instead
use `/home/alice/...`. Native Windows paths can use forward slashes as above, use `/home/alice/...`. Native Windows paths can use forward slashes as above,
or single-quoted backslashes (`'D:\data'`). Plain, unquoted YAML scalars also keep or single-quoted backslashes (`'D:\data'`). Plain, unquoted YAML scalars also keep
backslashes literal; only double-quoted scalars interpret backslash escapes. backslashes literal; only double-quoted scalars interpret backslash escapes.
These are path examples; the [local development prerequisites](../../docs/guide/deployment.md#option-2-local-development) These are path examples; the [local development prerequisites](../../README.md#option-2-local-development)
still apply, including the documented WSL limitations of the startup scripts. still apply, including the documented WSL limitations of the startup scripts.
**Gateway in Docker:** both bundled Compose files mount the repository's **Gateway in Docker:** both bundled Compose files mount the repository's
@ -2100,7 +2035,6 @@ models:
- `SERPLY_API_KEY` - [Serply](https://serply.io) key for `web_search` (Google Search, plus Google News and Google Scholar via `vertical`) - `SERPLY_API_KEY` - [Serply](https://serply.io) key for `web_search` (Google Search, plus Google News and Google Scholar via `vertical`)
- `GROUNDROUTE_API_KEY` - GroundRoute meta-search API key for `web_search` and `web_fetch` (routes across Serper, Brave, Exa, Tavily, Firecrawl, Perplexity with gain-share pricing) - `GROUNDROUTE_API_KEY` - GroundRoute meta-search API key for `web_search` and `web_fetch` (routes across Serper, Brave, Exa, Tavily, Firecrawl, Perplexity with gain-share pricing)
- `SOFYA_API_KEY` - [Sofya](https://sofya.co) key for `web_search` and `web_fetch` - `SOFYA_API_KEY` - [Sofya](https://sofya.co) key for `web_search` and `web_fetch`
- `SPECSTREAM_API_KEY` - [SpecStream](https://specstream.ai) key for `web_search` and `web_fetch`
- `UNBROWSE_API_KEY` - [Unbrowse](https://unbrowse.ai) key for `web_fetch` - `UNBROWSE_API_KEY` - [Unbrowse](https://unbrowse.ai) key for `web_fetch`
- `YDC_API_KEY` - You.com Search API key for `web_search` (optional: without it the provider uses You.com's keyless free-tier endpoint) - `YDC_API_KEY` - You.com Search API key for `web_search` (optional: without it the provider uses You.com's keyless free-tier endpoint)
- `BROWSERLESS_TOKEN` - Browserless token for `web_fetch` (Browserless provider) and `web_capture`, sent as the `token` query parameter (required by Browserless Cloud and by a self-hosted instance started with `TOKEN`) - `BROWSERLESS_TOKEN` - Browserless token for `web_fetch` (Browserless provider) and `web_capture`, sent as the `token` query parameter (required by Browserless Cloud and by a self-hosted instance started with `TOKEN`)
@ -2112,8 +2046,6 @@ models:
- `DEER_FLOW_SKILLS_PATH` - Skills directory when `skills.path` is omitted - `DEER_FLOW_SKILLS_PATH` - Skills directory when `skills.path` is omitted
- `GATEWAY_ENABLE_DOCS` - Set to `false` to disable Swagger UI (`/docs`), ReDoc (`/redoc`), and OpenAPI schema (`/openapi.json`) endpoints (default: `true`) - `GATEWAY_ENABLE_DOCS` - Set to `false` to disable Swagger UI (`/docs`), ReDoc (`/redoc`), and OpenAPI schema (`/openapi.json`) endpoints (default: `true`)
Search, fetch and capture providers treat an example value that `.env.example` ships (such as `your-jina-api-key`, matched exactly) as unset, in `config.yaml` and in the environment alike: a keyless provider such as the Jina Reader uses its free tier, and a provider that needs a key reports it missing. Any other value is sent as written, so a self-hosted token you chose keeps working. Browserless reads `BROWSERLESS_TOKEN` only when its `token` field is omitted, so a blank or placeholder `token` is not replaced by the variable. `make doctor` applies the same rules and names the variable or field that still holds a placeholder, including `JINA_API_KEY` and `YDC_API_KEY` for the keyless providers.
## Backend dotenv selection ## Backend dotenv selection
Set `DEER_FLOW_ENV_FILE` in the backend process environment **before startup** Set `DEER_FLOW_ENV_FILE` in the backend process environment **before startup**
@ -2280,92 +2212,6 @@ only when the agent is expected to edit files or run commands for a trusted
task. task.
## HITL Tool Approval
Set `hitl_approval.enabled: true` to gate bound tools immediately before execution.
The default is disabled for existing installations. When enabled, unknown tools,
file writes, shell commands, and both personal and deployment MCP tools are
reviewed. Concrete host read/navigation tools can run directly; an MCP server's
read-only annotation never grants permission.
```yaml
hitl_approval:
enabled: true
default_mode: ask
allowed_modes: [ask, auto]
review_unknown_tools: true
tools: []
allow_tools: []
deny_tools: []
always_ask_tools: []
reviewer_model: null
reviewer_timeout_seconds: 30
reviewer_max_input_bytes: 65536
max_payload_bytes: 524288
child_approval_timeout_seconds: 300
non_interactive: deny
max_actions: 32
```
| Mode | Behavior |
| --- | --- |
| `ask` | Present an approval card for operations requiring review. |
| `auto` | Independently assess the concrete operation against user intent. Allow low/medium-risk authorized operations; ask for uncertainty or high risk; deny critical violations. |
| `full` | Skip individual approvals within mandatory deployment and account restrictions. |
Clients select `context.tool_approval_mode` per run from the operator's `allowed_modes`.
The default ceiling permits Ask and Auto. Full requires explicitly adding `full`
to that operator-owned list; the client selection is not persisted server-side
and cannot extend the ceiling.
This setting is separate from Flash/Thinking/Pro/Ultra. `deny_tools` blocks in
every mode. `always_ask_tools` requires a human in every mode and cannot receive
a chat grant. Uninspectable tool implementations cannot receive chat grants.
Implementation fingerprints include both execution callables and their callable
adapters, so changing an asynchronous implementation requires fresh approval. `allow_tools` is an explicit operator exception. `tools` adds
otherwise routine tools to review; disabling `review_unknown_tools` restores a
selected-tool policy. These are trusted deployment settings, never model output.
Auto uses a separate, tool-free model invocation. `reviewer_model` names an
existing configured profile; null reuses the effective execution profile. No new
provider or credentials are required. Review input contains original user intent,
tool identity/schema, and complete arguments, treating tool material as untrusted
data. Reviewer callbacks cannot enter the Agent message stream; the host records
its token usage. Each review adds model latency and usage. Invalid output,
timeout, provider failure, or oversized input requests human approval. In an
unattended run these outcomes deny execution. Model review supplements the host's
rules and sandbox; it is not a security isolation boundary.
Cards show complete arguments and support approve, reject with a reason, edit
and approve, or allow the exact operation for this chat. Edits are validated
against the public tool schema and rerun through mandatory authorization. Injected
runtime fields cannot be edited. Grants bind owner, thread incarnation, tool
implementation/schema/connection, arguments, and policy. A changed MCP connection
requires fresh approval. Revoke grants from the Tool permissions menu. SQL-backed
deployments persist grants; memory-only deployments keep them for the process.
Root pauses use native checkpoints and block fresh runs until resolved. Native
parallel interrupts resume by their individual IDs; decisions cannot approve a
stale proposal. Root pauses have no time expiry; policy/tool changes fail closed,
and disabling the feature still permits the existing all-reject escape hatch.
The full card must fit `max_payload_bytes`; oversized cards deny execution rather
than hiding part of the operation. `max_actions` bounds calls in a model turn;
zero removes that count limit.
Delegated calls use an owner-scoped live approval endpoint and suspend the original
child invocation. The wait expires after `child_approval_timeout_seconds`, or the
child's execution deadline if earlier. Cancellation, lost run ownership and worker
restart invalidate pending child approvals; they do not replay the delegated task.
The UI polls pending child cards while the parent is active. Authenticated session
ownership is required to answer these cards. Requests and audit records are deleted
with their thread. Scheduled, webhook and other unattended origins cannot open a
human wait. The legacy `non_interactive: allow` explicitly bypasses Ask for those
root runs; it never overrides mandatory deny/ask rules, Auto uncertainty, or an
unresumable child.
Approval permits an execution attempt. Guardrails, read-before-write rules,
sandbox restrictions and tool-account authorization still run afterward.
## Best Practices ## Best Practices
1. **Place `config.yaml` in project root** - Set `DEER_FLOW_PROJECT_ROOT` if the runtime starts elsewhere 1. **Place `config.yaml` in project root** - Set `DEER_FLOW_PROJECT_ROOT` if the runtime starts elsewhere

View File

@ -180,8 +180,6 @@ sequenceDiagram
Why a string and not a closure: `run_context` is JSON-encoded by the `langgraph_sdk` HTTP client before reaching Gateway. A Python callable does not survive that serialization. The harness side (`_github_env_from_runtime`) already accepts either shape, but only `str` round-trips through the SDK transport. Why a string and not a closure: `run_context` is JSON-encoded by the `langgraph_sdk` HTTP client before reaching Gateway. A Python callable does not survive that serialization. The harness side (`_github_env_from_runtime`) already accepts either shape, but only `str` round-trips through the SDK transport.
**App JWT signing**: the mint builds the App JWT in a worker thread, because it reads `GITHUB_APP_PRIVATE_KEY_PATH` and signs with the key. The key is still read on every mint, but it is parsed again only when its PEM text changes (parsing an RSA-2048 key takes tens of milliseconds and holds the GIL). A rotated key, inline or on disk, is used by the next mint without a restart.
**Token TTL caveat**: GitHub installation tokens are valid for ~1 hour. Most agent runs finish well inside that window. Truly long coder runs (multi-hour refactors at the higher `recursion_limit=250` ceiling) may see a 401 on a late `git push` / `gh pr create`. Auto-refresh past the 1h TTL is intentionally deferred — it requires registering a token-provider lookup on the harness side, which crosses the harness/app boundary (`tests/test_harness_boundary.py`). Until refresh ships, long runs should finish GitHub writes before expiry or accept the loss. If minting fails (bad App id, wrong installation_id, missing private key), the agent still runs without push/write credentials — read-only is better than no response. **Token TTL caveat**: GitHub installation tokens are valid for ~1 hour. Most agent runs finish well inside that window. Truly long coder runs (multi-hour refactors at the higher `recursion_limit=250` ceiling) may see a 401 on a late `git push` / `gh pr create`. Auto-refresh past the 1h TTL is intentionally deferred — it requires registering a token-provider lookup on the harness side, which crosses the harness/app boundary (`tests/test_harness_boundary.py`). Until refresh ships, long runs should finish GitHub writes before expiry or accept the loss. If minting fails (bad App id, wrong installation_id, missing private key), the agent still runs without push/write credentials — read-only is better than no response.
## Thread-create Race Recovery ## Thread-create Race Recovery

View File

@ -27,12 +27,6 @@ Because selecting an agent always creates a new thread instead of mutating the c
`/goal <condition>` sets a goal on the chat's conversation and starts a run with the condition as the task; `/goal clear` clears it. `/goal` alone reports what the web goal bar shows: the goal, `In progress.` while a run is pending or running, and once auto-continue has stood down, why (for example `Stopped: the continuation limit was reached.` or `Waiting for you: it needs your input.`). A met goal is cleared and recorded in the thread state, so `/goal` answers `Goal met: <goal>` until the next goal change. Reasons come only from host-written codes; the goal checker's notes and provider errors are never sent. `/goal <condition>` sets a goal on the chat's conversation and starts a run with the condition as the task; `/goal clear` clears it. `/goal` alone reports what the web goal bar shows: the goal, `In progress.` while a run is pending or running, and once auto-continue has stood down, why (for example `Stopped: the continuation limit was reached.` or `Waiting for you: it needs your input.`). A met goal is cleared and recorded in the thread state, so `/goal` answers `Goal met: <goal>` until the next goal change. Reasons come only from host-written codes; the goal checker's notes and provider errors are never sent.
`/goal from-chat [hint]` composes the goal from the conversation instead of taking the text literally. The Gateway reads the thread summary plus recent agreement turns — visible user/assistant text, the assistant's clarification questions, and the user's answers to clarification cards — asks a non-thinking model (the deployment default; the goal evaluator instead uses the run's recorded model) to preserve the constraints and stopping condition agreed in the chat, and saves the objective through the existing goal route; the manager then sends the saved objective as its own final message and starts exactly one run through the ordinary chat path. An optional hint after the command adds a constraint without restating the whole goal. Evidence tool output, subagent chatter and attachments are not part of the synthesis context: they inform execution, not the objective. If the conversation does not yield a clear goal, nothing is saved or started and the reply explains why; a conversation whose last turn is an unanswered human-input request (a clarification, a sandbox-network approval, another `human_input_request`) is reported as pending instead of being synthesized, because the agreement is incomplete; a busy thread is rejected before the model call when this Gateway worker owns the run, while a run admitted by another worker is still rejected by the save-time reservation (so the model call may already have happened). Because the saved objective is shown first, a run that cannot start is followed by the channel's busy or error reply. Like `PUT`/`DELETE /goal`, the save is rejected while a tool approval is pending, so a new goal cannot silently discard it. Model-generated failure reasons are never sent back to the chat.
The synthesis model call is subject to the same model-use authorization as other model-backed routes: a role denied the deployment default model gets the route's 403 and the conversation is never sent to that model. The save is a compare-and-swap against the checkpoint the synthesis read — a goal or checkpoint write that lands during the model call rejects the stale objective with a 409, and the channel replies that the conversation changed and nothing was saved. If the chat switches conversations (`/new`) while synthesis is in flight, the goal stays saved in the original thread but the manager does not start a run in the new conversation; the reply says so and carries the saved objective.
Run handoff stays pinned to the thread where the goal was saved and checks the conversation binding again after confirmation delivery and run preparation. A rejected start gets an explicit goal-saved, task-not-started reply. If the response is lost after submission, the reply says the task may still be running and asks the user to check the conversation in DeerFlow before retrying.
DeerFlow's own replies here and to `/connect` use the binding owner's web UI language (the `locale` user preference), else `channel_connections.notification_locale` (`en-US` by default). A reply with no known owner, such as one to an invalid or expired code, uses the configured default. Agent replies are not translated. DeerFlow's own replies here and to `/connect` use the binding owner's web UI language (the `locale` user preference), else `channel_connections.notification_locale` (`en-US` by default). A reply with no known owner, such as one to an invalid or expired code, uses the configured default. Agent replies are not translated.
Connect codes are deliberately **bind-time defenses**, not chat-time defenses. After binding, ordinary `allowed_users` continue to gate regular messages exactly as before. Connect codes are deliberately **bind-time defenses**, not chat-time defenses. After binding, ordinary `allowed_users` continue to gate regular messages exactly as before.
@ -193,16 +187,6 @@ graph TB
Msg --> Stream3 --> Stream --> Mid1 --> Mid2 --> Final Msg --> Stream3 --> Stream --> Mid1 --> Mid2 --> Final
``` ```
Feishu rejects a card whose request body exceeds 30 KB (`230025`). The channel
budgets card text at 24 KB of that body, measured as the bytes the text adds
after both JSON escapes, and sends CJK unescaped (3 bytes per character, about
8,000 per card). A streamed update that no longer fits shows the part that does,
ending in `…`. A longer final answer fills the running card and continues in up
to 10 cards replied to the same source message, split at line breaks; past that
the last card is clipped and marked `... (truncated)`. A retry of a partially
delivered answer resumes after the cards already sent, and a reply to any of the
cards continues the conversation.
For the special GitHub case (`fire_and_forget=True` channel policy), the manager calls `runs.create()` and returns once the run is `pending` — no outbound reply, because GitHub agents post via the `gh` CLI from inside their sandbox. See [GITHUB_AGENTS.md](GITHUB_AGENTS.md) for the full GitHub flow. For the special GitHub case (`fire_and_forget=True` channel policy), the manager calls `runs.create()` and returns once the run is `pending` — no outbound reply, because GitHub agents post via the `gh` CLI from inside their sandbox. See [GITHUB_AGENTS.md](GITHUB_AGENTS.md) for the full GitHub flow.
## Attachment delivery feedback ## Attachment delivery feedback
@ -844,12 +828,6 @@ chat identity. That compatibility applies only to the old group-binding shape;
a new private binding never authorizes messages from an unbound group. Bind a a new private binding never authorizes messages from an unbound group. Bind a
specific group again with a name to make it available as a notification target. specific group again with a name to make it available as a notification target.
Each WeCom group is its own conversation, with one DeerFlow thread per member;
a private chat is keyed by the sender alone. A group bound with its own
`/connect` keeps the thread it had before groups got their own conversation. A
legacy group binding covers every group of the bot, so its earlier thread is
not continued in any of them; each group starts a new one.
### WeChat private notifications ### WeChat private notifications
Use the existing iLink QR/bot setup and bind in a private chat. Send one private Use the existing iLink QR/bot setup and bind in a private chat. Send one private

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