mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
Compare commits
241 Commits
v2.2.0-rc0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04a9833763 | ||
|
|
bea75f3b23 | ||
|
|
bce5dc3327 | ||
|
|
b16e197618 | ||
|
|
8853e68638 | ||
|
|
81de0761ed | ||
|
|
b4410f64d4 | ||
|
|
53c5195e5d | ||
|
|
eb97327ddf | ||
|
|
06634003a5 | ||
|
|
7892f79f9c | ||
|
|
314e2a3489 | ||
|
|
7d45fea17e | ||
|
|
70a4b83907 | ||
|
|
95514245e2 | ||
|
|
51cc489c8f | ||
|
|
416a0647a4 | ||
|
|
7ae36b469d | ||
|
|
2dc68b242f | ||
|
|
98e8598adb | ||
|
|
b80191fad3 | ||
|
|
b883deaab2 | ||
|
|
13e6fc29e5 | ||
|
|
6eb4833598 | ||
|
|
9699f04cdc | ||
|
|
041d9e375e | ||
|
|
2baadff446 | ||
|
|
fc7c7e012c | ||
|
|
3d826c6d95 | ||
|
|
c7bc8bab67 | ||
|
|
80cb6404b6 | ||
|
|
218c64180e | ||
|
|
4d95bff550 | ||
|
|
d9b2cb4bcb | ||
|
|
2a97ec3e58 | ||
|
|
c8d5c108f3 | ||
|
|
4a27b133f8 | ||
|
|
3df3b3c630 | ||
|
|
bf4aa82f09 | ||
|
|
8850f8eb7e | ||
|
|
cf8c29c6b6 | ||
|
|
989b20b1dc | ||
|
|
6fe87b65fa | ||
|
|
72fb20d56d | ||
|
|
42da64a25c | ||
|
|
f60e8b1f78 | ||
|
|
99e395aa9c | ||
|
|
d49e0f3d7c | ||
|
|
91f4fb4570 | ||
|
|
226d1bc5ce | ||
|
|
6d1b12f650 | ||
|
|
d03f1234f5 | ||
|
|
1fbc06279a | ||
|
|
10bbcc92de | ||
|
|
bfa2794c76 | ||
|
|
54753636b1 | ||
|
|
ea7bb64f02 | ||
|
|
c612531213 | ||
|
|
f17e0504d0 | ||
|
|
f746da3a4c | ||
|
|
1647320ad1 | ||
|
|
0dc423605a | ||
|
|
b0b3a8aa22 | ||
|
|
45c1c22ffa | ||
|
|
5c5cf24ff2 | ||
|
|
7a8051c0d9 | ||
|
|
31d97fc85b | ||
|
|
2598940283 | ||
|
|
fa8e015023 | ||
|
|
7adb2062eb | ||
|
|
3ebd4de993 | ||
|
|
4e09e63a68 | ||
|
|
f9767c65d5 | ||
|
|
3e59e22b4c | ||
|
|
fb094984e3 | ||
|
|
463361c5ba | ||
|
|
c738c5ef8b | ||
|
|
7bbccda575 | ||
|
|
7bb92cea10 | ||
|
|
aa1f376e90 | ||
|
|
97746ad725 | ||
|
|
0d04322d41 | ||
|
|
3b05ab7dc3 | ||
|
|
4c00e8d4a9 | ||
|
|
24884e8120 | ||
|
|
3163a96ebd | ||
|
|
fe05090b96 | ||
|
|
2a9e7255a7 | ||
|
|
955524a510 | ||
|
|
aaca3eeaeb | ||
|
|
f986536492 | ||
|
|
3920169fb6 | ||
|
|
a73a94a6a3 | ||
|
|
d8e085630a | ||
|
|
284e0d537d | ||
|
|
91f20a782f | ||
|
|
5b1bee3d5a | ||
|
|
8924c5844a | ||
|
|
3aefc56f5f | ||
|
|
c69e9ef0bc | ||
|
|
961262a17e | ||
|
|
b315cf2c9e | ||
|
|
2a8927c5e1 | ||
|
|
1dae5eb363 | ||
|
|
7d805cd1b8 | ||
|
|
6c94ecc6cb | ||
|
|
4e90f5d771 | ||
|
|
c953ab65f5 | ||
|
|
3a85baba9f | ||
|
|
d1238c6974 | ||
|
|
f68abbb831 | ||
|
|
372baf762f | ||
|
|
2bd60d1e6f | ||
|
|
a99cd299f9 | ||
|
|
3f9a78e02e | ||
|
|
d0007090c2 | ||
|
|
aadc816f0b | ||
|
|
d658b13c79 | ||
|
|
a3d21e5e4d | ||
|
|
ab168befae | ||
|
|
b8e1ca98a0 | ||
|
|
2a16b26960 | ||
|
|
d8b0d24a84 | ||
|
|
8d7e48e5e7 | ||
|
|
61d76d9a6a | ||
|
|
7e64330dd1 | ||
|
|
a41d0fcec1 | ||
|
|
856cde25b1 | ||
|
|
eab978f04d | ||
|
|
f9e05c24ea | ||
|
|
07b4e06697 | ||
|
|
3067b5a5bd | ||
|
|
c0b0ac3489 | ||
|
|
e163b02bea | ||
|
|
097fa6dca7 | ||
|
|
ee006e0c86 | ||
|
|
2feec9cb09 | ||
|
|
4a53a36e32 | ||
|
|
9731bcf28a | ||
|
|
7f9e0bf611 | ||
|
|
593ba49458 | ||
|
|
24d93399fb | ||
|
|
bef8c9c2e3 | ||
|
|
b6ed47778d | ||
|
|
c1f9064e9b | ||
|
|
59beff5799 | ||
|
|
bebbd2cb35 | ||
|
|
bb7958886d | ||
|
|
b561242ba3 | ||
|
|
8331afa759 | ||
|
|
9e2cf685b0 | ||
|
|
713a861e75 | ||
|
|
459fc8ca43 | ||
|
|
6538496dbc | ||
|
|
728446fe0d | ||
|
|
2145c9ab1f | ||
|
|
a46d9221f8 | ||
|
|
2ceda05bf9 | ||
|
|
26bf78f2b9 | ||
|
|
4c4a4e9858 | ||
|
|
3dd9e8841f | ||
|
|
e5969fd8b9 | ||
|
|
c373f8491d | ||
|
|
900a5173b1 | ||
|
|
5371343f69 | ||
|
|
fcbb14e83f | ||
|
|
6f24bc7c36 | ||
|
|
1dd6d05627 | ||
|
|
14abd9a28e | ||
|
|
12c304f12c | ||
|
|
3b627af8e7 | ||
|
|
7c3d2551b6 | ||
|
|
21175ba46f | ||
|
|
01e1ed778b | ||
|
|
3f86f071f3 | ||
|
|
d0ec8dcc5d | ||
|
|
92325bd126 | ||
|
|
2c8892c810 | ||
|
|
7fd0c0cb3e | ||
|
|
98b72af732 | ||
|
|
bf9b031127 | ||
|
|
80902c52b7 | ||
|
|
08f343f5d7 | ||
|
|
17b05718e0 | ||
|
|
a0ce43d46a | ||
|
|
02df37c5de | ||
|
|
830c8ff6f1 | ||
|
|
51f732304a | ||
|
|
980cf3a448 | ||
|
|
be39a8a879 | ||
|
|
a17e5694cc | ||
|
|
38e6be638b | ||
|
|
31e7b4ae4f | ||
|
|
9a49fb2bb3 | ||
|
|
35656d6a9f | ||
|
|
a9bd03ef57 | ||
|
|
c6a3993695 | ||
|
|
e0f5483fdf | ||
|
|
eb5d1e1880 | ||
|
|
12b2a9429c | ||
|
|
6910c13e1e | ||
|
|
762b7188ab | ||
|
|
3027ab6714 | ||
|
|
bee97022b1 | ||
|
|
0f2f199b3d | ||
|
|
793d1edabc | ||
|
|
44e372dd7f | ||
|
|
51f0e2de1c | ||
|
|
178a9ab458 | ||
|
|
5d2749b05b | ||
|
|
cdc3e92d6b | ||
|
|
ac21b69cbf | ||
|
|
d50cbf8dc5 | ||
|
|
882433ae50 | ||
|
|
8062d4ba12 | ||
|
|
d8d0830e16 | ||
|
|
2488ba7c27 | ||
|
|
3f1438d4a0 | ||
|
|
b4b2a68ee5 | ||
|
|
c50cdc5161 | ||
|
|
9e6ce2f98e | ||
|
|
9e7448cabd | ||
|
|
b86be89f09 | ||
|
|
f7da737118 | ||
|
|
c28e64fc8f | ||
|
|
15433dda2a | ||
|
|
62de7f3425 | ||
|
|
8d52d2541d | ||
|
|
81e1ab8dcc | ||
|
|
4fd2b211cc | ||
|
|
1e24a2115c | ||
|
|
477e53386d | ||
|
|
771cf619c1 | ||
|
|
8d11aeb97b | ||
|
|
30aa601f6a | ||
|
|
44b88151be | ||
|
|
b3f23326a5 | ||
|
|
dedb154e4c | ||
|
|
6ca83a32cd | ||
|
|
5c4ba4d2fe | ||
|
|
76835c1ff2 |
@ -62,6 +62,9 @@ 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
|
||||||
@ -73,6 +76,23 @@ 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
|
||||||
@ -84,3 +104,17 @@ backend/sandbox/
|
|||||||
!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
|
||||||
|
|||||||
@ -30,6 +30,9 @@ 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
|
||||||
|
|
||||||
@ -72,6 +75,10 @@ UNBROWSE_API_KEY=your-unbrowse-api-key
|
|||||||
# 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
|
||||||
|
|||||||
6
.github/workflows/backend-unit-tests.yml
vendored
6
.github/workflows/backend-unit-tests.yml
vendored
@ -57,7 +57,7 @@ jobs:
|
|||||||
# its peers.
|
# its peers.
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2, 3, 4]
|
shard: [1, 2, 3, 4, 5, 6]
|
||||||
|
|
||||||
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 4)
|
- name: Run unit tests of backend (shard ${{ matrix.shard }} of ${{ strategy.job-total }})
|
||||||
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=4 GROUP=${{ matrix.shard }}
|
run: make test-shard SPLITS=${{ strategy.job-total }} GROUP=${{ matrix.shard }}
|
||||||
|
|||||||
41
.github/workflows/e2e-tests.yml
vendored
41
.github/workflows/e2e-tests.yml
vendored
@ -14,6 +14,7 @@ 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]
|
||||||
@ -28,6 +29,7 @@ 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:
|
||||||
@ -41,6 +43,15 @@ 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
|
||||||
@ -51,21 +62,20 @@ 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: uv sync --locked
|
run: |
|
||||||
|
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
|
||||||
@ -82,9 +92,20 @@ jobs:
|
|||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Install Playwright Chromium
|
# Playwright collects the resource suite before selecting a shard. Its
|
||||||
|
# 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
|
||||||
run: npx playwright install chromium --with-deps
|
id: chromium
|
||||||
|
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
|
||||||
@ -100,7 +121,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 }}
|
if: ${{ !cancelled() && matrix.shard == 1 && steps.chromium.outcome == 'success' }}
|
||||||
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
|
||||||
|
|||||||
24
.github/workflows/replay-e2e.yml
vendored
24
.github/workflows/replay-e2e.yml
vendored
@ -71,21 +71,27 @@ 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: uv sync --group dev
|
run: |
|
||||||
|
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:
|
||||||
@ -97,9 +103,13 @@ 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: Install Playwright Chromium
|
- name: Verify Playwright Chromium
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
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: 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
|
||||||
|
|||||||
12
.github/workflows/sandbox-image-smoke.yml
vendored
12
.github/workflows/sandbox-image-smoke.yml
vendored
@ -29,7 +29,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
sandbox-image-smoke:
|
sandbox-image-smoke:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 45
|
||||||
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,7 +38,16 @@ 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.
|
||||||
@ -76,5 +85,6 @@ 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
|
||||||
|
|||||||
8
.github/workflows/triage.yml
vendored
8
.github/workflows/triage.yml
vendored
@ -55,7 +55,13 @@ 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\//]],
|
||||||
['area:backend', [/^backend\/app\//, /^backend\/packages\/harness\/deerflow\/(runtime|persistence|config|tools|guardrails|tracing|models|utils|uploads)\//]],
|
// Cover both backend packages. Keep the harness exclusions aligned
|
||||||
|
// 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\//]],
|
||||||
|
|||||||
318
AGENTS.md
318
AGENTS.md
@ -1,250 +1,122 @@
|
|||||||
# AGENTS.md
|
# DeerFlow
|
||||||
|
|
||||||
Guidance source; `CLAUDE.md` imports `@AGENTS.md`.
|
DeerFlow is an agent harness: a LangGraph-based backend with thread-isolated
|
||||||
Module guides:
|
sandboxes, memory, subagents, skills and MCP tools, and a Next.js frontend. IM
|
||||||
|
channels connect through the Gateway. `CLAUDE.md` imports this file; edit
|
||||||
|
guidance here, not in the shim.
|
||||||
|
|
||||||
- **[backend/AGENTS.md](backend/AGENTS.md)** — backend depth: harness/app split, agent &
|
## How guidance is organized
|
||||||
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.
|
|
||||||
|
|
||||||
## What is DeerFlow
|
Rules live in the `AGENTS.md` nearest to the code. Before you edit a directory,
|
||||||
|
read every `AGENTS.md` from that directory up to this file. Some coding agents do
|
||||||
|
not load the nested files on their own.
|
||||||
|
|
||||||
DeerFlow combines LangGraph and Next.js with thread-isolated sandbox execution,
|
| Area | Guide |
|
||||||
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` |
|
||||||
|
|
||||||
## Service Topology
|
Humans start at [README.md](README.md), [CONTRIBUTING.md](CONTRIBUTING.md) and
|
||||||
|
[docs/guide/](docs/guide/).
|
||||||
|
|
||||||
A single `make dev` / Docker stack runs four cooperating services:
|
## Service topology
|
||||||
|
|
||||||
| Service | Port | Role |
|
| Service | Port | Role |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **Nginx** | `2026` | Unified reverse-proxy entry point — open this in the browser |
|
| Nginx | 2026 | The single entry point; open this in the browser |
|
||||||
| **Gateway API** | `8001` | FastAPI REST API + embedded LangGraph-compatible agent runtime |
|
| Gateway | 8001 | FastAPI REST API and the embedded agent runtime |
|
||||||
| **Frontend** | `3000` | Next.js web interface |
|
| Frontend | 3000 | Next.js web interface |
|
||||||
| **Provisioner** | `8002` | Optional — only when sandbox is configured for provisioner/K8s mode |
|
| Provisioner | 8002 | Optional; only for provisioner or Kubernetes sandboxes |
|
||||||
|
|
||||||
Nginx is the single public entry: it proxies `/api/*` to the Gateway, rewriting
|
Nginx serves the frontend, proxies `/api/*` to the Gateway and rewrites
|
||||||
`/api/langgraph/*` onto the Gateway's native routes, and serves the frontend — see
|
`/api/langgraph/*` to the Gateway's native routes.
|
||||||
[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.
|
|
||||||
|
|
||||||
Keep the dedicated `/api/threads/{thread_id}/browser/stream` WebSocket regex before
|
Before changing ingress, port bindings or provisioner mounts, read
|
||||||
the generic threads regex in Docker, local and Helm nginx configs. Upgrade headers
|
[service topology details](docs/agent-service-topology.md).
|
||||||
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.
|
|
||||||
|
|
||||||
Both Compose files publish nginx at `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"`
|
## Repository map
|
||||||
(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.
|
|
||||||
|
|
||||||
Provisioner PVC mode declares `USERDATA_PVC_NAME` once as `user-data`; extra and
|
| Path | Content |
|
||||||
Lark broker credential mounts reuse it with their existing subPaths and read-only
|
| --- | --- |
|
||||||
flags. Keep credential mounts broker-only and keep distinct volumes in hostPath mode.
|
| `backend/packages/harness/` | `deerflow-harness`, import `deerflow.*`: the agent framework |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
## Repository Map
|
`config.yaml` and `extensions_config.json` are copied from the example files at
|
||||||
|
the repository root. Both are gitignored and the Gateway can write them. Do not
|
||||||
|
commit them.
|
||||||
|
|
||||||
```
|
## Commands
|
||||||
deer-flow/
|
|
||||||
├── Makefile # Root orchestration: drives the full stack (dev/start/stop, docker, setup)
|
|
||||||
├── config.example.yaml # Template → copy to config.yaml (gitignored) at repo root
|
|
||||||
├── extensions_config.example.json # Template → copy to extensions_config.json (gitignored): MCP servers + skills
|
|
||||||
├── backend/ # Python backend — see backend/AGENTS.md
|
|
||||||
│ ├── Makefile # Per-module backend commands (dev, gateway, test, lint, migrate-rev)
|
|
||||||
│ ├── extensions/sources/ # Deployable snapshots of locally installed Python extensions
|
|
||||||
│ ├── packages/extension-api/ # deerflow-extension-api package (import: deerflow_extension_api.*) — public extension contract
|
|
||||||
│ ├── packages/harness/ # deerflow-harness package (import: deerflow.*) — agent framework
|
|
||||||
│ └── app/ # FastAPI Gateway + IM channels (import: app.*)
|
|
||||||
├── frontend/ # Next.js frontend (pnpm) — see frontend/AGENTS.md
|
|
||||||
├── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
Third-party extensions load from operator-controlled `config.yaml -> plugins:`.
|
`make help` lists the root targets. First setup is `make config`, then
|
||||||
This imports code, so the list stays outside API-writable `extensions_config.json`.
|
`make install`, then `make dev`, which serves the stack on `localhost:2026` and
|
||||||
Extensions contribute middleware, lifecycle observers, Gateway services, FastAPI
|
writes `logs/gateway.log` and `logs/frontend.log`.
|
||||||
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/`.
|
|
||||||
|
|
||||||
**Extension-first evaluation** — before implementing a feature by editing core code,
|
|
||||||
evaluate whether it can ship as a packaged extension through the contribution points
|
|
||||||
above. Stay in core for bug fixes and for changes to the agent loop, memory, context
|
|
||||||
compaction, or authentication. If existing hooks cannot express the feature, add a
|
|
||||||
generic hook to the extension contract plus an extension that uses it — do not
|
|
||||||
hard-code business logic into core. Working examples:
|
|
||||||
`examples/deerflow-extension-{example,bookmarks,agent-teams}`.
|
|
||||||
[Workflow guide](examples/deerflow-extension-workflows/AGENTS.md): public
|
|
||||||
AgentRuns; POSIX, one process/store; approval and session-authorized recovery; uncertain admissions need reconciliation;
|
|
||||||
no multi-Gateway failover or hard currency budget.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Root runtime configs: copy `config.example.yaml` → `config.yaml` and
|
|
||||||
`extensions_config.example.json` → `extensions_config.json` (MCP/skills).
|
|
||||||
Both are gitignored and Gateway-editable; schema/resolution:
|
|
||||||
[backend/AGENTS.md](backend/AGENTS.md).
|
|
||||||
|
|
||||||
Skill quality review note:
|
|
||||||
- `skills/public/skill-reviewer/` is the built-in read-only reviewer (harness tool
|
|
||||||
`review_skill_package`, contracts in `contracts/skill_review/`). Model-visible review
|
|
||||||
data is tag-neutralized; raw payloads stay in tool artifacts. See
|
|
||||||
[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.
|
|
||||||
|
|
||||||
Scheduled-task note:
|
|
||||||
- `scheduler.tool_enabled` (default off) offers owner-bound schedule tools only
|
|
||||||
through Gateway capability admission. Interactive turns create/manage their
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Commands: Root vs. Module
|
|
||||||
|
|
||||||
**Root stack commands** (from repo root):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make setup # Interactive setup wizard (recommended for new users)
|
cd backend && make test && make test-blocking-io # offline suites
|
||||||
make doctor # Check configuration and system requirements
|
cd backend && make format # CI runs ruff format --check
|
||||||
make support-bundle # Generate redacted troubleshooting summary, AI issue draft, and optional zip
|
cd backend && python -m pytest tests/path/test_x.py::test_name -q
|
||||||
make config # Generate local config files from the examples
|
cd frontend && pnpm check && pnpm test
|
||||||
make check # Check that required tools are installed
|
cd frontend && pnpm rstest run <pattern>
|
||||||
make install # Install all dependencies (frontend + backend + pre-commit hooks)
|
make check-agent-guidance # size budget of these guides
|
||||||
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;
|
## Rules for every change
|
||||||
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,
|
- **Evaluate an extension first.** Before you add a feature to core code, check
|
||||||
as start/stop do.
|
whether it can ship as a packaged extension. Core is the place for bug fixes
|
||||||
|
and for changes to the agent loop, memory, context compaction and
|
||||||
|
authentication. When the existing hooks cannot express a feature, add a
|
||||||
|
generic hook to the extension contract and an extension that uses it; business
|
||||||
|
logic is not hard-coded into core. Contract and examples:
|
||||||
|
[extensions guide](backend/packages/harness/deerflow/extensions/AGENTS.md),
|
||||||
|
`examples/`.
|
||||||
|
- **Extensions are operator-controlled code.** They load from `plugins:` in
|
||||||
|
`config.yaml`. The list stays out of the API-writable `extensions_config.json`,
|
||||||
|
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).
|
||||||
|
|
||||||
Full list: `make help`.
|
## What belongs in an `AGENTS.md`
|
||||||
|
|
||||||
**Module commands** (from each module):
|
An agent reads these files on every task, so each line costs attention. A guide
|
||||||
|
holds four kinds of content:
|
||||||
|
|
||||||
```bash
|
1. Scope: what the directory owns, in one or two sentences.
|
||||||
# Backend (see backend/AGENTS.md for the full set)
|
2. Verification: the exact commands.
|
||||||
cd backend && make dev # Gateway API with reload (port 8001)
|
3. Traps: where the code misleads, or where a plausible change breaks something
|
||||||
cd backend && make test # Four parallel shards; excludes live and blocking-I/O tests
|
that a local test run does not show.
|
||||||
cd backend && make test-blocking-io # Strict blocking-I/O suite
|
4. Decisions and their reasons that the code cannot state, and links to docs.
|
||||||
cd backend && make lint # ruff check
|
|
||||||
cd backend && make format # ruff format
|
|
||||||
|
|
||||||
# Frontend (see frontend/AGENTS.md for the full set)
|
Values, defaults, field lists, function inventories, test names, issue numbers
|
||||||
cd frontend && pnpm dev # Dev server: Webpack by default (override with DEER_FLOW_DEV_BUNDLER=turbo)
|
and history stay in the code, the tests and git. Write full sentences in the
|
||||||
cd frontend && pnpm check # Lint + type check (run before committing)
|
positive form and give the reason. `make check-agent-guidance` enforces the size
|
||||||
cd frontend && pnpm test # Unit tests
|
budget.
|
||||||
```
|
|
||||||
|
|
||||||
Host pnpm calls use `scripts/pnpm.py`: native Windows tries `pnpm.cmd` before
|
### Office preview example
|
||||||
`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`
|
`examples/deerflow-extension-office-preview/` packages Office readers through the
|
||||||
|
optional `artifactPreviews` browser contract. Keep parser dependencies and resources
|
||||||
Run `make config`, then `make install`, then `make dev` on first setup.
|
in the extension; rebuild its manifest and static module graph after UI changes.
|
||||||
`make dev` requires existing config; both runtime configs are API-editable and
|
Test packaged assets with the Office E2E fixtures; rendering must preserve original
|
||||||
gitignored. Never commit them.
|
downloads and state its search limits.
|
||||||
|
|
||||||
### 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`, `frontend/package.json`, and `deploy/helm/deer-flow/Chart.yaml`
|
|
||||||
(`version` + `appVersion`), and `backend/uv.lock` must record the same version for the root
|
|
||||||
package (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 four 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.
|
|
||||||
|
|||||||
544
CHANGELOG.md
544
CHANGELOG.md
@ -7,9 +7,479 @@ 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
|
||||||
|
|
||||||
|
- **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:** 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
|
||||||
|
|
||||||
This section accumulates work toward the **2.2.0** milestone
|
This section accumulates work toward the **2.2.0** milestone
|
||||||
([2.2.0](https://github.com/bytedance/deer-flow/milestone/3)).
|
([2.2.0](https://github.com/bytedance/deer-flow/milestone/3)).
|
||||||
As of 2026-10-28, the milestone includes **1068 merged pull requests**.
|
This release closes that milestone with **1068 merged pull requests**.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@ -14678,7 +15148,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/pull/5224
|
[#5224]: https://github.com/bytedance/deer-flow/commit/572744975d369fda88168aaa7b481282f939affe
|
||||||
[#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
|
||||||
@ -14687,9 +15157,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/pull/5244
|
[#5244]: https://github.com/bytedance/deer-flow/commit/9ce6fdcb2252ea005b4fa98ca50bd3857e1f681a
|
||||||
[#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/pull/5247
|
[#5247]: https://github.com/bytedance/deer-flow/commit/fa89a125267be4213009376ec0e232a0c2411ca8
|
||||||
[#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
|
||||||
@ -14746,7 +15216,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/pull/5373
|
[#5373]: https://github.com/bytedance/deer-flow/commit/d5ae3882b6708fa9af7289a0c3c0be3eee157203
|
||||||
[#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
|
||||||
@ -14773,7 +15243,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/pull/5413
|
[#5413]: https://github.com/bytedance/deer-flow/commit/daf32cd24632f2cc7465aeb62f2a1e1e768eceb2
|
||||||
[#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
|
||||||
@ -14956,8 +15426,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/pull/5740
|
[#5740]: https://github.com/bytedance/deer-flow/commit/e7128dc7d6b4d49153b10f7276f010384ec4e6af
|
||||||
[#5741]: https://github.com/bytedance/deer-flow/pull/5741
|
[#5741]: https://github.com/bytedance/deer-flow/commit/363b0fc1b4a5bd6c142d1f1df9fb9743cca550a3
|
||||||
[#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
|
||||||
@ -14970,7 +15440,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/pull/5763
|
[#5763]: https://github.com/bytedance/deer-flow/commit/39351315bf5d096fc84ad8307d3702b6aa5b3e24
|
||||||
[#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
|
||||||
@ -15038,7 +15508,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/pull/5884
|
[#5884]: https://github.com/bytedance/deer-flow/commit/e8cb5e9c4f5f274401eeb555aff62e713035cbf4
|
||||||
[#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
|
||||||
@ -15100,9 +15570,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/pull/6009
|
[#6009]: https://github.com/bytedance/deer-flow/commit/b87ebd940227465d611084e5bf49582aaeb4e9bd
|
||||||
[#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/pull/6015
|
[#6015]: https://github.com/bytedance/deer-flow/commit/f87a04e581829ff35afd4fa228240071b486192f
|
||||||
[#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
|
||||||
@ -15126,7 +15596,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/pull/6066
|
[#6066]: https://github.com/bytedance/deer-flow/commit/73fdbcfdf4b5f4037b62bc2ce9d8b60ec04e69f8
|
||||||
[#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
|
||||||
@ -15152,7 +15622,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/pull/6112
|
[#6112]: https://github.com/bytedance/deer-flow/commit/52b21d13ac6e674a19cb76013d097e73f108c994
|
||||||
[#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
|
||||||
@ -15253,7 +15723,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/pull/6304
|
[#6304]: https://github.com/bytedance/deer-flow/commit/b85e4ae74bb407e1e2396243a8a6e8f5b2420e3f
|
||||||
[#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
|
||||||
@ -15637,6 +16107,7 @@ 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
|
||||||
@ -15909,3 +16380,46 @@ 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
|
||||||
|
[#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
|
||||||
|
|||||||
259
CHANGELOG_zh.md
259
CHANGELOG_zh.md
@ -9,8 +9,200 @@
|
|||||||
|
|
||||||
## [未发布]
|
## [未发布]
|
||||||
|
|
||||||
|
本节累积面向 **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])
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **网关:** 创建对话时在元数据中带上 `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])
|
||||||
|
|
||||||
|
- **调度器:** 一个已保存的计划无法再计算的定时任务,不再让其他所有任务停止运行。此前
|
||||||
|
已保存的时区或 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** 里程碑([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))的工作。
|
||||||
截至 2026-10-28,该里程碑已合并 **1068 个拉取请求**。
|
该里程碑随本次发布收尾,共合并 **1068 个拉取请求**。
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
@ -221,6 +413,12 @@
|
|||||||
选择器缺失、是目录或不可读时会在调用 Docker 之前直接失败,而文件删除后
|
选择器缺失、是目录或不可读时会在调用 Docker 之前直接失败,而文件删除后
|
||||||
`make down` 仍能停止整个栈;不设置或为空的选择器保持可选的仓库根 `.env`
|
`make down` 仍能停止整个栈;不设置或为空的选择器保持可选的仓库根 `.env`
|
||||||
默认值。([#6522])
|
默认值。([#6522])
|
||||||
|
- **HITL:** 新增独立于推理模式的 Ask、Auto、Full 工具权限模式。Auto 通过单独的
|
||||||
|
无工具模型调用审核具体操作,默认复用现有模型配置;不确定或高风险操作转人工审批。
|
||||||
|
卡片展示完整参数,支持批准、拒绝、修改参数及可撤销的会话精确授权。主代理审批通过
|
||||||
|
checkpoint 支持重启恢复;交互式子代理通过属主限定的通道等待,并处理取消和过期。
|
||||||
|
所有模式均保留部署规则和账户权限,个人/平台 MCP 连接、参数和策略均参与授权绑定。
|
||||||
|
默认关闭;`config_version` 66。
|
||||||
|
|
||||||
- **MCP:** 按未变更的服务器缓存完成包装的工具,仅在绑定对账后重新发现缺失或配置
|
- **MCP:** 按未变更的服务器缓存完成包装的工具,仅在绑定对账后重新发现缺失或配置
|
||||||
发生变化的服务器。将成功但未发现工具的结果与发现失败分开保存,并保留代际、共享
|
发生变化的服务器。将成功但未发现工具的结果与发现失败分开保存,并保留代际、共享
|
||||||
@ -11449,7 +11647,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/pull/5224
|
[#5224]: https://github.com/bytedance/deer-flow/commit/572744975d369fda88168aaa7b481282f939affe
|
||||||
[#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
|
||||||
@ -11458,9 +11656,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/pull/5244
|
[#5244]: https://github.com/bytedance/deer-flow/commit/9ce6fdcb2252ea005b4fa98ca50bd3857e1f681a
|
||||||
[#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/pull/5247
|
[#5247]: https://github.com/bytedance/deer-flow/commit/fa89a125267be4213009376ec0e232a0c2411ca8
|
||||||
[#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
|
||||||
@ -11517,7 +11715,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/pull/5373
|
[#5373]: https://github.com/bytedance/deer-flow/commit/d5ae3882b6708fa9af7289a0c3c0be3eee157203
|
||||||
[#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
|
||||||
@ -11544,7 +11742,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/pull/5413
|
[#5413]: https://github.com/bytedance/deer-flow/commit/daf32cd24632f2cc7465aeb62f2a1e1e768eceb2
|
||||||
[#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
|
||||||
@ -11727,8 +11925,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/pull/5740
|
[#5740]: https://github.com/bytedance/deer-flow/commit/e7128dc7d6b4d49153b10f7276f010384ec4e6af
|
||||||
[#5741]: https://github.com/bytedance/deer-flow/pull/5741
|
[#5741]: https://github.com/bytedance/deer-flow/commit/363b0fc1b4a5bd6c142d1f1df9fb9743cca550a3
|
||||||
[#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
|
||||||
@ -11741,7 +11939,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/pull/5763
|
[#5763]: https://github.com/bytedance/deer-flow/commit/39351315bf5d096fc84ad8307d3702b6aa5b3e24
|
||||||
[#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
|
||||||
@ -11809,7 +12007,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/pull/5884
|
[#5884]: https://github.com/bytedance/deer-flow/commit/e8cb5e9c4f5f274401eeb555aff62e713035cbf4
|
||||||
[#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
|
||||||
@ -11871,9 +12069,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/pull/6009
|
[#6009]: https://github.com/bytedance/deer-flow/commit/b87ebd940227465d611084e5bf49582aaeb4e9bd
|
||||||
[#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/pull/6015
|
[#6015]: https://github.com/bytedance/deer-flow/commit/f87a04e581829ff35afd4fa228240071b486192f
|
||||||
[#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
|
||||||
@ -11897,7 +12095,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/pull/6066
|
[#6066]: https://github.com/bytedance/deer-flow/commit/73fdbcfdf4b5f4037b62bc2ce9d8b60ec04e69f8
|
||||||
[#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
|
||||||
@ -11923,7 +12121,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/pull/6112
|
[#6112]: https://github.com/bytedance/deer-flow/commit/52b21d13ac6e674a19cb76013d097e73f108c994
|
||||||
[#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
|
||||||
@ -12024,7 +12222,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/pull/6304
|
[#6304]: https://github.com/bytedance/deer-flow/commit/b85e4ae74bb407e1e2396243a8a6e8f5b2420e3f
|
||||||
[#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
|
||||||
@ -12408,6 +12606,7 @@ 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
|
||||||
@ -12680,3 +12879,33 @@ 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
|
||||||
|
[#8006]: https://github.com/bytedance/deer-flow/pull/8006
|
||||||
|
[#8019]: https://github.com/bytedance/deer-flow/pull/8019
|
||||||
|
[#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
|
||||||
|
|||||||
@ -383,6 +383,41 @@ Every pull request triggers the following CI workflows:
|
|||||||
- **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 and blocking-I/O 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`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@ -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}`이며, 객체의 `kwargs`는 생성자에 전달됩니다. `kwargs` 값은 JSON 타입(객체, 배열, 문자열, 숫자, 불리언, null)이어야 하며, YAML 날짜와 타임스탬프는 JSON과 일치하도록 ISO 문자열로 변환됩니다. DeerFlow는 설정된 같은 목록을 리드 에이전트와 서브에이전트 파이프라인에 불러오며, 그 위치는 기본 제공 런타임 미들웨어와 루프/토큰 가드 뒤, 그리고 terminal-response/safety/clarification 꼬리 앞입니다. 따라서 엔터프라이즈 포크는 기본 제공 미들웨어 빌더를 패치하지 않고도 도메인 가드레일, 도구 호출 거버넌스, 관측성 훅을 추가할 수 있습니다. 패키지 누락, 잘못된 클래스, 손상된 모듈, 생성자 오류는 에이전트 생성 시점에 명확한 오류로 실패합니다. `config.yaml`과 `extensions_config.json`은 운영자가 제어하는 신뢰된 파일로 취급하세요. 커스텀 도구, 모델, 샌드박스, 가드레일, MCP 서버, MCP 인터셉터 선언과 마찬가지로 미들웨어 경로는 곧 코드 실행입니다. Gateway의 스킬/MCP 토글 엔드포인트는 이 필드를 보존하지만 `extensions.middlewares`에 대한 API 쓰기 경로는 제공하지 않습니다. 리드 에이전트 전용/서브에이전트 전용 미들웨어 목록을 따로 지정하는 기능은 아직 지원되지 않습니다.
|
고급 배포에서는 `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`를 지원하며, 문자열 항목과 이 필드를 생략한 객체는 계속 두 파이프라인 모두에 적용됩니다. 해당 체인에서 제외된 항목은 임포트하거나 인스턴스화하지 않습니다.
|
||||||
|
|
||||||
패키지화되고 설정 가능한 런타임 통합에는 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#feishu-group-notifications)를 참고하세요.
|
그룹은 자동으로 선택되지 않습니다. 선택을 해제하면 새 그룹 알림이 중지되고, 선택을 변경하면 이후 알림에 반영되며, 이미 큐에 들어간 알림은 원래 대상을 유지합니다. 연결이 해제된 바인딩은 대기 중인 알림을 절대 받지 않습니다. 첫 버전에서는 작업 양식 또는 REST API로 설정하며, 대화용 일정 도구의 입력은 기존과 같습니다. [그룹 전달 가이드](backend/docs/IM_CHANNEL_CONNECTIONS.md#group-notifications)를 참고하세요.
|
||||||
|
|
||||||
### 수명 주기, 안전 상한, 중지 조건
|
### 수명 주기, 안전 상한, 중지 조건
|
||||||
|
|
||||||
|
|||||||
19
README_zh.md
19
README_zh.md
@ -109,7 +109,7 @@ DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[
|
|||||||
|
|
||||||
管理员可在 **设置 → 模型 → 添加模型** 中选择厂商,仅填写 **API Key** 和
|
管理员可在 **设置 → 模型 → 添加模型** 中选择厂商,仅填写 **API Key** 和
|
||||||
**模型 ID**。后端预置 OpenAI、Anthropic、DeepSeek、百炼/通义千问、火山/豆包、
|
**模型 ID**。后端预置 OpenAI、Anthropic、DeepSeek、百炼/通义千问、火山/豆包、
|
||||||
Moonshot/Kimi、智谱/Z.AI 和 MiniMax 的端点、认证与适配器,地域和 Coding Plan
|
Moonshot/Kimi、智谱/Z.AI、MiniMax 和阶跃星辰(StepFun)的端点、认证与适配器,地域和 Coding Plan
|
||||||
使用明确区分的选项。模型 ID 不受内置枚举限制;内部名称自动生成且保持稳定。
|
使用明确区分的选项。模型 ID 不受内置枚举限制;内部名称自动生成且保持稳定。
|
||||||
新模型默认启用,保存后自动刷新聊天及 Agent 的模型选择器,无需重启 Gateway。
|
新模型默认启用,保存后自动刷新聊天及 Agent 的模型选择器,无需重启 Gateway。
|
||||||
|
|
||||||
@ -712,6 +712,18 @@ 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 能做“几乎任何事”的关键。
|
||||||
@ -842,6 +854,11 @@ 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 验证并汇总成完整输出。
|
||||||
|
|||||||
@ -8,11 +8,12 @@ gates the release on them agreeing with the tag.
|
|||||||
|
|
||||||
## Version sources
|
## Version sources
|
||||||
|
|
||||||
A release version must appear, identically, in four places:
|
A release version must appear, identically, in five places:
|
||||||
|
|
||||||
| File | Field |
|
| File | Field |
|
||||||
| -------------------------------------- | -------------------- |
|
| -------------------------------------- | -------------------- |
|
||||||
| `backend/pyproject.toml` | `version = "X.Y.Z"` |
|
| `backend/pyproject.toml` | `version = "X.Y.Z"` |
|
||||||
|
| `backend/packages/harness/pyproject.toml` | `version = "X.Y.Z"` |
|
||||||
| `frontend/package.json` | `"version": "X.Y.Z"` |
|
| `frontend/package.json` | `"version": "X.Y.Z"` |
|
||||||
| `deploy/helm/deer-flow/Chart.yaml` | `version: X.Y.Z` |
|
| `deploy/helm/deer-flow/Chart.yaml` | `version: X.Y.Z` |
|
||||||
| `deploy/helm/deer-flow/Chart.yaml` | `appVersion: "X.Y.Z"`|
|
| `deploy/helm/deer-flow/Chart.yaml` | `appVersion: "X.Y.Z"`|
|
||||||
@ -32,7 +33,7 @@ distinguishes it from a release.
|
|||||||
|
|
||||||
## Helper scripts
|
## Helper scripts
|
||||||
|
|
||||||
- `scripts/bump_version.sh <version>` — set all four fields at once, refresh
|
- `scripts/bump_version.sh <version>` — set all five fields at once, refresh
|
||||||
`backend/uv.lock`, then self-verify. Tolerates a leading `v` (e.g. `v2.1.0`).
|
`backend/uv.lock`, then self-verify. Tolerates a leading `v` (e.g. `v2.1.0`).
|
||||||
Needs `uv` on `PATH` (`backend/uv.lock` pins the root package version too, and
|
Needs `uv` on `PATH` (`backend/uv.lock` pins the root package version too, and
|
||||||
lint CI runs `uv lock --check`); the script fails before editing anything when
|
lint CI runs `uv lock --check`); the script fails before editing anything when
|
||||||
@ -77,7 +78,7 @@ distinguishes it from a release.
|
|||||||
|
|
||||||
### Release candidates
|
### Release candidates
|
||||||
|
|
||||||
Release-candidate tags must include the same prerelease suffix in all four
|
Release-candidate tags must include the same prerelease suffix in all five
|
||||||
version fields. For example, before tagging `v2.1.0-rc0`, run
|
version fields. For example, before tagging `v2.1.0-rc0`, run
|
||||||
`bash scripts/bump_version.sh 2.1.0-rc0` and run
|
`bash scripts/bump_version.sh 2.1.0-rc0` and run
|
||||||
`bash scripts/verify_versions.sh 2.1.0-rc0` from the repository root. Commit
|
`bash scripts/verify_versions.sh 2.1.0-rc0` from the repository root. Commit
|
||||||
@ -150,7 +151,7 @@ Both features stay opt-in: the provisioner ignores them until
|
|||||||
|
|
||||||
Both publishing workflows call `.github/workflows/verify-versions.yml` as their
|
Both publishing workflows call `.github/workflows/verify-versions.yml` as their
|
||||||
first job. It runs `scripts/verify_versions.sh` against the tag (minus the
|
first job. It runs `scripts/verify_versions.sh` against the tag (minus the
|
||||||
`v`). If any of the four version sources doesn't match the tag, or
|
`v`). If any of the five version sources doesn't match the tag, or
|
||||||
`backend/uv.lock` is stale, the verify job fails and **all** publish jobs are
|
`backend/uv.lock` is stale, the verify job fails and **all** publish jobs are
|
||||||
skipped — no images, no chart.
|
skipped — no images, no chart.
|
||||||
|
|
||||||
|
|||||||
@ -1,327 +1,61 @@
|
|||||||
# AGENTS.md
|
# Backend (`backend/`)
|
||||||
|
|
||||||
## Project Overview
|
The Python backend: the publishable harness in `packages/harness/deerflow/`, the
|
||||||
|
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).
|
||||||
|
|
||||||
The LangGraph backend provides sandbox execution, persistent memory, subagent delegation, and extensible tools, isolated per thread.
|
## Guides by area
|
||||||
|
|
||||||
Service roles and ports: [root service topology](../AGENTS.md#service-topology).
|
Read the guide for an area before you change it.
|
||||||
|
|
||||||
**Runtime**:
|
| Area | Guide |
|
||||||
- `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.
|
| --- | --- |
|
||||||
- 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.
|
| Run streaming, checkpoints, run ownership | [runtime](packages/harness/deerflow/runtime/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.
|
| Repositories, leases, scheduled tasks, schema changes | [persistence](packages/harness/deerflow/persistence/AGENTS.md), [migrations](packages/harness/deerflow/persistence/migrations/AGENTS.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).
|
|
||||||
|
|
||||||
**Project structure:** The [root repository map](../AGENTS.md#repository-map)
|
## Verify
|
||||||
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
|
||||||
PYTHONPATH=. uv run python -m scripts.benchmark.deermem_eviction validate-contracts
|
make test # offline suite in parallel shards
|
||||||
PYTHONPATH=. uv run python -m scripts.benchmark.deermem_eviction validate --dataset "$LONGMEMEVAL_ORACLE_PATH"
|
make test-blocking-io # strict blocking-I/O suite
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The offline test suite must not require network access, provider credentials,
|
Run `make test` and `make test-blocking-io` before and after a change. Every
|
||||||
or the LongMemEval dataset. Small LongMemEval-shaped fixtures must be synthetic
|
feature and bug fix ships with a test in `tests/`. Live tests stay opt-in, so the
|
||||||
and generated by tests.
|
default suite needs no network and no API key.
|
||||||
|
|
||||||
`scripts/benchmark/concurrency/` measures multi-process contention on the
|
## Rules
|
||||||
`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/`:
|
|
||||||
|
|
||||||
```bash
|
- **The harness never imports `app`.** `app.*` imports `deerflow.*`, and not the
|
||||||
uv run python scripts/benchmark/concurrency/run_concurrency_bench.py \
|
reverse, because the harness is published on its own.
|
||||||
--backend sqlite --workers 2,4,8,16 --ops-per-worker 50 --read-ratio 0.7
|
`tests/test_harness_boundary.py` enforces this in CI.
|
||||||
uv run pytest tests/test_bench_concurrency.py tests/test_bench_worker.py -q
|
- **Start the Gateway with `make dev` or `make gateway`.** `make dev` excludes
|
||||||
```
|
`DEER_FLOW_HOME` and `backend/sandbox` from the reload watcher. A bare
|
||||||
|
`uvicorn --reload` restarts the Gateway during a run, because agent tasks write
|
||||||
## Commands
|
Python files below `DEER_FLOW_HOME`.
|
||||||
|
- **From the repository root, run backend tools with
|
||||||
Root orchestration and extension commands live in the
|
`uv run --no-sync --project backend`.** `--project` keeps the caller's working
|
||||||
[root guide](../AGENTS.md#commands-root-vs-module); run them from the repo root.
|
directory, so relative config paths still resolve. `--no-sync` keeps installed
|
||||||
`make detect-thread-boundaries` inventories backend executor, thread and event-loop
|
extras such as the PostgreSQL drivers.
|
||||||
boundaries.
|
- **Run Python through `uv run` inside `backend/`**, without `--no-sync`, so the
|
||||||
|
environment matches the lock.
|
||||||
**Backend directory** (for backend development only):
|
- **Guard POSIX-only test markers with `os.name`.** Windows collects the same
|
||||||
```bash
|
files.
|
||||||
make install # Install backend dependencies
|
- **Tool approvals need addressable calls.** Strip missing, empty and duplicate
|
||||||
make dev # Gateway API, reload (port 8001)
|
tool-call IDs before creating a pause or a tool result. See the
|
||||||
make gateway # Gateway API only (port 8001)
|
[run interaction policy](docs/RUN_INTERACTION_POLICY.md#hitl-tool-approval).
|
||||||
make test # four offline workers; TEST_JOBS=1 for serial
|
- **Add a schema change with `make migrate-rev MSG="..."`** and follow the
|
||||||
make test-live # live tests (real APIs)
|
migrations guide.
|
||||||
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
|
|
||||||
|
|||||||
@ -54,6 +54,10 @@ 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) |
|
||||||
@ -74,6 +78,12 @@ 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`
|
||||||
@ -105,6 +115,10 @@ 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
|
||||||
@ -156,8 +170,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 |
|
| `GET /api/memory/config` | Memory configuration; credential values in `backend_config` are masked |
|
||||||
| `GET /api/memory/status` | Combined config + data |
|
| `GET /api/memory/status` | Combined config (masked the same way) + 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 |
|
||||||
|
|||||||
@ -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` | 获取记忆配置 |
|
| `GET /api/memory/config` | 获取记忆配置;`backend_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
@ -250,7 +250,12 @@ 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):
|
||||||
@ -259,9 +264,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 = 2**attempt
|
delay = self._outbound_retry_delay(attempt, exc)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%s %s failed (attempt %d/%d), retrying in %ds: %s",
|
"%s %s failed (attempt %d/%d), retrying in %ss: %s",
|
||||||
prefix,
|
prefix,
|
||||||
operation_name,
|
operation_name,
|
||||||
attempt + 1,
|
attempt + 1,
|
||||||
@ -276,6 +281,16 @@ 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:
|
||||||
|
|||||||
@ -10,6 +10,7 @@ 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
|
||||||
@ -202,7 +203,6 @@ 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,7 +263,6 @@ 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
|
||||||
@ -351,12 +350,10 @@ 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:
|
||||||
@ -441,6 +438,24 @@ 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
|
||||||
@ -1244,30 +1259,42 @@ 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:
|
||||||
from dingtalk_stream.card_replier import AICardReplier
|
await self._card_request("POST", "/v1.0/card/instances/createAndDeliver", self._card_create_body(out_track_id, chatbot_message, initial_text))
|
||||||
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,
|
||||||
@ -1276,20 +1303,35 @@ class DingTalkChannel(Channel):
|
|||||||
is_finalize: bool = False,
|
is_finalize: bool = False,
|
||||||
is_error: bool = False,
|
is_error: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
replier = self._card_repliers.get(out_track_id)
|
await self._card_request(
|
||||||
if not replier:
|
"PUT",
|
||||||
raise RuntimeError(f"No AICardReplier found for track ID {out_track_id}")
|
"/v1.0/card/streaming",
|
||||||
|
{
|
||||||
replier.dingtalk_client.access_token = await self._get_access_token()
|
"outTrackId": out_track_id,
|
||||||
await replier.async_streaming(
|
"guid": str(uuid.uuid4()),
|
||||||
card_instance_id=out_track_id,
|
"key": "content",
|
||||||
content_key="content",
|
"content": content,
|
||||||
content_value=content,
|
"isFull": True,
|
||||||
append=False,
|
"isFinalize": is_finalize,
|
||||||
finished=is_finalize,
|
"isError": is_error,
|
||||||
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:
|
||||||
@ -1318,26 +1360,6 @@ 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."""
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ 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__)
|
||||||
@ -275,7 +276,10 @@ 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)
|
||||||
self._thread_store_path.write_text(json.dumps(snapshot, indent=2), encoding="utf-8", newline="")
|
# Publish through the shared helper: it fsyncs the staged bytes
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
|||||||
@ -55,6 +55,9 @@ 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
|
||||||
@ -107,6 +110,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@ -144,12 +148,110 @@ 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.
|
||||||
|
|
||||||
@ -549,8 +651,10 @@ 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),
|
lambda: self._send_card_message(msg, delivered=delivered),
|
||||||
max_retries=_max_retries,
|
max_retries=_max_retries,
|
||||||
log_prefix="[Feishu]",
|
log_prefix="[Feishu]",
|
||||||
)
|
)
|
||||||
@ -668,9 +772,7 @@ 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)
|
||||||
if not response.success():
|
return self._uploaded_key(response, "image")
|
||||||
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:
|
||||||
@ -692,9 +794,17 @@ 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 file upload failed: code={response.code}, msg={response.msg}")
|
raise RuntimeError(f"Feishu {kind} upload failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}")
|
||||||
return response.data.file_key
|
key = getattr(getattr(response, "data", None), f"{kind}_key", None)
|
||||||
|
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.
|
||||||
@ -709,6 +819,10 @@ 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:
|
||||||
@ -719,14 +833,40 @@ 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)
|
||||||
replace_next_placeholder("[image]", virtual_path)
|
downloaded.append(("[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)
|
||||||
replace_next_placeholder("[file]", virtual_path)
|
downloaded.append(("[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(
|
||||||
@ -858,9 +998,11 @@ class FeishuChannel(Channel):
|
|||||||
"""
|
"""
|
||||||
card = {
|
card = {
|
||||||
"config": {"wide_screen_mode": True, "update_multi": True},
|
"config": {"wide_screen_mode": True, "update_multi": True},
|
||||||
"elements": [{"tag": "markdown", "content": text}],
|
# Unpaired surrogates cannot be encoded as UTF-8.
|
||||||
|
"elements": [{"tag": "markdown", "content": _LONE_SURROGATE.sub("\ufffd", text)}],
|
||||||
}
|
}
|
||||||
return json.dumps(card)
|
# ASCII escapes would make each CJK character 6 bytes instead of 3.
|
||||||
|
return json.dumps(card, ensure_ascii=False)
|
||||||
|
|
||||||
# -- reaction helpers --------------------------------------------------
|
# -- reaction helpers --------------------------------------------------
|
||||||
|
|
||||||
@ -940,7 +1082,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, self._compose_card_text(text, metadata))
|
running_card_id = await self._reply_card(source_message_id, _clip_card_text(self._compose_card_text(text, metadata), _CARD_CONTINUES_MARKER))
|
||||||
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)
|
||||||
@ -1054,8 +1196,14 @@ 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) -> None:
|
async def _send_card_message(self, msg: OutboundMessage, *, delivered: list[str | None] | None = None) -> 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.
|
||||||
@ -1063,11 +1211,45 @@ 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
|
||||||
|
|
||||||
@ -1088,8 +1270,11 @@ 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:
|
||||||
@ -1098,6 +1283,7 @@ 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(
|
||||||
@ -1112,14 +1298,6 @@ 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:
|
||||||
@ -1609,6 +1787,10 @@ 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
|
||||||
|
|
||||||
@ -1947,6 +2129,26 @@ 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,
|
||||||
@ -1972,6 +2174,7 @@ 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:
|
||||||
@ -2014,6 +2217,8 @@ 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).
|
||||||
|
|||||||
@ -16,6 +16,7 @@ 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
|
||||||
@ -38,6 +39,7 @@ _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.",
|
||||||
},
|
},
|
||||||
"zh-CN": {
|
"zh-CN": {
|
||||||
"goal": "目标:{objective}",
|
"goal": "目标:{objective}",
|
||||||
@ -56,6 +58,7 @@ _TEXT: dict[str, dict[str, str]] = {
|
|||||||
"fetch_failed": "无法获取目标信息。",
|
"fetch_failed": "无法获取目标信息。",
|
||||||
"clear_failed": "无法清除目标。",
|
"clear_failed": "无法清除目标。",
|
||||||
"set_failed": "无法设置目标。",
|
"set_failed": "无法设置目标。",
|
||||||
|
"busy": "当前任务仍在运行,请等待结束后再修改目标。",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,6 +68,7 @@ _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]
|
||||||
|
|
||||||
|
|
||||||
@ -102,6 +106,7 @@ 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"])]
|
||||||
|
|||||||
@ -69,6 +69,7 @@ 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__)
|
||||||
@ -301,6 +302,59 @@ _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}
|
||||||
@ -599,7 +653,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) -> dict[str, Any] | None:
|
def _turn_of_message(values: Any, message_id: str, *, thread_id: str | None = None) -> 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
|
||||||
@ -611,7 +665,7 @@ def _turn_of_message(values: Any, message_id: str) -> dict[str, Any] | None:
|
|||||||
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])),
|
(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)),
|
||||||
len(messages),
|
len(messages),
|
||||||
)
|
)
|
||||||
return {**values, "messages": messages[:end]}
|
return {**values, "messages": messages[:end]}
|
||||||
@ -810,7 +864,7 @@ def _apply_explicit_model_choice(
|
|||||||
carrier["model_name"] = model_name
|
carrier["model_name"] = model_name
|
||||||
|
|
||||||
|
|
||||||
def _extract_response_text(result: dict | list) -> str:
|
def _extract_response_text(result: dict | list, *, thread_id: str | None = None) -> 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``
|
||||||
@ -837,7 +891,7 @@ def _extract_response_text(result: dict | list) -> str:
|
|||||||
|
|
||||||
# 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):
|
if _is_hidden_human_control_message(msg, thread_id=thread_id):
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -1146,7 +1200,7 @@ def _accumulate_stream_text(
|
|||||||
return buffers[message_id], message_id
|
return buffers[message_id], message_id
|
||||||
|
|
||||||
|
|
||||||
def _extract_artifacts(result: dict | list) -> list[str]:
|
def _extract_artifacts(result: dict | list, *, thread_id: str | None = None) -> 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
|
||||||
@ -1167,7 +1221,7 @@ def _extract_artifacts(result: dict | list) -> list[str]:
|
|||||||
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):
|
if _is_hidden_human_control_message(msg, thread_id=thread_id):
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
# Look for AI messages with present_files tool calls
|
# Look for AI messages with present_files tool calls
|
||||||
@ -1181,13 +1235,20 @@ def _extract_artifacts(result: dict | list) -> list[str]:
|
|||||||
return artifacts
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
def _is_hidden_human_control_message(msg: Mapping[str, Any]) -> bool:
|
def _is_hidden_human_control_message(msg: Mapping[str, Any], *, thread_id: str | None = None) -> 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
|
||||||
@ -2311,11 +2372,9 @@ 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:
|
||||||
from langgraph_sdk import get_client
|
self._client = _make_langgraph_client(
|
||||||
|
self._langgraph_url,
|
||||||
self._client = get_client(
|
{**create_internal_auth_headers(), **self._csrf_headers()},
|
||||||
url=self._langgraph_url,
|
|
||||||
headers={**create_internal_auth_headers(), **self._csrf_headers()},
|
|
||||||
)
|
)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
@ -3462,9 +3521,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)
|
response_text = _extract_response_text(result, thread_id=thread_id)
|
||||||
pending_clarification = _has_current_turn_clarification(result)
|
pending_clarification = _has_current_turn_clarification(result)
|
||||||
artifacts = _extract_artifacts(result)
|
artifacts = _extract_artifacts(result, thread_id=thread_id)
|
||||||
|
|
||||||
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",
|
||||||
@ -3517,7 +3576,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)
|
return _turn_of_message(state.get("values") if isinstance(state, dict) else None, message_id, thread_id=thread_id)
|
||||||
|
|
||||||
async def _handle_streaming_chat(
|
async def _handle_streaming_chat(
|
||||||
self,
|
self,
|
||||||
@ -3594,7 +3653,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)
|
clarification_text = _extract_response_text(data, thread_id=thread_id)
|
||||||
if clarification_text and clarification_text != latest_text:
|
if clarification_text and clarification_text != latest_text:
|
||||||
latest_text = clarification_text
|
latest_text = clarification_text
|
||||||
|
|
||||||
@ -3644,9 +3703,9 @@ class ChannelManager:
|
|||||||
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)
|
response_text = _extract_response_text(result, thread_id=thread_id)
|
||||||
pending_clarification = _has_current_turn_clarification(result)
|
pending_clarification = _has_current_turn_clarification(result)
|
||||||
artifacts = _extract_artifacts(result)
|
artifacts = _extract_artifacts(result, thread_id=thread_id)
|
||||||
# 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.
|
||||||
@ -4024,6 +4083,23 @@ 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
|
||||||
|
detail = {
|
||||||
|
"PUT": "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,
|
||||||
@ -4032,7 +4108,7 @@ class ChannelManager:
|
|||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
json: dict[str, Any] | None = None,
|
json: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
async with httpx.AsyncClient() as http:
|
async with _make_gateway_client(self._gateway_url) as http:
|
||||||
request = getattr(http, method.lower())
|
request = getattr(http, method.lower())
|
||||||
kwargs: dict[str, Any] = {"timeout": 10, "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:
|
||||||
@ -4097,7 +4173,9 @@ 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:
|
except Exception as exc:
|
||||||
|
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")
|
||||||
@ -4107,7 +4185,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
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:
|
except Exception as exc:
|
||||||
|
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")
|
||||||
|
|
||||||
@ -4124,7 +4204,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 httpx.AsyncClient() as http:
|
async with _make_gateway_client(self._gateway_url) as http:
|
||||||
resp = await http.get(
|
resp = await http.get(
|
||||||
f"{self._gateway_url}{path}",
|
f"{self._gateway_url}{path}",
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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
|
||||||
@ -34,6 +35,9 @@ 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.
|
||||||
@ -323,14 +327,20 @@ 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)
|
await self._finalize_stream_message(chat_id, msg.chat_id, state, msg.text, msg)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._can_send_rich(msg.text):
|
if self._can_send_rich(msg.text):
|
||||||
@ -340,10 +350,51 @@ 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):
|
||||||
await self._send_new_message(chat_id, msg.chat_id, chunk, _max_retries=_max_retries)
|
message_id = 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.
|
||||||
@ -409,7 +460,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) -> None:
|
async def _finalize_stream_message(self, chat_id: int, chat_key: str, state: dict[str, Any], text: str, msg: OutboundMessage) -> 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 "")
|
||||||
@ -423,10 +474,12 @@ 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.
|
||||||
await self._send_new_message(chat_id, chat_key, chunks[0])
|
message_id = 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:]:
|
||||||
await self._send_new_message(chat_id, chat_key, chunk)
|
message_id = 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."""
|
||||||
@ -545,13 +598,24 @@ 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:
|
||||||
@ -561,6 +625,7 @@ 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:
|
||||||
@ -917,6 +982,24 @@ 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
|
||||||
@ -958,7 +1041,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 'Working on it...' reply and register it as the stream target."""
|
"""Send a best-effort acknowledgment before dispatching inbound work."""
|
||||||
if not self._application:
|
if not self._application:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -32,6 +32,7 @@ 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__)
|
||||||
@ -1083,15 +1084,8 @@ 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
|
||||||
raw_timeout = data.get("longpolling_timeout_ms")
|
timeout_ms = self._coerce_float(data.get("longpolling_timeout_ms"), 0.0)
|
||||||
if raw_timeout is None:
|
if 1_000 <= timeout_ms <= 300_000:
|
||||||
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]:
|
||||||
@ -1707,7 +1701,10 @@ 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)
|
||||||
self._cursor_path.write_text(json.dumps({"get_updates_buf": self._get_updates_buf}, ensure_ascii=False, indent=2), encoding="utf-8", newline="")
|
# Publish through the shared helper: it fsyncs the staged bytes
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
from app.channels.commands import is_known_channel_command, strip_leading_mentions
|
||||||
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,6 +40,10 @@ 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:
|
||||||
@ -434,8 +438,6 @@ 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:
|
||||||
@ -445,7 +447,11 @@ 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"
|
||||||
size_limit = 2 * 1024 * 1024 if attachment.is_image else 20 * 1024 * 1024
|
# aibot media upload limits: image 10 MB, voice 2 MB, video 10 MB,
|
||||||
|
# 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",
|
||||||
@ -581,7 +587,22 @@ class WeComChannel(Channel):
|
|||||||
if handled:
|
if handled:
|
||||||
return
|
return
|
||||||
|
|
||||||
inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
|
# WeCom group chats deliver "@bot /new" with the mention left in the text
|
||||||
|
# (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"),
|
||||||
@ -589,8 +610,12 @@ 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=user_id, # keep user's conversation in memory
|
chat_id=group_chatid or user_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text=text,
|
text=text,
|
||||||
msg_type=inbound_type,
|
msg_type=inbound_type,
|
||||||
@ -598,7 +623,7 @@ class WeComChannel(Channel):
|
|||||||
files=files or [],
|
files=files or [],
|
||||||
metadata=inbound_metadata,
|
metadata=inbound_metadata,
|
||||||
)
|
)
|
||||||
inbound.topic_id = user_id # keep the same thread
|
inbound.topic_id = user_id # one thread per member in each conversation
|
||||||
|
|
||||||
stream_id = generate_req_id("stream")
|
stream_id = generate_req_id("stream")
|
||||||
self._ws_frames[msg_id] = frame
|
self._ws_frames[msg_id] = frame
|
||||||
@ -616,6 +641,8 @@ 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()
|
||||||
@ -627,7 +654,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 = f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}"
|
workspace_id = _group_workspace_id(aibotid, 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
|
||||||
@ -649,6 +676,35 @@ 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
|
||||||
@ -671,7 +727,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 = f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}"
|
workspace_id = _group_workspace_id(aibotid, 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] = {
|
||||||
|
|||||||
@ -1,264 +1,93 @@
|
|||||||
### Gateway API (`app/gateway/`)
|
# Gateway API (`app/gateway/`)
|
||||||
|
|
||||||
Reject external `sandbox`, `thread_data`, `viewed_images`, `goal_outcome`. Non-null goals: valid text, int counters/limits >=0, no Overwrite; keep sparse data.
|
The FastAPI application on port 8001. It serves the REST API, embeds the
|
||||||
|
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.
|
||||||
|
|
||||||
Studio retains sanitized creation metadata.
|
## Verify
|
||||||
|
|
||||||
Capability Center's `business` adapter validates bundled credentials and creates
|
```bash
|
||||||
an MCP connection. The MCP API permits only its isolated launcher and credential
|
cd backend
|
||||||
environment keys; reject arbitrary commands and manifest-policy bypasses. Keep
|
python -m pytest tests -q -k "gateway or router or auth or run_manager"
|
||||||
credentials out of tool schemas and preserve admin checks, masked atomic edits,
|
make test-blocking-io # request handlers share one event loop
|
||||||
and MCP cache reloads.
|
```
|
||||||
|
|
||||||
Memory shutdown resolves hot-reloaded config/backend, flushes and closes in one
|
Read these contracts before you change the matching area:
|
||||||
`await_drained` operation. Resolve config off-loop inside the best-effort handler
|
[thread lifecycle](../../docs/THREAD_LIFECYCLE.md) (branch, regenerate, edit
|
||||||
so malformed edits cannot abort teardown. Cancellation waits for owned workers.
|
replay, archive), [run event stream](../../docs/RUN_EVENT_STREAM.md),
|
||||||
The host sets no close timeout: `close()` must be quick or internally bounded;
|
[run interaction policy](../../docs/RUN_INTERACTION_POLICY.md),
|
||||||
budget it, flush timeout and other hooks within pod grace. Drain extension
|
[auth design](../../docs/AUTH_DESIGN.md), [knowledge](../../docs/KNOWLEDGE.md).
|
||||||
`stop_services()` across cancellation before later resources unwind; each
|
|
||||||
service `stop()` has a 30-second `asyncio.timeout`.
|
|
||||||
|
|
||||||
Routers with persistent writes drain through
|
## Trust boundary
|
||||||
`app.gateway.persistent_writes.run_drained_write`.
|
|
||||||
|
|
||||||
`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.
|
||||||
|
|
||||||
Retention holds durable thread admission and the checkpoint lock before
|
## Runs and threads
|
||||||
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.
|
|
||||||
|
|
||||||
`conversation_access.py` grants the opt-in read-only tool only for explicit
|
- **One writer per thread.** A run, a checkpoint write, an artifact write, a branch
|
||||||
`conversation_references` with effective `runs:read`; never infer grants from
|
and a delete are all thread operations that share one durable uniqueness
|
||||||
messages or checkpoints. Pass the callback through `RunContext`, not serialized
|
constraint. Reserve through `RunManager.reserve_thread_operation()` or
|
||||||
config, and let the worker inject and clear it. `conversation_reader.py` shares
|
`services.reserve_checkpoint_write()`. A separate lock would let two workers
|
||||||
HTTP transcript visibility/pagination without a Request; callers own authorization.
|
write the same thread.
|
||||||
Exclude non-text/internal content. `RunCreateRequest` lifts SDK-compatible
|
- **Several workers.** In-memory run records exist only on the worker that owns the
|
||||||
`context.conversation_references` into its top-level field before validation,
|
run. Cancel, takeover and orphan recovery are compare-and-set operations on the
|
||||||
then removes it from context; supplying both is 422. This shares bounds/error
|
run store; use the store's claim and cancel primitives so that a lease renewal
|
||||||
locations and keeps `merge_run_context_overrides` clear. Admission and
|
between your read and your write is not overwritten. Multi-worker deployments
|
||||||
`/api/features` use `conversation_references_enabled()` as the sole configured
|
need `run_events.backend: db`.
|
||||||
predicate.
|
- **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.
|
||||||
|
|
||||||
FastAPI listens on 8001. `GET /health` is liveness; `GET /health/ready` concurrently
|
## Lifecycle
|
||||||
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`.
|
|
||||||
|
|
||||||
`build_run_config()` resolves the default LangGraph super-step budget from the
|
- **Probe file-backed managed subagent storage once during startup, off the
|
||||||
hot-reloaded top-level `recursion_limit` setting. A valid request-level value
|
event loop.** Probe failures warn operators before their first create request;
|
||||||
takes precedence; invalid values fall back to that default, and
|
atomic create stays fail-closed rather than falling back to an overwrite.
|
||||||
`max_recursion_limit` caps both sources.
|
- **Shutdown order matters.** `lifespan` drains runs and extensions first, then
|
||||||
|
closes MCP, then shuts the sandbox provider down off the event loop. Cleanup code
|
||||||
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.
|
keeps running after cancellation, allocates nothing new, and logs failures
|
||||||
|
instead of raising.
|
||||||
CORS defaults to same-origin behind nginx on 2026. Split-origin and port-forwarded
|
- **Persistent writes in routers** go through
|
||||||
browser clients must set exact `GATEWAY_CORS_ORIGINS`, shared by `CORSMiddleware`
|
`app.gateway.persistent_writes.run_drained_write` so that a client disconnect
|
||||||
and `CSRFMiddleware`. Expose `Content-Location` through `CORS_EXPOSED_HEADERS`
|
cannot leave a half-written file.
|
||||||
(`csrf_middleware.py`): run-creating routes put the run ID there, and the LangGraph
|
- **LangGraph Studio.** `langgraph_studio.py` is loaded by LangGraph's file loader
|
||||||
SDK uses only that header for run metadata. Without exposure, JS cannot read it,
|
before the runtime starts and without a `sys.modules` entry. Keep its
|
||||||
breaking `useStream.onCreated` and thread-gated actions.
|
annotations eager, and identify Studio callers by the upstream `StudioUser`
|
||||||
|
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.
|
|
||||||
|
|||||||
@ -923,11 +923,18 @@ 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:
|
||||||
|
|||||||
@ -77,6 +77,9 @@ _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$")),
|
||||||
|
|||||||
@ -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 admin.
|
"""Raised when a role write would demote the last remaining active 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,8 +123,9 @@ 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
|
||||||
admin (the count and the write share one serialized
|
active 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
|
||||||
|
|
||||||
|
|||||||
@ -316,11 +316,12 @@ 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 system_role != "admin":
|
if row.system_role == "admin" and not row.disabled 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")
|
||||||
|
|||||||
@ -29,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 reset_current_user, set_current_user
|
from deerflow.runtime.user_context import bind_authenticated_user, reset_current_user, set_current_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -207,6 +207,14 @@ 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
|
||||||
@ -226,6 +234,8 @@ 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)
|
||||||
|
|||||||
@ -194,6 +194,11 @@ 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 [
|
||||||
|
|||||||
@ -6,6 +6,26 @@ 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."""
|
||||||
|
|
||||||
|
|||||||
@ -66,7 +66,10 @@ 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:
|
||||||
app_config = get_config()
|
# ``get_config`` revalidates ``config.yaml`` and
|
||||||
|
# ``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
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
@ -57,16 +57,39 @@ def _source_id(reference: str, request_url: str) -> str:
|
|||||||
return validate_thread_id(match.group(1))
|
return validate_thread_id(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
def _visible_text(row: dict) -> tuple[str, str] | None:
|
def _visible_text(row: dict, *, thread_id: str | None = None) -> tuple[str, str] | None:
|
||||||
message = row.get("content")
|
message = row.get("content")
|
||||||
if not isinstance(message, dict):
|
if not isinstance(message, Mapping):
|
||||||
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") or {}
|
extra = message.get("additional_kwargs")
|
||||||
|
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
|
||||||
if str((row.get("metadata") or {}).get("caller", "")).startswith(("middleware:", "subagent:")):
|
metadata = row.get("metadata")
|
||||||
|
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):
|
||||||
@ -228,7 +251,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) if row["seq"] == message_seq else None
|
parsed = _visible_text(row, thread_id=thread_id) 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
|
||||||
@ -273,7 +296,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)
|
parsed = _visible_text(row, thread_id=thread_id)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return False
|
return False
|
||||||
role, text = parsed
|
role, text = parsed
|
||||||
|
|||||||
@ -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
|
from collections.abc import Callable, Mapping
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -70,8 +70,19 @@ 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]) -> bool:
|
def _is_thread_history_hidden_message_row(row: dict[str, Any], *, thread_id: str | None = None) -> bool:
|
||||||
caller = str((row.get("metadata") or {}).get("caller", ""))
|
metadata = row.get("metadata")
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
@ -110,7 +121,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)) 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, thread_id=thread_id)) 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:
|
||||||
@ -137,7 +148,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)) 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, thread_id=thread_id)) 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:
|
||||||
|
|||||||
@ -20,6 +20,7 @@ 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
|
||||||
@ -384,6 +385,27 @@ 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.
|
||||||
|
|
||||||
@ -788,6 +810,8 @@ 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``
|
||||||
@ -865,6 +889,9 @@ 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
|
||||||
@ -898,6 +925,15 @@ 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)
|
||||||
@ -1236,13 +1272,20 @@ 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):
|
def _run_completion_hook(*, scheduled_task_service: Any | None, batch_continuations: Any | None = 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
|
||||||
|
|
||||||
@ -1272,11 +1315,13 @@ 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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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 make_origin
|
from deerflow.runtime.run_origin import DEERFLOW_ORIGIN_KEY, 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,15 +73,32 @@ 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):
|
def __init__(self, host, user_id: str, token_version: int, source: str, permissions: frozenset[str], plugin_namespace: str | None = None, origin: dict | 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:
|
||||||
@ -113,7 +130,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 = make_origin("extension", namespace=self._plugin_namespace)
|
request.state.run_origin = self._origin or 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:
|
||||||
@ -146,13 +163,56 @@ 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 read_artifact(self, *, thread_id, path):
|
async def validate_artifact_source(self, *, thread_id, run_id, idempotency_key):
|
||||||
from app.gateway.extension_artifacts import read_output_snapshot
|
from app.gateway.deps import get_run_manager
|
||||||
|
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):
|
||||||
return await asyncio.to_thread(read_output_snapshot, self._user_id, thread_id, path)
|
if expected_checkpoint_id is not None:
|
||||||
|
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
|
||||||
@ -198,7 +258,8 @@ 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):
|
async def _start(self, *, thread_id, input=None, resume=None, context=None, idempotency_key=None, execution_fence=None, artifact_imports=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
|
||||||
@ -209,9 +270,16 @@ 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")
|
||||||
@ -225,8 +293,30 @@ 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) -> AgentRun:
|
async def start(self, *, thread_id, input, context=None, idempotency_key=None, execution_fence=None, artifact_imports=None) -> AgentRun:
|
||||||
return await self._start(thread_id=thread_id, input=input, context=context, idempotency_key=idempotency_key, execution_fence=execution_fence)
|
return await self._start(thread_id=thread_id, input=input, context=context, idempotency_key=idempotency_key, execution_fence=execution_fence, artifact_imports=artifact_imports)
|
||||||
|
|
||||||
|
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:
|
||||||
@ -245,6 +335,40 @@ 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
|
||||||
|
|
||||||
|
|||||||
@ -1,24 +1,243 @@
|
|||||||
"""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 read_output_snapshot(user_id, thread_id, path):
|
def validate_import_receipts(values: object) -> None:
|
||||||
|
"""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) -> 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/"))
|
||||||
|
return await run_file_io(read_output_snapshot, user_id, thread_id, path)
|
||||||
|
|
||||||
|
|
||||||
|
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) -> 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")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_host_snapshot(user_id: str, thread_id: str, path: str, area: str) -> bytes:
|
||||||
paths = get_paths()
|
paths = get_paths()
|
||||||
root = paths.base_dir.resolve()
|
root = paths.base_dir.resolve()
|
||||||
relative = paths.sandbox_outputs_dir(thread_id, user_id=user_id).relative_to(paths.base_dir)
|
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)
|
||||||
parts = (*relative.parts, *path.removeprefix("/mnt/user-data/outputs/").split("/"))
|
relative = base.relative_to(paths.base_dir)
|
||||||
|
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]:
|
||||||
@ -28,7 +247,7 @@ def read_output_snapshot(user_id, thread_id, path):
|
|||||||
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:
|
if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_BYTES or (area == "uploads" and before.st_nlink != 1):
|
||||||
raise AgentRunError(422, "Artifact must be a regular file of at most 2 MiB")
|
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())
|
||||||
|
|||||||
@ -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, event), []).append(GitHubAgentMatch(user_id=user_id, agent=cfg, trigger=resolved))
|
index.setdefault((binding.repo.lower(), 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, event), [])
|
return registry.get((repo.lower(), event), [])
|
||||||
|
|||||||
@ -81,6 +81,11 @@ 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":
|
||||||
|
|||||||
@ -51,6 +51,7 @@ 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
|
||||||
@ -313,10 +314,7 @@ 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.
|
||||||
"""
|
"""
|
||||||
# Lazy: importing the aio_sandbox package pulls in the whole provider stack.
|
return httpx.AsyncClient(timeout=_PROBE_TIMEOUT_SECONDS, trust_env=control_plane_trust_env(provisioner_url))
|
||||||
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:
|
||||||
|
|||||||
74
backend/app/gateway/history_seed.py
Normal file
74
backend/app/gateway/history_seed.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"""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)
|
||||||
351
backend/app/gateway/hitl_approval.py
Normal file
351
backend/app/gateway/hitl_approval.py
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
"""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")
|
||||||
78
backend/app/gateway/model_discovery.py
Normal file
78
backend/app/gateway/model_discovery.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""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
|
||||||
@ -1,100 +1,52 @@
|
|||||||
# Router contracts
|
# Gateway routers (`app/gateway/routers/`)
|
||||||
|
|
||||||
`GET /api/plugins/diagnostics` uses `require_admin_user` before reading the
|
One module per API area. The parent [Gateway guide](../AGENTS.md) covers trust
|
||||||
canonical runtime diagnostic snapshot. Return bounded entry-point identifiers,
|
boundaries and run admission; this file covers what every route handler has to do.
|
||||||
known levels and fixed summaries; raw messages stay in logs. Never clear the
|
|
||||||
sink or expand PAT permissions.
|
|
||||||
|
|
||||||
Branch metadata inherits valid `assistant_id` values verbatim. Validate with
|
## Verify
|
||||||
`custom_agent_name` and clear invalid legacy values to null in the new row;
|
|
||||||
leave the source record unchanged.
|
|
||||||
|
|
||||||
Console usage attributes legacy rows without a per-model usage map to
|
```bash
|
||||||
`model_name`, including their recorded `total_input_tokens`. Accumulate those
|
cd backend
|
||||||
inputs with newer usage buckets for the same model; do not infer cache hits.
|
python -m pytest tests -q -k "<area>_router" # e.g. threads_router, console_router
|
||||||
Regression coverage lives in `tests/test_console_router.py`.
|
make test-blocking-io # after adding any file, config or network read
|
||||||
|
```
|
||||||
|
|
||||||
Shared scheduled-task update validation preserves an unchanged future `once`
|
## Rules for a route handler
|
||||||
instant on a live task inside `scheduler.min_once_delay_seconds`; new/changed
|
|
||||||
instants and terminal reactivation still enforce the minimum delay.
|
|
||||||
|
|
||||||
Scheduled-task admission rejects nonexistent naive `run_at` and `end_at`
|
- **Authorize first.** Check the session, role or permission before the handler
|
||||||
wall times through the shared Gateway datetime resolver. Keep this check out
|
reads credentials, parses an upload or makes an outbound request. An
|
||||||
of runtime `next_run_at`: legacy tasks retain their accepted execution time,
|
unauthorized caller must not be able to trigger that work.
|
||||||
including unchanged live once definitions. Explicit offsets retain their instant;
|
- **Fixed text in 5xx responses.** Exceptions from storage, config loading and
|
||||||
ambiguous naive times keep the existing fold choice.
|
subprocesses contain host paths. Log the exception on the server and return a
|
||||||
|
fixed detail string.
|
||||||
The MCP task list accepts optional `TaskStatus` and `active_only` query filters.
|
- **No blocking calls on the event loop.** `get_app_config()` stats and hashes the
|
||||||
Pass both to the repository before its limit; preserve `threads:read`, owner
|
config files on every call, and storage backends do file I/O. Call both through
|
||||||
checks, current-thread incarnation scoping, and the unfiltered array response.
|
`asyncio.to_thread`. The blocking-I/O suite fails the build otherwise.
|
||||||
|
- **Writes survive disconnects.** Wrap a persistent write in
|
||||||
Custom Agent packages are strict, versioned definition-only JSON documents.
|
`app.gateway.persistent_writes.run_drained_write`. A cancelled request then
|
||||||
Export explicitly projects the caller's portable `AgentConfig` fields and SOUL;
|
still waits for the worker that owns the write, and no half-written state stays
|
||||||
exclude `github` installation bindings, memory contents, conversations and
|
behind.
|
||||||
credentials. Import may rename locally but must remain owner-scoped and
|
- **Catalog responses are projections.** A listing endpoint returns names and
|
||||||
create-only: collisions return 409, never overwrite or upsert. Ordinary creation
|
display fields. Provider paths, credentials and raw tool or server configuration
|
||||||
and import share `_persist_new_agent`, which uses `run_drained_write` so a
|
stay on the server.
|
||||||
cancelled request still drains the owned persistence worker.
|
- **Provenance comes from the session.** Values that say who or what triggered an
|
||||||
|
action (for example the plugin `ActionContext.interaction`) are set from the
|
||||||
`GET /api/skills`, `/api/skills/custom` and `/api/skills/{name}` serve every
|
authenticated request only. Ignore the same field in a request body.
|
||||||
signed-in user, so their unexpected-failure 500s return a fixed detail. Load
|
- **Unreadable configuration is not default configuration.** When a policy cannot
|
||||||
errors (e.g. a malformed `extensions_config.json`) carry host paths and stay in
|
be read, skip the optional work (cleanup, sweep) and keep serving. Applying a
|
||||||
the server log.
|
default policy can delete data the operator wanted to keep.
|
||||||
|
- **Imports create, they do not overwrite.** Agent and skill imports are scoped to
|
||||||
Thread upload, upload delete and shelf attach-to-thread 500s name the file but
|
the caller and return 409 on a name collision. Exports leave out credentials,
|
||||||
not the cause: staging and unlink `OSError`s carry host paths and stay in the
|
memory, conversations and installation bindings.
|
||||||
server log.
|
- **Memory backup imports** replace the selected facts and require an explicit
|
||||||
|
`facts` array. Omitting it returns 422. An empty array clears that bucket.
|
||||||
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.
|
- **Outbound requests from a route** (model discovery, connection tests) have a
|
||||||
|
time limit and a size limit, do not follow redirects, and return redacted
|
||||||
`GET /api/agent-tool-groups` projects only configured group names for the
|
provider errors.
|
||||||
custom-Agent settings allowlist. Never expose tool provider paths, credentials,
|
- **Locks that several Gateway processes share** are file locks beside the data,
|
||||||
or raw tool configuration through this catalog.
|
taken off the event loop. A process mutex alone does not order writes between
|
||||||
|
workers.
|
||||||
Custom Agent update guards apply legacy shared-file checks only when the
|
- **Path segments from clients.** A name that becomes a directory (agent name,
|
||||||
resolved store is `FileAgentStore`, including the `update_agent` tool. DB
|
skill name, file name) is validated when it is created, including Windows
|
||||||
imports retain source files for rollback; those files cannot veto SQL updates.
|
reserved device names. Read and delete routes stay permissive so that data
|
||||||
Keep store resolution and filesystem checks off-loop in the HTTP route.
|
stored by an older release can still be exported and removed.
|
||||||
Agent PUT uses `preserve_non_managed_fields(..., new_tool_groups=...)` to clear
|
|
||||||
an inherited knowledge default when changed groups remove knowledge access.
|
|
||||||
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`.
|
|
||||||
|
|||||||
@ -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")
|
description: str | None = Field(default=None, description="Updated description; null resets it to empty")
|
||||||
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,7 +601,9 @@ 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,
|
||||||
"description": body.description if "description" in fields_set else agent_cfg.description,
|
# Explicit null resets to the AgentConfig default; storing None
|
||||||
|
# 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
|
||||||
|
|||||||
@ -4,17 +4,21 @@ 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
|
||||||
@ -41,6 +45,8 @@ _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):
|
||||||
@ -334,7 +340,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) -> str:
|
def _sha256_of_file(path: Path, *, pinned: tuple[BinaryIO, os.stat_result] | None = None) -> 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
|
||||||
@ -346,45 +352,217 @@ def _sha256_of_file(path: Path) -> str:
|
|||||||
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).
|
||||||
"""
|
"""
|
||||||
stat = path.stat()
|
file_stat = path.stat() if pinned is None else pinned[1]
|
||||||
# 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.
|
||||||
return _sha256_of_file_cached(
|
key = (
|
||||||
str(path),
|
str(path),
|
||||||
stat.st_dev,
|
file_stat.st_dev,
|
||||||
stat.st_ino,
|
file_stat.st_ino,
|
||||||
stat.st_ctime_ns,
|
file_stat.st_ctime_ns,
|
||||||
stat.st_mtime_ns,
|
file_stat.st_mtime_ns,
|
||||||
stat.st_size,
|
file_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."""
|
||||||
digest = hashlib.sha256()
|
pinned = getattr(_artifact_digest_source, "pinned", None)
|
||||||
|
# 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:
|
||||||
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
return hashlib.file_digest(handle, "sha256").hexdigest()
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest()
|
handle = pinned[1]
|
||||||
|
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-only tags for large files."""
|
"""Keep content revisions for editable files and metadata 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:
|
||||||
return f'"{_sha256_of_file(path)}"'
|
# Populate the shared cache only with metadata from the hashed handle.
|
||||||
|
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.
|
||||||
|
|||||||
@ -13,6 +13,7 @@ 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
|
||||||
|
|
||||||
@ -181,6 +182,14 @@ 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"
|
||||||
@ -228,8 +237,36 @@ 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 int(usage.get("missing_usage_calls") or 0) > 0
|
return _token_count(usage.get("missing_usage_calls")) > 0
|
||||||
|
|
||||||
|
|
||||||
def _has_missing_usage(token_usage_by_model: object) -> bool:
|
def _has_missing_usage(token_usage_by_model: object) -> bool:
|
||||||
@ -263,11 +300,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 = int(usage.get("input_tokens") or 0)
|
input_tokens = _token_count(usage.get("input_tokens"))
|
||||||
output_tokens = int(usage.get("output_tokens") or 0)
|
output_tokens = _token_count(usage.get("output_tokens"))
|
||||||
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, int(usage.get("cache_read_tokens") or 0))
|
cost += _token_cost(input_tokens, output_tokens, price, _token_count(usage.get("cache_read_tokens")))
|
||||||
priced = True
|
priced = True
|
||||||
if priced:
|
if priced:
|
||||||
return cost
|
return cost
|
||||||
@ -511,9 +548,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 += int(usage.get("total_tokens", 0) or 0)
|
entry.tokens += _token_count(usage.get("total_tokens"))
|
||||||
entry.input_tokens += int(usage.get("input_tokens") or 0)
|
entry.input_tokens += _token_count(usage.get("input_tokens"))
|
||||||
entry.cache_read_tokens += int(usage.get("cache_read_tokens") or 0)
|
entry.cache_read_tokens += _token_count(usage.get("cache_read_tokens"))
|
||||||
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())
|
||||||
@ -522,7 +559,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(int(usage.get("input_tokens") or 0), int(usage.get("output_tokens") or 0), price, int(usage.get("cache_read_tokens") or 0))
|
model_cost = _token_cost(_token_count(usage.get("input_tokens")), _token_count(usage.get("output_tokens")), price, _token_count(usage.get("cache_read_tokens")))
|
||||||
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.
|
||||||
|
|||||||
@ -7,6 +7,8 @@ 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
|
||||||
|
|
||||||
@ -82,6 +84,12 @@ 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."""
|
||||||
|
|
||||||
@ -93,6 +101,8 @@ 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(
|
||||||
@ -104,8 +114,11 @@ 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
|
||||||
|
|||||||
@ -14,6 +14,7 @@ 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__)
|
||||||
@ -133,6 +134,7 @@ 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,
|
||||||
@ -141,6 +143,11 @@ 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])
|
||||||
|
|||||||
@ -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_effective_user_id
|
from deerflow.runtime.user_context import get_authenticated_user_id, 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,7 +94,8 @@ 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")
|
||||||
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))
|
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=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")
|
||||||
@ -109,4 +110,5 @@ 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")
|
||||||
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))
|
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=context, page=page, page_size=page_size, search=search))
|
||||||
|
|||||||
@ -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, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, 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,3 +180,100 @@ 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
|
||||||
|
|||||||
@ -15,6 +15,7 @@ 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,
|
||||||
@ -552,29 +553,19 @@ 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:
|
||||||
if isinstance(value, dict):
|
return mask_secret_values(value, _is_sensitive_extra_key)
|
||||||
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:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""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
|
||||||
@ -9,11 +10,13 @@ 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 deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError, MemoryManager, get_memory_manager
|
from app.gateway.secret_masking import mask_secret_values, normalize_config_key
|
||||||
|
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"])
|
||||||
|
|
||||||
@ -117,6 +120,12 @@ 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":
|
||||||
@ -159,6 +168,8 @@ 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.
|
||||||
|
|
||||||
@ -168,6 +179,16 @@ 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
|
||||||
@ -176,6 +197,20 @@ 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,
|
||||||
@ -234,6 +269,18 @@ 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."""
|
||||||
|
|
||||||
@ -242,7 +289,13 @@ 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).")
|
backend_config: dict = Field(..., description="Backend-private config (self-interpreted by the backend); credential values such as api_key are masked as ***.")
|
||||||
|
|
||||||
|
@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):
|
||||||
@ -392,7 +445,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)
|
selected_agent = _management_agent_name_or_501(manager, agent_name, for_creation=True)
|
||||||
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(
|
||||||
@ -519,13 +572,16 @@ 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="Import the default memory document, or replace only one selected agent's facts while preserving shared summaries.",
|
description=(
|
||||||
|
"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: MemoryResponse, request: Request, agent_name: str | None = None) -> MemoryResponse:
|
async def import_memory(body: MemoryImportRequest, 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)
|
selected_agent = _management_agent_name_or_501(manager, agent_name, for_creation=True)
|
||||||
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(
|
||||||
@ -540,6 +596,8 @@ async def import_memory(body: MemoryResponse, request: Request, agent_name: str
|
|||||||
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:
|
||||||
|
|||||||
@ -66,6 +66,7 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
@ -75,6 +76,7 @@ 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))),
|
||||||
|
|||||||
@ -70,6 +70,7 @@ 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
|
||||||
@ -93,8 +94,36 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""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
|
||||||
|
|
||||||
@ -8,9 +9,12 @@ 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__)
|
||||||
@ -45,6 +49,72 @@ 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."""
|
||||||
|
|
||||||
|
|||||||
@ -338,6 +338,20 @@ 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.
|
||||||
|
|
||||||
@ -347,6 +361,7 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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_permission
|
from app.gateway.authz import require_cancel_permission_if, 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,6 +110,7 @@ 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")
|
||||||
@ -122,7 +123,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 can be retried")
|
raise HTTPException(status_code=409, detail="Only failed items of a batch that is not cancelled can be retried")
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,8 @@ 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
|
||||||
@ -57,6 +59,8 @@ 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
|
||||||
@ -70,6 +74,8 @@ 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
|
||||||
@ -82,9 +88,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) -> str:
|
def _validate_path_name(name: str, *, for_creation: bool = False) -> str:
|
||||||
try:
|
try:
|
||||||
return normalize_managed_subagent_name(name)
|
return normalize_managed_subagent_name(name, for_creation=for_creation)
|
||||||
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
|
||||||
|
|
||||||
@ -94,7 +100,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", "skills"):
|
for field in ("timeout_seconds", "max_turns", "model", "thinking_enabled", "reasoning_effort", "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)
|
||||||
@ -119,6 +125,8 @@ 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",
|
||||||
@ -136,6 +144,8 @@ 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",
|
||||||
@ -171,6 +181,7 @@ 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:
|
||||||
|
|||||||
@ -38,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 = llm_text.strip_think_blocks
|
_strip_think_blocks_outside_json_strings = llm_text.strip_think_blocks_outside_json_strings
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_string_list(text: str) -> list[str] | None:
|
def _parse_json_string_list(text: str) -> list[str] | None:
|
||||||
candidate = _strip_think_blocks(text)
|
candidate = _strip_think_blocks_outside_json_strings(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("]")
|
||||||
|
|||||||
@ -16,6 +16,7 @@ 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
|
||||||
@ -54,6 +55,7 @@ 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,
|
||||||
@ -64,11 +66,13 @@ 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
|
||||||
@ -81,6 +85,50 @@ 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.
|
||||||
@ -181,7 +229,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]) -> None:
|
def stamp_turn_duration_on_last_ai(messages, run_durations: dict[str, int], *, thread_id: str | None = None) -> 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``),
|
||||||
@ -209,10 +257,37 @@ def stamp_turn_duration_on_last_ai(messages, run_durations: dict[str, int]) -> N
|
|||||||
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") or {}
|
metadata = msg.get("metadata")
|
||||||
|
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:
|
||||||
payload.setdefault("additional_kwargs", {})["turn_duration"] = run_durations[rid]
|
raw_additional_kwargs = payload.get("additional_kwargs")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@ -753,9 +828,19 @@ 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,
|
||||||
@ -832,6 +917,7 @@ 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")
|
||||||
@ -1544,7 +1630,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)
|
stamp_turn_duration_on_last_ai(messages, run_durations, thread_id=thread_id)
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
@ -1613,7 +1699,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)
|
stamp_turn_duration_on_last_ai(data, run_durations, thread_id=thread_id)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@ -1678,7 +1764,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)
|
stamp_turn_duration_on_last_ai(data, durations, thread_id=thread_id)
|
||||||
|
|
||||||
return {"data": data, "has_more": has_more}
|
return {"data": data, "has_more": has_more}
|
||||||
|
|
||||||
@ -1735,8 +1821,15 @@ 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
|
||||||
presented = by_tool.get("present_files") if isinstance(by_tool, dict) else None
|
if not isinstance(by_tool, dict):
|
||||||
if not isinstance(presented, list) or 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")
|
||||||
|
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
|
||||||
|
|
||||||
@ -1854,12 +1947,18 @@ 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:
|
||||||
types = event_types.split(",")
|
# Split on commas and normalise each entry: the value goes straight into
|
||||||
|
# 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(
|
||||||
|
|||||||
@ -38,6 +38,8 @@ from app.gateway.checkpoint_lineage import (
|
|||||||
resolve_stamp_candidate_versions,
|
resolve_stamp_candidate_versions,
|
||||||
)
|
)
|
||||||
from app.gateway.deps import get_checkpointer, 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,
|
||||||
@ -63,7 +65,7 @@ from deerflow.runtime.context_compaction import (
|
|||||||
ThreadCompactionResult,
|
ThreadCompactionResult,
|
||||||
compact_thread_context,
|
compact_thread_context,
|
||||||
)
|
)
|
||||||
from deerflow.runtime.context_keys import checkpoint_agent_binding_metadata
|
from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY, 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,
|
||||||
@ -80,6 +82,7 @@ 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
|
||||||
@ -126,6 +129,13 @@ 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"
|
||||||
@ -569,7 +579,7 @@ class ThreadPatchRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ThreadStateUpdateRequest(BaseModel):
|
class ThreadStateUpdateRequest(BaseModel):
|
||||||
"""Request body for updating thread state (human-in-the-loop resume)."""
|
"""Request body for updating thread state (checkpoint mutation, not a resume API)."""
|
||||||
|
|
||||||
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")
|
||||||
@ -622,10 +632,13 @@ 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):
|
||||||
@ -804,6 +817,13 @@ 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
|
||||||
@ -822,14 +842,15 @@ 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))
|
||||||
|
|
||||||
# Remove checkpoints (best-effort)
|
# Retain ownership if a configured checkpointer cannot delete the thread.
|
||||||
|
# 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:
|
except Exception as exc:
|
||||||
logger.debug("Could not delete checkpoints for thread %s (not critical)", sanitize_log_param(thread_id))
|
logger.exception("Failed to delete checkpoints for thread %s", 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
|
||||||
@ -842,6 +863,16 @@ 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
|
||||||
@ -965,11 +996,17 @@ 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 {}
|
||||||
@ -1006,17 +1043,25 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
|
|||||||
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
|
||||||
# concurrent request for the same thread_id can commit in between, so
|
# another worker's 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. The memory
|
# path does — instead of surfacing the conflict as a 500. Same-process
|
||||||
# store serializes same-id creates under its per-thread lock and keeps
|
# HTTP creates share the lifecycle lock; the memory store retains its
|
||||||
# its historical overwrite behavior, so it does not reach this branch.
|
# historical overwrite behavior for direct callers.
|
||||||
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)
|
||||||
# A duplicate-key error with no row we can read back is a real failure.
|
# The same-owner reconciliation above found nothing, yet the insert hit
|
||||||
|
# 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:
|
||||||
@ -1027,12 +1072,14 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
|
|||||||
# 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, {})
|
||||||
@ -1221,9 +1268,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) -> None:
|
async def _write_branch_row(project_id: str | None) -> dict:
|
||||||
try:
|
try:
|
||||||
await thread_store.create(
|
return 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,
|
||||||
@ -1238,13 +1285,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:
|
||||||
await _write_branch_row(source_project_id)
|
branch_record = 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.
|
||||||
await _write_branch_row(None)
|
branch_record = 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
|
||||||
@ -1254,6 +1301,7 @@ 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,
|
||||||
@ -1261,7 +1309,9 @@ async def _branch_thread_with_reservation(
|
|||||||
parent_thread_id=thread_id,
|
parent_thread_id=thread_id,
|
||||||
)
|
)
|
||||||
if seed_events:
|
if seed_events:
|
||||||
await get_run_event_store(request).put_batch(seed_events)
|
event_store = get_run_event_store(request)
|
||||||
|
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"
|
||||||
@ -1472,6 +1522,7 @@ 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()},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -1497,11 +1548,21 @@ 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:
|
||||||
@ -1519,9 +1580,19 @@ async def set_thread_goal(thread_id: ThreadId, body: ThreadGoalRequest, request:
|
|||||||
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:
|
||||||
@ -1554,11 +1625,14 @@ 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, _ = build_checkpoint_state_mutation_accessor(
|
accessor, read_config = 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
|
||||||
@ -1593,6 +1667,32 @@ 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)
|
||||||
@ -1616,13 +1716,12 @@ 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_raw = snapshot.tasks or ()
|
tasks = _pending_interrupt_tasks(snapshot.tasks or (), pending_interrupts)
|
||||||
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")
|
||||||
@ -1648,7 +1747,9 @@ 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 {}))
|
||||||
@ -1712,6 +1813,7 @@ 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,
|
||||||
@ -1728,6 +1830,8 @@ 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")
|
||||||
@ -1996,6 +2100,7 @@ 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
|
||||||
|
|
||||||
@ -2046,6 +2151,7 @@ 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:
|
||||||
@ -2061,10 +2167,14 @@ async def get_thread_history(
|
|||||||
serialized_msgs,
|
serialized_msgs,
|
||||||
)
|
)
|
||||||
|
|
||||||
is_latest_checkpoint = False
|
|
||||||
|
|
||||||
next_tasks = list(snapshot.next or ())
|
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
|
||||||
# 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
|
||||||
@ -2079,6 +2189,7 @@ 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:
|
||||||
|
|||||||
@ -14,6 +14,7 @@ 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
|
||||||
@ -103,13 +104,10 @@ class PurgeResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _projects_config() -> ProjectsConfig:
|
def _projects_config() -> ProjectsConfig:
|
||||||
"""Projects config, falling back to defaults when the app config is unavailable."""
|
"""Read the retention policy; an unavailable config must not shorten it."""
|
||||||
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:
|
||||||
@ -149,10 +147,11 @@ 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=_projects_config().trash_retention_days,
|
retention_days=config.trash_retention_days,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
include_reconciliation=_reconciliation_due(user_id),
|
include_reconciliation=_reconciliation_due(user_id),
|
||||||
)
|
)
|
||||||
|
|||||||
193
backend/app/gateway/run_inputs.py
Normal file
193
backend/app/gateway/run_inputs.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
"""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_current_user, get_run_context, 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_run_context(request).agent_runs 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_run_context(request).agent_runs
|
||||||
|
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
|
||||||
@ -26,7 +26,6 @@ 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
|
||||||
@ -62,99 +61,6 @@ _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 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@ -545,21 +451,15 @@ 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]:
|
||||||
if not _has_explicit_trial_request(self._original_user_text, task):
|
# Natural-language wording is not an execution authorization protocol.
|
||||||
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")
|
# The authenticated task-card/page button owns manual dispatch. This
|
||||||
result = await self._service.dispatch_task(task, now=datetime.now(UTC), trigger="manual")
|
# capability has already checked owner, conversation and permissions.
|
||||||
outcome = result.get("outcome")
|
return self._result(
|
||||||
error = result.get("error") or "Scheduled task trial could not be dispatched"
|
"trial",
|
||||||
if outcome == "not_found":
|
task=await self._view(task),
|
||||||
raise scheduler_error(404, "task_not_found", error)
|
requires_user_action="run_once_now",
|
||||||
if outcome == "conflict":
|
instruction="No trial has started. Use the task card's Run once now button, or open /workspace/scheduled-tasks and run this task there.",
|
||||||
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"]
|
||||||
|
|||||||
30
backend/app/gateway/secret_masking.py
Normal file
30
backend/app/gateway/secret_masking.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""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
|
||||||
@ -30,7 +30,8 @@ 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_stream_bridge
|
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.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,
|
||||||
@ -105,7 +106,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 reset_current_user, set_current_user
|
from deerflow.runtime.user_context import get_current_user, 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
|
||||||
@ -157,6 +158,7 @@ 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,
|
||||||
@ -164,6 +166,8 @@ _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
|
||||||
@ -200,6 +204,10 @@ _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
|
||||||
@ -261,6 +269,18 @@ 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:
|
||||||
@ -550,15 +570,17 @@ 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.
|
||||||
source_kwargs = item.get("additional_kwargs")
|
raw_kwargs = item.get("additional_kwargs")
|
||||||
source_kwargs = source_kwargs if isinstance(source_kwargs, dict) else {}
|
source_kwargs = raw_kwargs if isinstance(raw_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 == source_kwargs:
|
if additional_kwargs == raw_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}
|
||||||
|
|
||||||
|
|
||||||
@ -621,7 +643,63 @@ 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"})
|
SERVER_OWNED_STATE_CHANNELS = frozenset({"sandbox", "thread_data", "viewed_images", "goal_outcome", "applied_run_inputs"})
|
||||||
|
|
||||||
|
#: 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 _normalize_external_goal(value: Any, *, location: str) -> dict[str, Any] | None:
|
def _normalize_external_goal(value: Any, *, location: str) -> dict[str, Any] | None:
|
||||||
@ -639,7 +717,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 {**value, "objective": objective}
|
return {**{key: item for key, item in value.items() if key != "last_batch_wakeup_run_id"}, "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]:
|
||||||
@ -668,10 +746,19 @@ 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 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:
|
||||||
@ -740,9 +827,22 @@ 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")}
|
||||||
# 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:
|
||||||
result = {**result, "goal": _normalize_external_goal(result["goal"], location="input.goal"), "goal_outcome": None}
|
normalized_goal = _normalize_external_goal(result["goal"], location="input.goal")
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@ -784,11 +884,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 that cross run admission.
|
"""Persist the same normalized messages, goal, and todos 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 there would preserve a non-canonical scope even though the graph
|
message or state metadata there would preserve a non-canonical scope even
|
||||||
receives the validated form.
|
though the graph receives the validated form.
|
||||||
"""
|
"""
|
||||||
if not isinstance(graph_input, dict):
|
if not isinstance(graph_input, dict):
|
||||||
return raw_input
|
return raw_input
|
||||||
@ -796,6 +896,9 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@ -868,6 +971,45 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
@ -880,6 +1022,7 @@ _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",
|
||||||
@ -918,6 +1061,8 @@ _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,
|
||||||
@ -1308,11 +1453,17 @@ def _clamp_recursion_limit(value: Any, max_limit: int, default_limit: int) -> in
|
|||||||
return min(value, max_limit)
|
return min(value, max_limit)
|
||||||
|
|
||||||
|
|
||||||
def custom_agent_name(assistant_id: str | None) -> str | None:
|
# Error details echo caller-supplied agent names; bound them so an oversized
|
||||||
|
# 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.
|
then contain only letters, digits and hyphens. *field* names the request
|
||||||
|
field in the error message.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: *assistant_id* cannot name an agent.
|
ValueError: *assistant_id* cannot name an agent.
|
||||||
@ -1321,7 +1472,7 @@ def custom_agent_name(assistant_id: str | None) -> str | None:
|
|||||||
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 assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.")
|
raise ValueError(f"Invalid {field} {assistant_id!r:.{_MAX_ECHOED_AGENT_NAME_CHARS}}: must contain only letters, digits, and hyphens after normalization.")
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
@ -1906,24 +2057,30 @@ 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 = {
|
checkpoint_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||||
"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
|
||||||
|
|
||||||
accessor, config = await abuild_checkpoint_state_accessor(
|
async def checkpoint_messages() -> list:
|
||||||
request,
|
accessor, config = await abuild_checkpoint_state_accessor(request, thread_id=thread_id, assistant_id=assistant_id)
|
||||||
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
|
||||||
if not isinstance(messages, list) or not messages:
|
return messages if isinstance(messages, list) else []
|
||||||
|
|
||||||
|
# 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(
|
||||||
@ -1933,7 +2090,7 @@ async def ensure_checkpoint_history_seeded(
|
|||||||
)
|
)
|
||||||
if not events:
|
if not events:
|
||||||
return
|
return
|
||||||
await event_store.put_batch(events)
|
await write_history_seed(request, events, reservation=reservation, seed_kind="checkpoint", assistant_id=assistant_id)
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@ -2048,20 +2205,30 @@ 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
|
||||||
try:
|
# The caller's config/context ``agent_name`` arrives here unvalidated, and
|
||||||
# The caller's config/context ``agent_name`` arrives here unvalidated.
|
# every run that names a custom agent passes through, with or without a
|
||||||
|
# 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 ValueError("agent_name must be a string")
|
raise HTTPException(
|
||||||
normalized = assistant_id.strip().lower().replace("_", "-")
|
status_code=422,
|
||||||
|
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, ValueError) as exc:
|
except FileNotFoundError 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="knowledge_scope assistant configuration could not be resolved",
|
detail=f"Agent '{normalized:.{_MAX_ECHOED_AGENT_NAME_CHARS}}' configuration could not be loaded",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@ -2185,6 +2352,7 @@ async def start_run(
|
|||||||
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_
|
||||||
|
|
||||||
@ -2257,10 +2425,43 @@ 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=get_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:
|
||||||
# LangGraph continues pending work with None; {} starts new input.
|
if admitted_run is None:
|
||||||
|
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
|
||||||
@ -2306,6 +2507,12 @@ 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(get_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.
|
||||||
@ -2599,6 +2806,28 @@ 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,
|
||||||
@ -2614,6 +2843,14 @@ 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):
|
||||||
@ -2651,8 +2888,15 @@ 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:
|
||||||
record = await run_mgr.create_or_reject(
|
if admitted_run is None and not (command and command.get("resume") is not None):
|
||||||
|
# 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,
|
||||||
@ -2687,6 +2931,8 @@ 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")
|
||||||
@ -2713,13 +2959,48 @@ 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
|
||||||
|
else:
|
||||||
return record
|
return record
|
||||||
|
|
||||||
worker = run_after_metadata(record)
|
worker = run_after_metadata(record)
|
||||||
|
answered_approval = None
|
||||||
try:
|
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)
|
||||||
|
answered_approval = await mark_approval_answered(
|
||||||
|
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.
|
||||||
# Keep the admission-to-task-attachment window await-free so
|
# Once resume persistence succeeds, keep task attachment
|
||||||
# cancellation cannot strand a pending row.
|
# await-free so 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(
|
||||||
@ -2732,12 +3013,28 @@ async def start_run(
|
|||||||
record.task,
|
record.task,
|
||||||
)
|
)
|
||||||
producer_lease_handed_off = True
|
producer_lease_handed_off = True
|
||||||
except Exception as exc:
|
except BaseException 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=f"Failed to attach run worker: {exc}",
|
error=attachment_error,
|
||||||
)
|
)
|
||||||
|
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
|
||||||
@ -2849,6 +3146,7 @@ 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,
|
||||||
@ -2857,6 +3155,14 @@ 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}
|
||||||
|
|
||||||
|
|
||||||
@ -2941,6 +3247,9 @@ 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}:
|
||||||
|
|||||||
105
backend/app/gateway/tool_approvals.py
Normal file
105
backend/app/gateway/tool_approvals.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
"""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}
|
||||||
@ -1689,11 +1689,60 @@ 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
|
||||||
@ -1710,9 +1759,19 @@ 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():
|
||||||
|
|||||||
@ -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 next_run_at, should_advance_schedule_cursor
|
from deerflow.scheduler.schedules import pauses_unschedulable, should_advance_schedule_cursor, stored_next_run_at
|
||||||
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,10 +450,15 @@ 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:
|
||||||
@ -475,7 +480,17 @@ 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:
|
||||||
@ -530,22 +545,39 @@ class ScheduledTaskService:
|
|||||||
*,
|
*,
|
||||||
trigger: str,
|
trigger: str,
|
||||||
now: datetime,
|
now: datetime,
|
||||||
) -> datetime | str | None:
|
) -> tuple[datetime | str | None, 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")
|
return task.get("next_run_at"), None
|
||||||
return next_run_at(
|
return stored_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],
|
||||||
@ -570,15 +602,21 @@ 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.
|
||||||
task_status = self._task_status_for_failure(task, trigger=trigger)
|
next_at, schedule_error = self._next_run_at_after_launch(task, trigger=trigger, now=now)
|
||||||
|
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=self._next_run_at_after_launch(task, trigger=trigger, now=now),
|
next_run_at=next_at,
|
||||||
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=str(exc),
|
last_error=last_error,
|
||||||
increment_run_count=False,
|
increment_run_count=False,
|
||||||
expected_lease_owner=expected_lease_owner,
|
expected_lease_owner=expected_lease_owner,
|
||||||
)
|
)
|
||||||
@ -736,8 +774,13 @@ 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 = self._next_run_at_after_launch(task, trigger=trigger, now=now)
|
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,
|
||||||
|
)
|
||||||
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"],
|
||||||
@ -751,7 +794,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=None,
|
last_error=last_error,
|
||||||
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
|
||||||
@ -774,8 +817,6 @@ 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
|
||||||
@ -786,7 +827,13 @@ 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.
|
||||||
task_status = self._task_status_for_launch(task, trigger=trigger, next_at=next_at)
|
next_at, schedule_error = 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),
|
||||||
|
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,
|
||||||
@ -816,7 +863,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=None,
|
last_error=last_error,
|
||||||
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,
|
||||||
|
|||||||
144
backend/app/subagent_batches/continuation.py
Normal file
144
backend/app/subagent_batches/continuation.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"""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"])
|
||||||
@ -188,6 +188,11 @@ 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
|
||||||
{
|
{
|
||||||
@ -351,7 +356,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` 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` 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.
|
||||||
- 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
|
||||||
@ -1458,7 +1463,7 @@ DELETE /api/threads/{thread_id}/uploads/{filename}
|
|||||||
|
|
||||||
### Thread Cleanup
|
### Thread Cleanup
|
||||||
|
|
||||||
Remove DeerFlow-managed local thread files under `.deer-flow/threads/{thread_id}` after the LangGraph thread itself has been deleted.
|
Delete a thread's checkpoint data and DeerFlow-managed files and metadata.
|
||||||
|
|
||||||
```http
|
```http
|
||||||
DELETE /api/threads/{thread_id}
|
DELETE /api/threads/{thread_id}
|
||||||
@ -1475,6 +1480,14 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@ -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 (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.
|
**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.
|
||||||
|
|
||||||
### Agent Architecture
|
### Agent Architecture
|
||||||
|
|
||||||
|
|||||||
@ -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;服务端通过内部认证头把请求归入 `default` 用户桶。
|
**预期:** 无 auth 相关错误。渠道不依赖浏览器 cookie;服务端通过内部认证头接受请求,run 归入绑定 owner 的用户桶(未绑定时为规范化后的平台用户 ID;仅 `DEER_FLOW_AUTH_DISABLED=1` 时为 `default`)。
|
||||||
|
|
||||||
#### TC-DOCKER-05: reset_admin 密码写入 0600 凭证文件(不再走日志)
|
#### TC-DOCKER-05: reset_admin 密码写入 0600 凭证文件(不再走日志)
|
||||||
|
|
||||||
|
|||||||
@ -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 内部认证通信,使用 `default` 用户桶
|
- **IM 渠道**(Feishu/Slack/Telegram):通过 Gateway 内部认证通信。已通过 `/connect` 绑定的发送者使用绑定 owner 的用户桶,未绑定的发送者使用规范化后的平台用户 ID;只有设置 `DEER_FLOW_AUTH_DISABLED=1` 的单用户模式才统一使用 `default` 用户桶。详见 [IM_CHANNEL_CONNECTIONS.md](IM_CHANNEL_CONNECTIONS.md)
|
||||||
- **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响
|
- **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响
|
||||||
|
|
||||||
## 故障排查
|
## 故障排查
|
||||||
|
|||||||
64
backend/docs/BATCH_CONTINUATION.md
Normal file
64
backend/docs/BATCH_CONTINUATION.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# 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.
|
||||||
@ -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` | Chat Completions / Bearer |
|
| `openai` | OpenAI API | `https://api.openai.com/v1` | Responses for the GPT-6 IDs below; otherwise 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,6 +334,7 @@ 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
|
||||||
@ -342,6 +343,47 @@ 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
|
||||||
@ -813,7 +855,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
|
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 down
|
scripts/dev_multi_instance.sh down
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -891,6 +933,16 @@ 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
|
||||||
@ -1262,8 +1314,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, You.com)
|
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Serper, Serply, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute, Sofya, SpecStream, You.com)
|
||||||
- `web_fetch` - Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless, Sofya, Unbrowse)
|
- `web_fetch` - Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless, Sofya, SpecStream, 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
|
||||||
@ -1330,6 +1382,14 @@ 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:
|
||||||
@ -1596,6 +1656,7 @@ 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
|
||||||
@ -1604,6 +1665,10 @@ 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
|
||||||
@ -1931,7 +1996,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](../../README.md#option-2-local-development)
|
These are path examples; the [local development prerequisites](../../docs/guide/deployment.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
|
||||||
@ -2035,6 +2100,7 @@ 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`)
|
||||||
@ -2212,6 +2278,92 @@ 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
|
||||||
|
|||||||
@ -187,6 +187,16 @@ 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
|
||||||
@ -828,6 +838,12 @@ 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
|
||||||
|
|||||||
@ -51,6 +51,32 @@ may retain `search(query)` when it uses an operator-owned connection with a
|
|||||||
fixed resource visibility. User/tenant mappings and resource authorization
|
fixed resource visibility. User/tenant mappings and resource authorization
|
||||||
remain provider responsibilities; DeerFlow does not infer tenants from client
|
remain provider responsibilities; DeerFlow does not infer tenants from client
|
||||||
metadata or create a second ACL store.
|
metadata or create a second ACL store.
|
||||||
|
The optional `authenticated_user_id` field carries separate host authentication
|
||||||
|
provenance and defaults to `None`. Gateway binds it only for verified sessions
|
||||||
|
and PATs, never internal-service or auth-disabled synthetic identities. Generic
|
||||||
|
retrieval tools and catalog routes read it from a dedicated task-local binding;
|
||||||
|
client flags, runtime `user_id`, saved assistant configuration and storage owner
|
||||||
|
restoration cannot create it. Child tasks, threads and ordinary isolated
|
||||||
|
subagents launched from an authenticated execution inherit its proof; a
|
||||||
|
lost/fresh context denies shared access. Durable batch items are launched from
|
||||||
|
the Gateway's startup poller and do not inherit the submitting request's proof,
|
||||||
|
even within the same process. Like scheduled-task and IM-channel runs, they
|
||||||
|
cannot use authenticated shared retrieval merely by carrying an owner `user_id`.
|
||||||
|
Trusted embedding hosts may use `bind_authenticated_user()` from
|
||||||
|
`deerflow.runtime.user_context` only after authenticating the end user, and must
|
||||||
|
bind `None` for non-user callers.
|
||||||
|
The bound value must be exactly the same nonempty string that
|
||||||
|
`resolve_runtime_user_id()` returns for the execution. LangGraph Server
|
||||||
|
identities from `runtime.server_info.user.identity` or
|
||||||
|
`configurable.langgraph_auth_user_id` take precedence over runtime context and
|
||||||
|
are normalized with `make_safe_user_id()`. A trusted embedding host must bind
|
||||||
|
that same resolved ID after authentication, rather than a raw identity such as
|
||||||
|
an email address. Binding a different representation denies shared access;
|
||||||
|
providers must not normalize the two values independently to bypass the mismatch.
|
||||||
|
Providers opting into shared access must require a nonempty exact match with
|
||||||
|
`context.user_id` and still enforce their connection/resource/Agent policy.
|
||||||
|
The WeKnora extension's explicit `access_mode: authenticated` uses this field
|
||||||
|
for dynamic-user shared retrieval; its default exact-user mode is unchanged.
|
||||||
`agent_name` identifies the current executor: the selected assistant name,
|
`agent_name` identifies the current executor: the selected assistant name,
|
||||||
defaulting to `lead_agent`, or the executing subagent's configured name from
|
defaulting to `lead_agent`, or the executing subagent's configured name from
|
||||||
its server runtime `agent_id`. It does not identify the parent assistant.
|
its server runtime `agent_id`. It does not identify the parent assistant.
|
||||||
@ -200,9 +226,13 @@ Inline shared references accept both the raw `#knowledge-source-ID` and the
|
|||||||
sanitized `#user-content-knowledge-source-ID`; the sanitizer's DOM-clobbering
|
sanitized `#user-content-knowledge-source-ID`; the sanitizer's DOM-clobbering
|
||||||
protection stays enabled. Missing or malformed references render inert labels.
|
protection stays enabled. Missing or malformed references render inert labels.
|
||||||
|
|
||||||
`format_result` budgets chunk text and source evidence together: a cited hit
|
`format_result` budgets chunk text and source evidence together: a hit
|
||||||
that cannot fit is omitted as a whole. Results display at most 100 hits, matching
|
that cannot fit is omitted as a whole, and later fitting hits remain eligible.
|
||||||
the bounded artifact readers. Only displayed excerpts enter artifacts.
|
It examines only the first 100 input hits in provider order, including skipped
|
||||||
|
hits. Per-chunk truncation precedes packing; the remaining total budget does
|
||||||
|
not shorten a retained excerpt or change its source ID. Only displayed excerpts
|
||||||
|
enter artifacts. Omission notices use leftover space rather than displacing
|
||||||
|
complete evidence that fits.
|
||||||
The global tool-output budget prunes evidence removed by truncation or
|
The global tool-output budget prunes evidence removed by truncation or
|
||||||
externalization and replaces complete orphan citations with their labels.
|
externalization and replaces complete orphan citations with their labels.
|
||||||
Subagents accumulate at most 100 validated sources across values frames before
|
Subagents accumulate at most 100 validated sources across values frames before
|
||||||
|
|||||||
@ -163,10 +163,7 @@ Use `routing` when an MCP server should be preferred for specific requests, such
|
|||||||
as internal database questions that should use a PostgreSQL MCP tool before web
|
as internal database questions that should use a PostgreSQL MCP tool before web
|
||||||
search. Routing hints are soft model guidance: they add a
|
search. Routing hints are soft model guidance: they add a
|
||||||
`<mcp_routing_hints>` prompt section, but they do not forbid other tools. Use
|
`<mcp_routing_hints>` prompt section, but they do not forbid other tools. Use
|
||||||
agent-level allow/deny policy for hard restrictions. If `tool_search.enabled`
|
agent-level allow/deny policy for hard restrictions. When deferred schemas are enabled, the model uses tool_search to select a tool explicitly. Keyword mentions, quotes and negation do not automatically promote schemas. The legacy auto_promote_top_k setting is accepted for configuration compatibility and has no automatic effect.
|
||||||
defers MCP tool schemas, matching routing metadata can also auto-promote the
|
|
||||||
deferred schema before the model call. Auto-promotion is controlled by the
|
|
||||||
top-level `config.yaml -> tool_search.auto_promote_top_k` setting.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -197,21 +194,15 @@ top-level `config.yaml -> tool_search.auto_promote_top_k` setting.
|
|||||||
|
|
||||||
- `routing.mode`: `off` disables hints; `prefer` emits hints.
|
- `routing.mode`: `off` disables hints; `prefer` emits hints.
|
||||||
- `routing.priority`: `0` to `100`; higher-priority hints are rendered first.
|
- `routing.priority`: `0` to `100`; higher-priority hints are rendered first.
|
||||||
When `tool_search.enabled=true`, priority also orders auto-promote matches.
|
This ordering does not select or authorize tools.
|
||||||
- `routing.keywords`: operator-authored terms that describe when to prefer the
|
- routing.keywords: operator-authored capability descriptions for advisory hints. They are not matched against conversation text to select a tool or infer user intent. Empty keywords emit no hint line.
|
||||||
MCP tool. Empty keywords are allowed but do not emit a hint line and do not
|
|
||||||
trigger auto-promotion. Auto-promote matching is a case-insensitive substring
|
|
||||||
test against the latest user message (not token/word-boundary matching), so
|
|
||||||
prefer distinctive keywords — a short term like `api` also matches `rapid`.
|
|
||||||
Over-matching only exposes an extra tool schema (soft/additive), never
|
|
||||||
disables other tools.
|
|
||||||
- `tools.<original_tool_name>.routing`: overrides only the fields explicitly
|
- `tools.<original_tool_name>.routing`: overrides only the fields explicitly
|
||||||
set for that tool. The key is the MCP server's original tool name, before the
|
set for that tool. The key is the MCP server's original tool name, before the
|
||||||
`<server>_` prefix added for model binding. If the server-level
|
`<server>_` prefix added for model binding. If the server-level
|
||||||
`routing.mode` is `off`, a tool override must set `mode: "prefer"`; setting
|
`routing.mode` is `off`, a tool override must set `mode: "prefer"`; setting
|
||||||
only `priority` or `keywords` still inherits `off` and emits no hint.
|
only `priority` or `keywords` still inherits `off` and emits no hint.
|
||||||
- `tool_search.auto_promote_top_k`: global limit for auto-promoted deferred MCP
|
- tool_search.auto_promote_top_k: legacy setting; default 3 and accepted range 1..5 remain compatible, but do not expose deferred schemas automatically.
|
||||||
schemas per model call. Default `3`; valid range `1..5`.
|
|
||||||
|
|
||||||
## Tool Name Prefixes
|
## Tool Name Prefixes
|
||||||
|
|
||||||
|
|||||||
@ -74,6 +74,15 @@ into a normal assistant error message), so the Layer-1 golden can't catch a miss
|
|||||||
by shape alone — it inspects `replay_provider.replay_misses()` and fails loud
|
by shape alone — it inspects `replay_provider.replay_misses()` and fails loud
|
||||||
instead. Layer-2 already fails on a miss (the recorded turns never render).
|
instead. Layer-2 already fails on a miss (the recorded turns never render).
|
||||||
|
|
||||||
|
The `write_read_file.ultra.json` fixture includes a transport-encoding migration
|
||||||
|
for the request-only tool-result JSON envelope. Its two affected lead-agent input
|
||||||
|
and conversation hashes were recomputed after checking that decoding the envelope
|
||||||
|
exactly matched the original recorded inputs. All recorded assistant outputs,
|
||||||
|
title/suggestion entries, and the SSE golden are unchanged. This is an offline
|
||||||
|
fixture migration, not a new live recording or evidence of model behavior under
|
||||||
|
the new encoding; normal replay still requires exact hashes, including tool
|
||||||
|
content and execution status.
|
||||||
|
|
||||||
## Record a new scenario (needs a real key — dev machine only)
|
## Record a new scenario (needs a real key — dev machine only)
|
||||||
|
|
||||||
Recording drives the **real frontend** so captured inputs match exactly what the
|
Recording drives the **real frontend** so captured inputs match exactly what the
|
||||||
|
|||||||
@ -5,6 +5,8 @@ as the single source for lead-agent tool visibility, clarification middleware
|
|||||||
behavior, sandbox network approval eligibility, and system-prompt guidance.
|
behavior, sandbox network approval eligibility, and system-prompt guidance.
|
||||||
The implementation lives in `../packages/harness/deerflow/agents/interaction_policy.py`.
|
The implementation lives in `../packages/harness/deerflow/agents/interaction_policy.py`.
|
||||||
|
|
||||||
|
Interactive runs inspect available context and proceed with reversible assumptions or independent work when appropriate. Ask when essential information, consequential choices, or authorization for high-risk work is missing; a fixed clarify-plan-act sequence is not required. Clarification interrupts and trust boundaries remain unchanged.
|
||||||
|
|
||||||
## Modes and precedence
|
## Modes and precedence
|
||||||
|
|
||||||
Interactive runs may ask a human for clarification. Runs in `scheduled`,
|
Interactive runs may ask a human for clarification. Runs in `scheduled`,
|
||||||
@ -34,3 +36,52 @@ remain runtime-only and are never copied into checkpoint-persisted
|
|||||||
`configurable`. Public run requests cannot use these keys to disable
|
`configurable`. Public run requests cannot use these keys to disable
|
||||||
clarification, impersonate a webhook channel, or override a scheduled run back
|
clarification, impersonate a webhook channel, or override a scheduled run back
|
||||||
to interactive behavior.
|
to interactive behavior.
|
||||||
|
|
||||||
|
## HITL tool approval
|
||||||
|
|
||||||
|
Tool permissions are independent of interaction and reasoning modes. The opt-in
|
||||||
|
`hitl_approval` policy offers Ask, Auto and Full with mandatory deny/ask rules and
|
||||||
|
an operator-controlled ceiling. Native tool calls pause at their execution boundary
|
||||||
|
using LangGraph interrupts; the durable run becomes `waiting_for_input`. Resume
|
||||||
|
consumption is compare-and-swap bound to every pending action's request ID, and
|
||||||
|
parallel interrupts are routed by their individual graph IDs.
|
||||||
|
|
||||||
|
Auto runs a separate, tool-free reviewer over the original user's intent and exact
|
||||||
|
bound operation. Review preparation is checkpointed: resuming cannot obtain a new
|
||||||
|
review verdict or silently replace the approved arguments. The model's result
|
||||||
|
cannot override host policy or inner authorization. Invalid/uncertain review
|
||||||
|
requires a human, and fails closed when the run cannot collect an answer.
|
||||||
|
|
||||||
|
Interactive child calls wait through a parent-owned bridge with a time limit.
|
||||||
|
They retain their original execution and use a session-authenticated, owner-scoped
|
||||||
|
HTTP answer. Cancellation, expiry, ownership loss or worker restart invalidates
|
||||||
|
these live requests. Unattended parent origins cannot open child approval waits.
|
||||||
|
A child without this bridge fails closed. The legacy `non_interactive: allow`
|
||||||
|
only bypasses Ask on explicitly configured unattended root runs.
|
||||||
|
|
||||||
|
The resume run's journal adopts the paused calls it answers, recovered from the
|
||||||
|
durable interrupt payload. Approved calls reach the history feed through their
|
||||||
|
tool callback; rejections have none, so the journal reconciles them under the
|
||||||
|
resume run like other middleware-written results (#4666). Results from other
|
||||||
|
earlier runs are not re-attributed.
|
||||||
|
|
||||||
|
A call that cannot carry a stable approval decision is never paused. A null or
|
||||||
|
duplicated call id can never match the resume validator and would block new runs
|
||||||
|
forever; an empty id is technically matchable, but it is denied as well because
|
||||||
|
strict providers reject an empty tool-call id when the conversation is replayed.
|
||||||
|
The middleware removes those calls from the model response with a visible
|
||||||
|
rejection note instead of pausing, so the tool is never executed and the thread
|
||||||
|
stays usable. The same uniqueness rule applies when a call is denied by result:
|
||||||
|
answering a denial `ToolMessage` also answers any other call that shares its id,
|
||||||
|
so a shared id is stripped rather than answered, and the next request cannot
|
||||||
|
carry the id twice. A pause stored by an older release with a null or duplicated
|
||||||
|
id is **not** cleared by this: every resume, including all-reject, is still
|
||||||
|
rejected, so continue the work in a new thread. In-place recovery is tracked in
|
||||||
|
[#8071](https://github.com/bytedance/deer-flow/issues/8071).
|
||||||
|
|
||||||
|
`waiting_for_input` is not terminal success or failure. Goals, delivery checks,
|
||||||
|
and batch continuation must preserve a native pause. New runs cannot discard it.
|
||||||
|
Root checkpoint approvals do not expire and block new runs until resolved;
|
||||||
|
live child approvals retain the time limit described above.
|
||||||
|
See [HITL Tool Approval](CONFIGURATION.md#hitl-tool-approval) for grant scoping,
|
||||||
|
parameter editing, limits and persistence semantics.
|
||||||
|
|||||||
@ -143,7 +143,7 @@ sequenceDiagram
|
|||||||
关键组件:
|
关键组件:
|
||||||
|
|
||||||
- `runtime/runs/worker.py::run_agent` — 在 `asyncio.Task` 里跑 `agent.astream()`,把每个 chunk 通过 `serialize(chunk, mode=mode)` 转成 JSON,再 `bridge.publish()`。
|
- `runtime/runs/worker.py::run_agent` — 在 `asyncio.Task` 里跑 `agent.astream()`,把每个 chunk 通过 `serialize(chunk, mode=mode)` 转成 JSON,再 `bridge.publish()`。
|
||||||
- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。`stream_bridge.heartbeat_interval_seconds`(默认 15 秒)是 bridge 实例的默认心跳周期,统一作用于 Gateway SSE、`/wait` 和内部 channel watcher;显式传给 `subscribe()` 的值仍可覆盖单次订阅。Memory 和 Redis 都只保留 `queue_maxsize` 条数据事件;游标早于保留水位线时返回 `StreamGap`,不会从当前最早事件静默部分重放。Redis backend 会在写入数据或首个 END 时刷新 retained stream key TTL;启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run,再处理恢复 task;尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error;周期恢复不做非原子的 thread 投影。普通 durable terminal status 本身仍不足以证明尾帧已发布;Redis producer 在 durable admission 前取得短租约,经 request task、worker、finalizer 转移并保留到 END。只有显式 `orphan_recovered` 或 durable terminal 加已失效的 tracked producer lease 才能在 heartbeat 上触发 END fallback;lease loss 会阻止后续 stream 写入,active 或 untracked producer 继续等待,避免截断尾帧。Redis END 通过同一个 Lua 原子完成 lease 校验、既有 END 检查和必要写入,因此重复调用、owner/recovery 竞争和 ACK 丢失重试至多追加一个 END;当 END 已位于 stream 尾部时,后续事件写入会被拒绝;旧 stream 中 END 后已有迟到数据时,仍允许继续写入数据。cleanup 后无 owner 的迟到 END 重试是成功 no-op,不会复活已删除的 stream。普通事件只做 O(1) 尾部 END 检查。`stream_bridge.legacy_end_scan_enabled` 默认 `true`:尾部不是 END 时扫描整个保留窗口,包括正常 run 的首次 END;原子 Lua 的耗时随保留条目数量和 payload 总字节数增长,期间会阻塞其他 Redis 客户端。只有全部旧 writer 停止且旧 replay buffer 已过期或清除后,才可设为 `false` 并重启所有 Gateway,使 END 检查也只看尾部。TTL 为 0 时必须显式清除旧 buffer。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制。
|
- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。`stream_bridge.heartbeat_interval_seconds`(默认 15 秒)是 bridge 实例的默认心跳周期,统一作用于 Gateway SSE、`/wait` 和内部 channel watcher;显式传给 `subscribe()` 的值仍可覆盖单次订阅。Memory 和 Redis 都只保留 `queue_maxsize` 条数据事件。`MemoryStreamBridge` 在分配事件 ID 前深拷贝发布的 payload,并在 stream Condition 外为每个订阅者返回独立深拷贝,因此生产者复用 payload 或订阅者修改数据都不会影响保留帧及重连重放;心跳与 END sentinel 保持对象身份。游标早于保留水位线时返回 `StreamGap`,不会从当前最早事件静默部分重放。Redis backend 会在写入数据或首个 END 时刷新 retained stream key TTL;启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run,再处理恢复 task;尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error;周期恢复不做非原子的 thread 投影。普通 durable terminal status 本身仍不足以证明尾帧已发布;Redis producer 在 durable admission 前取得短租约,经 request task、worker、finalizer 转移并保留到 END。只有显式 `orphan_recovered` 或 durable terminal 加已失效的 tracked producer lease 才能在 heartbeat 上触发 END fallback;lease loss 会阻止后续 stream 写入,active 或 untracked producer 继续等待,避免截断尾帧。Redis END 通过同一个 Lua 原子完成 lease 校验、既有 END 检查和必要写入,因此重复调用、owner/recovery 竞争和 ACK 丢失重试至多追加一个 END;当 END 已位于 stream 尾部时,后续事件写入会被拒绝;旧 stream 中 END 后已有迟到数据时,仍允许继续写入数据。cleanup 后无 owner 的迟到 END 重试是成功 no-op,不会复活已删除的 stream。普通事件只做 O(1) 尾部 END 检查。`stream_bridge.legacy_end_scan_enabled` 默认 `true`:尾部不是 END 时扫描整个保留窗口,包括正常 run 的首次 END;原子 Lua 的耗时随保留条目数量和 payload 总字节数增长,期间会阻塞其他 Redis 客户端。只有全部旧 writer 停止且旧 replay buffer 已过期或清除后,才可设为 `false` 并重启所有 Gateway,使 END 检查也只看尾部。TTL 为 0 时必须显式清除旧 buffer。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制。
|
||||||
- `stream_bridge.producer_lease_recovery_enabled` 是上述 producer-lease fallback 的 restart-required activate gate。升级时先保持 `false` 完成所有 writer 的滚动发布并排空旧 run,再在所有 Gateway 上同时设为 `true` 并重启;mixed-version 阶段不得启用。
|
- `stream_bridge.producer_lease_recovery_enabled` 是上述 producer-lease fallback 的 restart-required activate gate。升级时先保持 `false` 完成所有 writer 的滚动发布并排空旧 run,再在所有 Gateway 上同时设为 `true` 并重启;mixed-version 阶段不得启用。
|
||||||
- `app/gateway/services.py::sse_consumer` — 从 bridge 订阅,格式化为 SSE wire 帧。
|
- `app/gateway/services.py::sse_consumer` — 从 bridge 订阅,格式化为 SSE wire 帧。
|
||||||
- `runtime/serialization.py::serialize` — mode-aware 序列化;`messages` mode 下 `serialize_messages_tuple` 把 `(chunk, metadata)` 转成 `[chunk.model_dump(), metadata]`。
|
- `runtime/serialization.py::serialize` — mode-aware 序列化;`messages` mode 下 `serialize_messages_tuple` 把 `(chunk, metadata)` 转成 `[chunk.model_dump(), metadata]`。
|
||||||
|
|||||||
@ -45,7 +45,7 @@ once the feed's loaded page window no longer reaches back to it.
|
|||||||
store already assigned, keyed by
|
store already assigned, keyed by
|
||||||
`runtime/events/message_identity.py::message_identity` — the backend half of
|
`runtime/events/message_identity.py::message_identity` — the backend half of
|
||||||
the identity rule `frontend/src/core/threads/hooks.ts::messageIdentity` applies
|
the identity rule `frontend/src/core/threads/hooks.ts::messageIdentity` applies
|
||||||
(tool messages by `tool_call_id`; `X` / `X__user` human copies collapse to
|
(tool results by their own message ID; `X` / `X__user` human copies collapse to
|
||||||
one). The two halves must stay in sync: a mismatch is silent, degrading
|
one). The two halves must stay in sync: a mismatch is silent, degrading
|
||||||
placement rather than raising. `runs/worker.py::_MessageSeqStamper` attaches
|
placement rather than raising. `runs/worker.py::_MessageSeqStamper` attaches
|
||||||
the result as `additional_kwargs.deerflow_seq` on root `values` frames only —
|
the result as `additional_kwargs.deerflow_seq` on root `values` frames only —
|
||||||
@ -74,6 +74,26 @@ fetch-and-decode of every message row's tool outputs on long threads.
|
|||||||
client input, because a welded-in seq goes stale when a fork re-seeds the feed
|
client input, because a welded-in seq goes stale when a fork re-seeds the feed
|
||||||
(#4380).
|
(#4380).
|
||||||
|
|
||||||
|
`RunJournal` assigns missing ToolMessage IDs on the returned message object
|
||||||
|
before serializing callback results, including `Command` message updates, so
|
||||||
|
the graph checkpoint and feed retain the same ID. Middleware results receive
|
||||||
|
the same treatment when reconciled. Provider `tool_call_id` values may repeat
|
||||||
|
within a run and across runs; they remain protocol correlation data, never
|
||||||
|
thread-global result identities. Legacy results without message IDs have no
|
||||||
|
dedupe identity or seq lookup rather than aliasing unrelated invocations.
|
||||||
|
For a registered lead-agent call, the journal remembers the captured result ID
|
||||||
|
until a new canonical assistant response emits that call ID again. Reconciliation
|
||||||
|
restores the remembered ID on a copy serialized before its first capture, so
|
||||||
|
the copy does not append another result or consume a sequence. An explicit
|
||||||
|
message ID is preserved. A later invocation starts fresh even if its provider
|
||||||
|
ID and output content are identical; duplicate callbacks for the same LLM run
|
||||||
|
do not reset this bookkeeping. Callbacks without a registered lead call still
|
||||||
|
assign distinct IDs to their returned objects. This cache is journal-local and
|
||||||
|
is cleared on detach, not a fallback identity for historical rows.
|
||||||
|
The web execution timeline and write-file preview resolve a provider call ID
|
||||||
|
only inside its originating assistant-message occurrence; UI keys include that
|
||||||
|
occurrence while outgoing protocol IDs remain unchanged.
|
||||||
|
|
||||||
**Run delivery receipts** (`runtime/journal.py` + `runs/worker.py`):
|
**Run delivery receipts** (`runtime/journal.py` + `runs/worker.py`):
|
||||||
`RunJournal` records each non-empty artifact update once per tool `Command` for
|
`RunJournal` records each non-empty artifact update once per tool `Command` for
|
||||||
the terminal `run.delivery` event. When a command contains multiple messages, a
|
the terminal `run.delivery` event. When a command contains multiple messages, a
|
||||||
@ -96,7 +116,7 @@ regular file created or modified under `/mnt/user-data/outputs`; internal
|
|||||||
process-feedback files are excluded (the scanner's `EXCLUDED_DIR_NAMES` plus
|
process-feedback files are excluded (the scanner's `EXCLUDED_DIR_NAMES` plus
|
||||||
the configured `tool_output.storage_subdir`), so a run that only externalized
|
the configured `tool_output.storage_subdir`), so a run that only externalized
|
||||||
oversized tool outputs does not fail delivery. At least one candidate must be
|
oversized tool outputs does not fail delivery. At least one candidate must be
|
||||||
covered by a path attributed by the journal to `present_files`; presenting only
|
covered by a path attributed by the journal to `present_files` or `present_shared_artifact`; presenting only
|
||||||
an unrelated pre-existing path does not satisfy delivery. Receipts for such
|
an unrelated pre-existing path does not satisfy delivery. Receipts for such
|
||||||
runs add `produced_paths`, `presented_paths`, `matched_paths`, `verification`,
|
runs add `produced_paths`, `presented_paths`, `matched_paths`, `verification`,
|
||||||
`stage`, and `satisfied` to the Slice 1 fact fields. Missing a matching
|
`stage`, and `satisfied` to the Slice 1 fact fields. Missing a matching
|
||||||
@ -323,7 +343,7 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
|
|||||||
**Where things live**:
|
**Where things live**:
|
||||||
- `runtime/checkpoint_mode.py` — mode + snapshot-frequency freeze, marker injection, delta detection, compatibility gate, both error types
|
- `runtime/checkpoint_mode.py` — mode + snapshot-frequency freeze, marker injection, delta detection, compatibility gate, both error types
|
||||||
- `runtime/checkpoint_state.py` — `CheckpointStateAccessor`, `build_state_mutation_graph`, `RollbackPoint`
|
- `runtime/checkpoint_state.py` — `CheckpointStateAccessor`, `build_state_mutation_graph`, `RollbackPoint`
|
||||||
- `checkpoint_patches.py` (package root) — the one remaining patch: `BinaryOperatorAggregate` unwrapping an `Overwrite` first write into an empty channel (#4380; probe-guarded). The `InMemorySaver` delta-history patch is gone: `langgraph-checkpoint` 4.2.0 fixed that write loss upstream (#8526) while keeping its override, so the dependency floor plus the full → delta migration contract test are the gate — never re-add a version-guarded saver patch.
|
- `checkpoint_patches.py` (package root) — the one remaining patch: `BinaryOperatorAggregate` unwrapping an `Overwrite` first write into an empty channel (#4380; probe-guarded). The `InMemorySaver` delta-history patch is gone: `langgraph-checkpoint` 4.2.0 fixed that write loss upstream (#8526) while keeping its override. The 4.3.0 floor also keeps same-superstep replay in live task-path order; SQLite 3.1.2 and Postgres 3.1.3 carry the matching saver behavior, including paged Postgres ancestor walks. Dependency floors plus the migration, ordering and pagination contract tests are the gates — never re-add version-guarded saver patches or defect-triggered skips.
|
||||||
- `agents/thread_state.py` — `ThreadState`/`DeltaThreadState`, `delta_messages_field` / `DELTA_MESSAGES_FIELD` (`DeltaChannel` at the configured `snapshot_frequency`, default 10), schema adaptation helpers
|
- `agents/thread_state.py` — `ThreadState`/`DeltaThreadState`, `delta_messages_field` / `DELTA_MESSAGES_FIELD` (`DeltaChannel` at the configured `snapshot_frequency`, default 10), schema adaptation helpers
|
||||||
- `runtime/context_compaction.py` — compaction via accessor + mutation graph (reference consumer). Runs stamp their effective agent into server-owned checkpoint metadata; manual compaction uses that binding—not request `agent_name`—for memory policy and bucket. Missing/invalid legacy bindings and unreadable agent configs fail closed by skipping the optional flush while compaction may continue with the default model; a missing pre-binding checkpoint emits a warning so the skipped write is observable.
|
- `runtime/context_compaction.py` — compaction via accessor + mutation graph (reference consumer). Runs stamp their effective agent into server-owned checkpoint metadata; manual compaction uses that binding—not request `agent_name`—for memory policy and bucket. Missing/invalid legacy bindings and unreadable agent configs fail closed by skipping the optional flush while compaction may continue with the default model; a missing pre-binding checkpoint emits a warning so the skipped write is observable.
|
||||||
- `runtime/checkpoint_cache/` + `runtime/checkpointer/cached_saver.py` — delta-mode checkpoint history cache; checkpoint state reads MUST go through `CheckpointStateAccessor`, and the checkpointer may be a `CachedHistorySaver` wrapper — never rely on concrete saver types
|
- `runtime/checkpoint_cache/` + `runtime/checkpointer/cached_saver.py` — delta-mode checkpoint history cache; checkpoint state reads MUST go through `CheckpointStateAccessor`, and the checkpointer may be a `CachedHistorySaver` wrapper — never rely on concrete saver types
|
||||||
@ -333,6 +353,32 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
|
|||||||
|
|
||||||
## Deferred terminal commits and lease ownership
|
## Deferred terminal commits and lease ownership
|
||||||
|
|
||||||
|
RunManager serializes creation, replacement and assembly publication per thread.
|
||||||
|
The global registry lock never spans Store I/O; unrelated cached reads and
|
||||||
|
heartbeat snapshots do not queue behind an admission gate. Their own Store I/O
|
||||||
|
can still encounter database or connection-pool contention. A pending-ID map
|
||||||
|
lets readers of preallocated run IDs join the same-thread gate before the record
|
||||||
|
is published. Failed or cancelled admissions remove that temporary membership;
|
||||||
|
normal creation registers only after its initial write succeeds. Heartbeats use
|
||||||
|
the short registry snapshot, not the thread gate, so an admitted run can renew
|
||||||
|
while its replacement awaits a Store result. Preserve terminal-attribution and
|
||||||
|
cancellation compensation below; changing lock granularity does not grant a new
|
||||||
|
worker authority over a durable row.
|
||||||
|
|
||||||
|
With heartbeat enabled, assembly evidence is merged into the row's metadata
|
||||||
|
through `RunStore.merge_owned_metadata()`, which requires this Worker's live
|
||||||
|
lease on a `pending`/`running` row and writes no other field. A full snapshot
|
||||||
|
would also rewrite status, owner, lease and error, reviving a row a peer already
|
||||||
|
reclaimed. A rejection fences the run and fails assembly before execution; a
|
||||||
|
locally fenced run is refused before any write, and a Store without the method
|
||||||
|
fails closed instead of falling back to `put()`. Without heartbeat, evidence
|
||||||
|
still persists a full row snapshot. Serialize either write with the
|
||||||
|
same run's lease renewal and local acknowledgment, so it cannot restore an old
|
||||||
|
lease after renewal. Lock order is thread gate, then snapshot gate, then the
|
||||||
|
short registry lock. Renewal uses only the snapshot gate and must release it
|
||||||
|
before signaling local cancellation, which acquires the thread gate. Keep the
|
||||||
|
lease-expiry timer armed while waiting for the snapshot gate.
|
||||||
|
|
||||||
**Deferred terminal commit:** With an event store, the worker stages its terminal
|
**Deferred terminal commit:** With an event store, the worker stages its terminal
|
||||||
status locally and commits it only after finalization's receipt and duration
|
status locally and commits it only after finalization's receipt and duration
|
||||||
writes. `RunRecord.terminal_commit_pending` keeps `_renew_leases()` renewing that
|
writes. `RunRecord.terminal_commit_pending` keeps `_renew_leases()` renewing that
|
||||||
|
|||||||
@ -25,6 +25,17 @@ PII-redacted before splitting, then escaped before insertion into prompt blocks.
|
|||||||
No human-anchor filter or successful "too long" placeholder can discard selected
|
No human-anchor filter or successful "too long" placeholder can discard selected
|
||||||
source. Recent-message retention and AI/Tool partition boundaries stay unchanged.
|
source. Recent-message retention and AI/Tool partition boundaries stay unchanged.
|
||||||
|
|
||||||
|
State system messages, tagged dynamic-context reminders and the latest genuine
|
||||||
|
user request remain outside compression. Summary source conversion uses message
|
||||||
|
copies to include native document `text`/`content` plaintext, title and context
|
||||||
|
in their original block order, before redaction and chunking. Nonempty string
|
||||||
|
titles and context are retained even when the source is opaque, absent or
|
||||||
|
malformed; unsupported source bodies are not stringified. A `content` source
|
||||||
|
contributes only direct strings and `text` blocks. Nested `document` blocks are
|
||||||
|
not traversed, including their metadata; recursive extraction is outside this
|
||||||
|
fix's scope. Compaction does not decode binary documents, fetch remote media or
|
||||||
|
change checkpoint content.
|
||||||
|
|
||||||
Only after all chunks succeed are hooks, the compaction event, history capture
|
Only after all chunks succeed are hooks, the compaction event, history capture
|
||||||
and message replacement committed once. Any failed chunk leaves active history
|
and message replacement committed once. Any failed chunk leaves active history
|
||||||
unchanged; manual `/compact` reports generation failure. A pass processes at most
|
unchanged; manual `/compact` reports generation failure. A pass processes at most
|
||||||
@ -232,7 +243,7 @@ keep:
|
|||||||
|
|
||||||
#### `summary_prompt`
|
#### `summary_prompt`
|
||||||
- **Type**: String or null
|
- **Type**: String or null
|
||||||
- **Default**: `null` (uses LangChain's default prompt)
|
- **Default**: `null` (uses DeerFlow's built-in prompt)
|
||||||
- **Description**: Custom prompt template for generating summaries. The prompt should guide the model to extract the most important context.
|
- **Description**: Custom prompt template for generating summaries. The prompt should guide the model to extract the most important context.
|
||||||
|
|
||||||
#### `skill_file_read_tool_names`
|
#### `skill_file_read_tool_names`
|
||||||
@ -243,11 +254,12 @@ keep:
|
|||||||
Legacy `preserve_recent_skill_*` settings are no longer used. Loaded skill retention is handled by the durable `skill_context` reference channel instead of by preserving raw skill-read messages in the summarization window.
|
Legacy `preserve_recent_skill_*` settings are no longer used. Loaded skill retention is handled by the durable `skill_context` reference channel instead of by preserving raw skill-read messages in the summarization window.
|
||||||
|
|
||||||
**Default Prompt Behavior:**
|
**Default Prompt Behavior:**
|
||||||
The default LangChain prompt instructs the model to:
|
The built-in DeerFlow prompt extends LangChain's prompt and asks the model to:
|
||||||
- Extract highest quality/most relevant context
|
- Extract highest quality/most relevant context
|
||||||
- Focus on information critical to the overall goal
|
- Focus on information critical to the overall goal
|
||||||
- Avoid repeating completed actions
|
- Avoid repeating completed actions
|
||||||
- Return only the extracted context
|
- Return only the extracted context
|
||||||
|
- Preserve current user requirements, literal approval phrases, conditions for acting, and later revisions
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
@ -271,6 +283,11 @@ The default LangChain prompt instructs the model to:
|
|||||||
- Uses approximate token counting based on character count
|
- Uses approximate token counting based on character count
|
||||||
- For Anthropic models: ~3.3 characters per token
|
- For Anthropic models: ~3.3 characters per token
|
||||||
- For other models: Uses LangChain's default estimation
|
- For other models: Uses LangChain's default estimation
|
||||||
|
- For `VllmChatModel` using Chat Completions: History and token-based retention
|
||||||
|
estimates include the assistant reasoning replayed to vLLM. A non-null `reasoning` field takes
|
||||||
|
precedence over `reasoning_content`, including when it is empty. This applies
|
||||||
|
to both automatic and manual compaction. The estimate is approximate and
|
||||||
|
leaves the summary prompt and its trimming budget unchanged.
|
||||||
- Can be customized with a custom `token_counter` function
|
- Can be customized with a custom `token_counter` function
|
||||||
|
|
||||||
### Message Preservation
|
### Message Preservation
|
||||||
|
|||||||
@ -5,6 +5,9 @@ production authentication or a substitute for Gateway run-control tests.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -24,8 +27,10 @@ from langchain_core.messages import AIMessage
|
|||||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||||
from langgraph.checkpoint.memory import InMemorySaver
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
|
||||||
|
from app.gateway.extension_artifacts import read_output_snapshot, write_import_snapshot
|
||||||
from app.gateway.routers.plugins import router
|
from app.gateway.routers.plugins import router
|
||||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||||
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||||
from deerflow.tools.builtins.clarification_tool import ask_clarification_tool
|
from deerflow.tools.builtins.clarification_tool import ask_clarification_tool
|
||||||
|
|
||||||
@ -37,9 +42,13 @@ class PreviewModel(FakeMessagesListChatModel):
|
|||||||
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||||
latest = next(m for m in reversed(messages) if m.type == "human")
|
latest = next(m for m in reversed(messages) if m.type == "human")
|
||||||
response = latest.additional_kwargs.get("human_input_response")
|
response = latest.additional_kwargs.get("human_input_response")
|
||||||
|
try:
|
||||||
|
current_request = json.loads(latest.content.split("\n", 1)[1]).get("request", "")
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
current_request = latest.content
|
||||||
if response:
|
if response:
|
||||||
answer = AIMessage(content=f"Clarification accepted: {response['value']}.")
|
answer = AIMessage(content=f"Clarification accepted: {response['value']}.")
|
||||||
elif "Clarification fixture" in latest.content:
|
elif "Clarification fixture" in current_request:
|
||||||
answer = AIMessage(content="", tool_calls=[{"name": "ask_clarification", "id": "clarify-environment", "args": {"question": "Which environment should I check?", "clarification_type": "missing_info"}, "type": "tool_call"}])
|
answer = AIMessage(content="", tool_calls=[{"name": "ask_clarification", "id": "clarify-environment", "args": {"question": "Which environment should I check?", "clarification_type": "missing_info"}, "type": "tool_call"}])
|
||||||
else:
|
else:
|
||||||
answer = AIMessage(content="Release evidence verified. See the shared team record.")
|
answer = AIMessage(content="Release evidence verified. See the shared team record.")
|
||||||
@ -51,6 +60,7 @@ class PreviewRuns:
|
|||||||
self.threads = {}
|
self.threads = {}
|
||||||
self.runs = {}
|
self.runs = {}
|
||||||
self.tasks = set()
|
self.tasks = set()
|
||||||
|
self.artifact_gate = asyncio.Event()
|
||||||
self.graph = create_agent(PreviewModel(responses=[]), tools=[ask_clarification_tool], middleware=[ClarificationMiddleware()], checkpointer=InMemorySaver())
|
self.graph = create_agent(PreviewModel(responses=[]), tools=[ask_clarification_tool], middleware=[ClarificationMiddleware()], checkpointer=InMemorySaver())
|
||||||
|
|
||||||
def bound(self, owner):
|
def bound(self, owner):
|
||||||
@ -75,9 +85,25 @@ class PreviewRuns:
|
|||||||
state = await host.graph.aget_state({"configurable": {"thread_id": thread_id}})
|
state = await host.graph.aget_state({"configurable": {"thread_id": thread_id}})
|
||||||
if not state.values:
|
if not state.values:
|
||||||
raise AgentRunError(404, "No checkpoint")
|
raise AgentRunError(404, "No checkpoint")
|
||||||
return {"values": {"messages": [m.model_dump() for m in state.values["messages"]]}, "next": list(state.next)}
|
return {"values": {"messages": [m.model_dump() for m in state.values["messages"]]}, "next": list(state.next), "checkpoint_id": state.config["configurable"]["checkpoint_id"]}
|
||||||
|
|
||||||
async def start(self, *, thread_id, input, idempotency_key):
|
async def validate_artifact_source(self, *, thread_id, run_id, idempotency_key):
|
||||||
|
self.check(thread_id)
|
||||||
|
run = host.runs.get(run_id)
|
||||||
|
if run is None or run.thread_id != thread_id or run.status != "running" or run_id != idempotency_key:
|
||||||
|
raise AgentRunError(422, "Producing run is not executing")
|
||||||
|
|
||||||
|
async def read_artifact(self, *, thread_id, path, expected_checkpoint_id=None):
|
||||||
|
self.check(thread_id)
|
||||||
|
if expected_checkpoint_id is not None and (await self.get_state(thread_id=thread_id))["checkpoint_id"] != expected_checkpoint_id:
|
||||||
|
raise AgentRunError(412, "Producing checkpoint changed")
|
||||||
|
return await asyncio.to_thread(read_output_snapshot, owner, thread_id, path)
|
||||||
|
|
||||||
|
async def import_artifact(self, *, thread_id, filename, content):
|
||||||
|
self.check(thread_id)
|
||||||
|
return await asyncio.to_thread(write_import_snapshot, owner, thread_id, "community.agent-teams", filename, content)
|
||||||
|
|
||||||
|
async def start(self, *, thread_id, input, idempotency_key, artifact_imports=None):
|
||||||
self.check(thread_id)
|
self.check(thread_id)
|
||||||
if idempotency_key in host.runs:
|
if idempotency_key in host.runs:
|
||||||
return host.runs[idempotency_key]
|
return host.runs[idempotency_key]
|
||||||
@ -87,6 +113,8 @@ class PreviewRuns:
|
|||||||
host.runs[idempotency_key] = run
|
host.runs[idempotency_key] = run
|
||||||
|
|
||||||
async def execute():
|
async def execute():
|
||||||
|
if "Artifact fixture" in input["messages"][0]["content"]:
|
||||||
|
await host.artifact_gate.wait()
|
||||||
await host.graph.ainvoke(input, config={"configurable": {"thread_id": thread_id}})
|
await host.graph.ainvoke(input, config={"configurable": {"thread_id": thread_id}})
|
||||||
host.runs[idempotency_key] = AgentRun(thread_id, idempotency_key, "success", run.assistant_id)
|
host.runs[idempotency_key] = AgentRun(thread_id, idempotency_key, "success", run.assistant_id)
|
||||||
|
|
||||||
@ -103,6 +131,7 @@ class PreviewRuns:
|
|||||||
|
|
||||||
|
|
||||||
def create_app(directory):
|
def create_app(directory):
|
||||||
|
os.environ["DEER_FLOW_HOME"] = str(directory)
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-agent-teams"))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-agent-teams"))
|
||||||
extensions, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_agent_teams:install", config={"enabled": True, "storage_path": str(Path(directory) / "teams.sqlite")}, required=True)])
|
extensions, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_agent_teams:install", config={"enabled": True, "storage_path": str(Path(directory) / "teams.sqlite")}, required=True)])
|
||||||
assert not diagnostics
|
assert not diagnostics
|
||||||
@ -143,6 +172,40 @@ def create_app(directory):
|
|||||||
handlers = {"publish": service.publish_state, "read": service.read_state, "update": service.update_state}
|
handlers = {"publish": service.publish_state, "read": service.read_state, "update": service.update_state}
|
||||||
return await handlers[payload["operation"]](payload["input"], ToolContext(ExtensionPrincipal(request.state.user.id), {"enabled": True}, payload["source_thread"], agent_runs=runs.bound(request.state.user.id)))
|
return await handlers[payload["operation"]](payload["input"], ToolContext(ExtensionPrincipal(request.state.user.id), {"enabled": True}, payload["source_thread"], agent_runs=runs.bound(request.state.user.id)))
|
||||||
|
|
||||||
|
@app.post("/preview/artifact")
|
||||||
|
async def artifact(payload: dict, request: Request):
|
||||||
|
from deerflow_extension_api.plugins import ActionContext
|
||||||
|
|
||||||
|
owner = request.state.user.id
|
||||||
|
bound = runs.bound(owner)
|
||||||
|
ctx = ActionContext(ExtensionPrincipal(owner), {"enabled": True}, agent_runs=bound)
|
||||||
|
team = await service.get({"team_id": payload["team_id"]}, ctx)
|
||||||
|
member = team["members"][0]
|
||||||
|
runs.artifact_gate.clear()
|
||||||
|
try:
|
||||||
|
await service.send({"team_id": team["id"], "member_id": member["id"], "request_id": payload["request_id"], "text": "Artifact fixture: produce a release report"}, ctx)
|
||||||
|
await service.tick()
|
||||||
|
path = get_paths().sandbox_outputs_dir(member["thread_id"], user_id=owner) / "report.bin"
|
||||||
|
|
||||||
|
def write():
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(base64.b64decode(payload["content_base64"], validate=True))
|
||||||
|
|
||||||
|
await asyncio.to_thread(write)
|
||||||
|
return await service.register_artifact(
|
||||||
|
{"team_id": team["id"], "request_id": payload["request_id"], "name": payload["name"], "path": "/mnt/user-data/outputs/report.bin", **({"artifact_id": payload["artifact_id"]} if "artifact_id" in payload else {})},
|
||||||
|
ToolContext(ExtensionPrincipal(owner), {"enabled": True}, member["thread_id"], agent_runs=bound, run_id=next(r.run_id for r in runs.runs.values() if r.thread_id == member["thread_id"] and r.status == "running")),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
runs.artifact_gate.set()
|
||||||
|
|
||||||
|
@app.get("/preview/imported")
|
||||||
|
async def imported(team_id: str, member_id: str, request: Request):
|
||||||
|
team = await service.db("get", request.state.user.id, team_id)
|
||||||
|
member = next(member for member in team["members"] if member["id"] == member_id)
|
||||||
|
directory = get_paths().sandbox_uploads_dir(member["thread_id"], user_id=request.state.user.id)
|
||||||
|
return await asyncio.to_thread(lambda: [{"filename": file.name, "content_base64": base64.b64encode(file.read_bytes()).decode()} for file in directory.glob("artifact-*")])
|
||||||
|
|
||||||
@app.get("/api/agents")
|
@app.get("/api/agents")
|
||||||
async def catalog():
|
async def catalog():
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -102,7 +102,7 @@ from deerflow_extension_api.state import ExtensionData
|
|||||||
|
|
||||||
#: Contract version. Before 1.0, minors may break and patches are additive.
|
#: Contract version. Before 1.0, minors may break and patches are additive.
|
||||||
#: From 1.0 on, bump the major for breaking changes.
|
#: From 1.0 on, bump the major for breaking changes.
|
||||||
API_VERSION = "0.2.9"
|
API_VERSION = "0.2.11"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AGENT_RUNS_CONTEXT_KEY",
|
"AGENT_RUNS_CONTEXT_KEY",
|
||||||
|
|||||||
@ -11,7 +11,7 @@ AGENT_RUNS_CONTEXT_KEY = "__deerflow_extension_agent_runs"
|
|||||||
|
|
||||||
|
|
||||||
class AgentRunError(Exception):
|
class AgentRunError(Exception):
|
||||||
"""Host rejection with an HTTP-equivalent status (403, 404, 409, 422, 503)."""
|
"""Host rejection with an HTTP-equivalent status (403, 404, 409, 412, 422, 503)."""
|
||||||
|
|
||||||
def __init__(self, status_code: int, message: str):
|
def __init__(self, status_code: int, message: str):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
@ -76,6 +76,22 @@ class AgentRuns(Protocol):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
async def send_input(self, *, thread_id: str, run_id: str, text: str, mode: str, request_id: str, context: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
"""Queue a new turn or steer the active Run; never change its model."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def list_inputs(self, *, thread_id: str) -> dict[str, Any]:
|
||||||
|
"""Read host-recorded input IDs/status, including across reconnects."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def authorize_control(self, *, thread_id: str, run_id: str, clear_goal: bool = False) -> None:
|
||||||
|
"""Recheck cancellation and optional Goal-write authority, without a write."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def dispatch_input(self, *, thread_id: str, run_id: str, input_id: str) -> AgentRun:
|
||||||
|
"""Dispatch a stored queued input after its Run, using ordinary admission."""
|
||||||
|
...
|
||||||
|
|
||||||
async def configuration_evidence(self, *, thread_id: str, run_id: str) -> dict[str, Any]:
|
async def configuration_evidence(self, *, thread_id: str, run_id: str) -> dict[str, Any]:
|
||||||
"""Read immutable host evidence captured from this run's actual assembly.
|
"""Read immutable host evidence captured from this run's actual assembly.
|
||||||
|
|
||||||
@ -84,11 +100,32 @@ class AgentRuns(Protocol):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def read_artifact(self, *, thread_id: str, path: str) -> bytes:
|
async def validate_artifact_source(self, *, thread_id: str, run_id: str, idempotency_key: str) -> None:
|
||||||
|
"""Require this plugin's executing keyed Run and safe output capture.
|
||||||
|
|
||||||
|
Checking the durable key also handles admission whose acknowledgement
|
||||||
|
has not yet reached the plugin. Caller-selected queue state is not proof.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def read_artifact(self, *, thread_id: str, path: str, expected_checkpoint_id: str | None = None) -> bytes:
|
||||||
"""Read a bounded regular output file through owner authorization.
|
"""Read a bounded regular output file through owner authorization.
|
||||||
|
|
||||||
Returns at most 2 MiB, rejecting symlinks and concurrent mutations.
|
Returns at most 2 MiB, rejecting symlinks and concurrent mutations.
|
||||||
The caller must retain these bytes to create an immutable version.
|
The caller must retain these bytes to create an immutable version.
|
||||||
|
With expected_checkpoint_id, reject a changed checkpoint under the same
|
||||||
|
durable reservation as the read. Remote reads require sandbox authority.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def import_artifact(self, *, thread_id: str, filename: str, content: bytes) -> dict[str, Any]:
|
||||||
|
"""Place at most 2 MiB of retained bytes in an idle thread's uploads.
|
||||||
|
|
||||||
|
Requires a plugin-scoped handle and current thread-write authority.
|
||||||
|
The host selects a content-addressed filename and synchronizes remote
|
||||||
|
sandboxes. Identical calls reuse the copy; a changed destination is
|
||||||
|
rejected without overwriting it. Returns path, filename, sha256 and size.
|
||||||
|
Caller cancellation drains file/sandbox IO before releasing the thread.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@ -108,12 +145,25 @@ class AgentRuns(Protocol):
|
|||||||
"""Create an owned thread, or return the caller's existing thread ID."""
|
"""Create an owned thread, or return the caller's existing thread ID."""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def start(self, *, thread_id: str, input: Mapping[str, Any], context: Mapping[str, Any] | None = None, idempotency_key: str | None = None, execution_fence: ExecutionFence | None = None) -> AgentRun:
|
async def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
thread_id: str,
|
||||||
|
input: Mapping[str, Any],
|
||||||
|
context: Mapping[str, Any] | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
execution_fence: ExecutionFence | None = None,
|
||||||
|
artifact_imports: list[dict[str, Any]] | None = None,
|
||||||
|
) -> AgentRun:
|
||||||
"""Start or continue the thread's Agent with ordinary run admission.
|
"""Start or continue the thread's Agent with ordinary run admission.
|
||||||
|
|
||||||
Scoped handles namespace local idempotency keys automatically. An active
|
Scoped handles namespace local idempotency keys automatically. An active
|
||||||
run conflicts; start never implicitly cancels it. execution_fence is
|
run conflicts; start never implicitly cancels it. execution_fence is
|
||||||
host-only admission authority, never copied into Agent input/context.
|
host-only admission authority, never copied into Agent input/context.
|
||||||
|
artifact_imports contains up to four import receipts (path, filename,
|
||||||
|
sha256, size). A new run verifies the copies while holding admission,
|
||||||
|
before constructing its Agent. Idempotent retries reuse the original Run
|
||||||
|
without revalidating files that it may already have edited.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,13 @@ text later reaches a message (the ``durable_context_data`` block), but
|
|||||||
will not equal ``output_content_hash``. A consumer must join a compaction to what came
|
will not equal ``output_content_hash``. A consumer must join a compaction to what came
|
||||||
after it through this event alone, never by re-hashing a later projection of the same
|
after it through this event alone, never by re-hashing a later projection of the same
|
||||||
text.
|
text.
|
||||||
|
|
||||||
|
``task_id`` / ``run_id`` / ``thread_id`` name the task the compaction happened in. The
|
||||||
|
host copies them from the ``TaskInfo`` it seeds into the live task store — the same
|
||||||
|
store the observer receives — so a consumer can key a compaction on
|
||||||
|
``(thread_id, run_id)`` instead of matching hashes across sessions. They stay ``None``
|
||||||
|
when no task identity exists: a manual ``/compact`` outside any run (observers then
|
||||||
|
receive a detached store) or a subagent executed without a parent run.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -46,6 +53,9 @@ class CompactionEvent:
|
|||||||
output_content_hash: str
|
output_content_hash: str
|
||||||
compacted_message_count: int
|
compacted_message_count: int
|
||||||
kept_message_count: int
|
kept_message_count: int
|
||||||
|
task_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
thread_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ContextCompactionObserver(Protocol):
|
class ContextCompactionObserver(Protocol):
|
||||||
|
|||||||
@ -40,6 +40,8 @@ class ToolContext(ActionContext):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
thread_id: str | None
|
thread_id: str | None
|
||||||
|
# Host-stamped execution identity, never supplied by tool arguments.
|
||||||
|
run_id: str | None = field(default=None, kw_only=True)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "deerflow-extension-api"
|
name = "deerflow-extension-api"
|
||||||
version = "0.2.9"
|
version = "0.2.11"
|
||||||
description = "Public contracts for DeerFlow extensions"
|
description = "Public contracts for DeerFlow extensions"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
# Keep the contract package import-light and independent from the host. Public
|
# Keep the contract package import-light and independent from the host. Public
|
||||||
|
|||||||
@ -1,143 +1,56 @@
|
|||||||
### Request Trace Context (`packages/harness/deerflow/trace_context.py`)
|
# Harness package (`deerflow/`)
|
||||||
|
|
||||||
DeerFlow's request-level correlation id — the `X-Trace-Id` header and the `deerflow_trace_id` key. Not Langfuse's trace id, not `run_id`, not the short subagent `trace_id` log label.
|
The publishable `deerflow-harness` package: agents, runtime, tools, sandbox,
|
||||||
|
models, MCP, skills, config and persistence. Each subpackage has its own guide,
|
||||||
|
listed in [backend/AGENTS.md](../../../AGENTS.md). This file covers the modules
|
||||||
|
at the package root. The harness never imports `app.*`.
|
||||||
|
|
||||||
**The ContextVar is the only source.** Every path that reaches a run binds one first; downstream treats the id as a plain `str`, no `if trace_id:` guards.
|
## Request trace context (`trace_context.py`)
|
||||||
|
|
||||||
Entry points and binders: Gateway HTTP — `TraceMiddleware`; scheduled occurrence — `ScheduledTaskService._attempt_queued_run` → `launch_scheduled_thread_run`; MCP task notification — `launch_mcp_task_notification_run`; IM inbound — `ChannelManager._worker_loop`; embedded / TUI / CLI turn — `DeerFlowClient.stream()`.
|
The request correlation ID is the `X-Trace-Id` header and the `deerflow_trace_id`
|
||||||
|
key. It is not the Langfuse trace ID and not the `run_id`.
|
||||||
|
|
||||||
Only the first is HTTP; the rest run outside ASGI, so the binding cannot live in middleware alone. Each scopes **one unit of work**, never a poller loop — a leaked binding on a reused worker task would tag later occurrences with the first id. `ensure_trace_context` inherits, keeping layered scheduled bindings and a manual trigger inside a Gateway request on one trace.
|
- **The ContextVar is the only source.** Every entry point that starts a run
|
||||||
|
binds an ID first: the HTTP middleware, a scheduled occurrence, an MCP task
|
||||||
|
notification, an IM message and an embedded client turn. A new entry point
|
||||||
|
binds one too.
|
||||||
|
- **Bind one unit of work, never a poller loop.** A binding that leaks on a
|
||||||
|
reused worker task tags later work with the first ID.
|
||||||
|
- **Every other carrier is an output.** A `deerflow_trace_id` that a caller sends
|
||||||
|
is replaced, because the stored run would otherwise disagree with the header
|
||||||
|
and the logs.
|
||||||
|
- **An HTTP request does not inherit an ambient ID.** A crafted header must not
|
||||||
|
fall back to the previous request's ID.
|
||||||
|
- **Use `ensure_trace_id()` and `resolve_trace_id()`** in place of a local
|
||||||
|
fallback chain. A ContextVar does not survive a bare thread hop, so an ID that
|
||||||
|
crosses one travels as data.
|
||||||
|
- **`DeerFlowClient.stream()` binds per step, never across a `yield`.** A sync
|
||||||
|
generator shares the caller's context.
|
||||||
|
- **A crash-recovered scheduled launch keeps the first attempt's ID** on the run
|
||||||
|
record. That is accepted: restamping would rewrite an existing record.
|
||||||
|
|
||||||
**Every other carrier is a derived output, never read back as an input.** `worker._bind_trace_id` stamps the runtime context and `config["metadata"]`; `services.start_run` stamps the run record; a caller-sent `deerflow_trace_id` (`body.metadata`, `body.config.context`) is replaced — honouring it would let the persisted run disagree with the header and the logs. `_SERVER_OWNED_RUNTIME_CONTEXT_KEYS` covers the embedded path and also rejects caller-supplied sandbox lease/scope identities, `redact_config_secrets` scrubs the kwargs echo (`runs.kwargs_json`), and `build_run_config` merges metadata onto a copy so the stamp cannot reach `body.config`. Callers pin an id with `X-Trace-Id`.
|
## Embedded client (`client.py`)
|
||||||
|
|
||||||
Accepted divergence: a crash-recovered scheduled launch reuses its run via the idempotency key without restamping — the record keeps the first attempt's id, the retry's logs a fresh one; restamping would rewrite an existing record. Not a bug. Thread metadata omits the key entirely — a thread spans many runs.
|
`DeerFlowClient` runs the harness without HTTP and shares config, data
|
||||||
|
directories and response schemas with the Gateway.
|
||||||
|
|
||||||
**Do not open-code fallback chains.** Two helpers own the resolution order:
|
- **A method that mirrors a Gateway route returns the same schema.**
|
||||||
|
`TestGatewayConformance` in `tests/test_client.py` parses each result with the
|
||||||
|
Gateway model; add a new method there.
|
||||||
|
- **The client does not delete a thread's data directory.** Only the Gateway
|
||||||
|
does, after the LangGraph thread is deleted.
|
||||||
|
|
||||||
- `resolve_trace_id(*carriers)` — first usable carrier, else ambient. For ids travelling as data in `runtime.context`; ContextVars do not survive a bare thread hop.
|
## Other root modules
|
||||||
- `ensure_trace_context(trace_id)` — reuse the surrounding scope, else start a self-contained one. For boundary crossings (`SubagentExecutor._aexecute`, the memory `trace_context_manager` hook) and non-HTTP entry points; no argument mints a scoped id.
|
|
||||||
|
|
||||||
`request_trace_context` (HTTP) deliberately does **not** inherit: a crafted header must not fall back to the previous request's id.
|
- **Lark CLI credentials (`integrations/lark_cli.py`).** A managed skill pack is
|
||||||
|
staged privately and published with one rename, so a failed write leaves the
|
||||||
`get_current_trace_id()` stays nullable only for the logging filter (pre-entry-point records render as `trace_id=-`); everything else uses `ensure_trace_id()`/`resolve_trace_id()`.
|
previous pack in place. When you switch the app, clear the old OAuth data
|
||||||
|
before `lark-cli config init`: on Linux the new secret is written under the
|
||||||
`DeerFlowClient.stream()` binds per `next()` step and around `inner.close()`, never across a `yield`: a sync generator shares the caller's context, so a scope held across yields would leak the id and break on cross-context GC finalization.
|
same data directory, and a later clear would remove it.
|
||||||
|
- **Workspace snapshots (`workspace_changes/recorder.py`).** On cancellation,
|
||||||
`logging.enhance.enabled` gates **log output only** (`trace_id` field presence and format) — not the id, the header, or the run metadata — so `TraceMiddleware` reads no `AppConfig`; `logging` stays restart-required (`STARTUP_ONLY_FIELDS["logging"]`). `X-Trace-Id` is in `CORS_EXPOSED_HEADERS` (not safelisted). Unhandled-exception 500s keep the header — `TraceMiddleware` sends its own plain 500 (CORS-opaque, see its docstring) before re-raising; mid-stream failures propagate unchanged.
|
drain a text scan before you remove the cache that its worker reads. A
|
||||||
|
metadata scan owns no cache and is cancelled at once.
|
||||||
Tests: the `tests/test_trace_*` and `tests/test_worker_trace_binding.py` suites, `test_gateway_services.py`, `test_run_metadata_secret_safety.py`, plus the Langfuse suites in `tracing/AGENTS.md`.
|
- **Project summaries (`projects/summaries.py`).** `stop()` closes admission
|
||||||
|
first, and `start()` reopens it only after the stop has finished.
|
||||||
### Managed Lark CLI credentials (`integrations/lark_cli.py`)
|
- **Environment values are strings.** Parse a boolean before you pass it to an
|
||||||
|
SDK, because `bool("false")` is true.
|
||||||
Installed `lark-shared` guidance points to Capability Center > Plugins > Lark
|
|
||||||
(`/workspace/capabilities?tab=plugins&plugin=lark`). Guidance changes bump the
|
|
||||||
version marker; reinstalling the managed skill pack refreshes the stored text.
|
|
||||||
|
|
||||||
Managed skill-pack installation holds `_lark_install_lock` while preparing a
|
|
||||||
private tree; the final `staging_target.rename(target)` publishes the validated
|
|
||||||
skill bodies and manifest together, keeping earlier writes private. A write
|
|
||||||
failure leaves the previous pack unchanged and discards staging; replacement
|
|
||||||
moves the old target to a backup first and restores it on an ordinary
|
|
||||||
publication error. Atomic writes fsync only their temporary files, so
|
|
||||||
whole-tree durability after power loss is not guaranteed.
|
|
||||||
`_publish_bytes_atomically` removes a failed temporary through
|
|
||||||
`cleanup_failed_atomic_write`, so an unlink or warning-log failure cannot
|
|
||||||
replace the original publication error, including interruptions. Callers own
|
|
||||||
final file modes: runtime artifacts executable, skill bodies and manifests
|
|
||||||
read-only. Coverage: `tests/test_lark_cli_atomic_publish.py`.
|
|
||||||
|
|
||||||
App registration and direct app switching replace the per-user Lark credential
|
|
||||||
tree transactionally. Clear the old OAuth data before `lark-cli config init`:
|
|
||||||
on Linux it writes the new app secret into the file-backed keychain under the
|
|
||||||
data directory, so clearing afterward would leave `config.json` with a dangling
|
|
||||||
keychain reference. The snapshot still supplies the previous OAuth data for
|
|
||||||
logout and restores the complete old tree on any switch failure.
|
|
||||||
|
|
||||||
### DDG image search
|
|
||||||
|
|
||||||
Offload config/DDGS together. Only returned empties mean `No images found`;
|
|
||||||
the SDK sentinel can hide timeouts. See `tests/test_image_search_errors.py`.
|
|
||||||
|
|
||||||
### Browser Progress Screenshots (`community/browser_automation/`)
|
|
||||||
|
|
||||||
Hidden per-action browser progress frames use JPEG at quality 80 to keep their
|
|
||||||
storage and transfer cost bounded relative to lossless PNG. The explicit
|
|
||||||
`browser_screenshot` tool remains PNG because it creates a user-requested
|
|
||||||
artifact. New automatic capture entry points must reuse the shared progress
|
|
||||||
encoding definition in `tools.py` so the byte encoding and `.jpg` suffix cannot
|
|
||||||
drift. `browser_screenshot` never replaces an `outputs/` file: it hard-links
|
|
||||||
onto the first free `-N` name.
|
|
||||||
|
|
||||||
### Embedded Client (`packages/harness/deerflow/client.py`)
|
|
||||||
|
|
||||||
`DeerFlowClient` embeds the harness without HTTP/FastAPI, sharing Gateway modules, config, data directories, and response schemas.
|
|
||||||
|
|
||||||
**Agent Conversation**:
|
|
||||||
- `chat(message, thread_id)` — synchronous; returns final AI text.
|
|
||||||
- The first `values` snapshot reconciles any suffix appended to already-streamed AI text by a model wrapper; unchanged text and non-prefix replacements are not re-emitted. Later snapshots retain the same append-only rule.
|
|
||||||
- `stream(message, thread_id)` — subscribes to LangGraph `stream_mode=["values", "messages", "custom"]` and yields `StreamEvent`:
|
|
||||||
- `"values"` — state snapshot (title, messages, artifacts, summary_text). Always forward `summary_text` (current summary or `None`), including unchanged values/resets. Never re-emit AI text delivered via `messages`; serialized `ToolMessage` entries retain non-`None` native `artifact`
|
|
||||||
- `"messages-tuple"` — current-turn AI text **deltas** by `id` and each tool call/result once; excludes resumed history and preserves result `artifact`
|
|
||||||
- `"custom"` — forwarded from `StreamWriter`; DeerFlow-built-in custom events are dual-emitted through `deerflow.utils.custom_events`, so `astream_events(version="v2")` consumers also receive one `on_custom_event` with `name=payload["type"]` and the unchanged payload as `data`
|
|
||||||
- `"end"` — turn `usage`: signed chunks + positive full-message remainders, deduped by id
|
|
||||||
- **Custom-event invariant** — use `emit_custom_event` / `aemit_custom_event`, never `StreamWriter` alone. Built-in payloads require a non-empty string `type`; typeless payloads stay writer-only, absent from `astream_events`. The writer runs first and is authoritative for Gateway/Web UI/embedded clients; best-effort callbacks must not break it. Async graph hooks must await the async helper, never dispatch synchronously on a running event loop.
|
|
||||||
- Lazy graph creation uses `create_agent()` + `build_middlewares()`.
|
|
||||||
- Cache graphs by storage `user_id` and the unordered set of named-agent `mcp_plugins`. `stream()` materializes `user_id` before worker/loop boundaries in every auth mode.
|
|
||||||
- Supports `checkpointer` parameter for state persistence across turns
|
|
||||||
- `reset_agent()` reloads AgentConfig and rebuilds the graph. Every run's metadata carries `mcp_plugins` for delegation, including cache hits.
|
|
||||||
- [Streaming design](../../../docs/STREAMING.md): Gateway/client parallel paths, LangGraph `stream_mode`, per-id deduplication, and regression tests
|
|
||||||
|
|
||||||
**Gateway Equivalent Methods** (replaces Gateway API):
|
|
||||||
|
|
||||||
| Category | Methods | Return format |
|
|
||||||
|----------|---------|---------------|
|
|
||||||
| Models | `list_models()`, `get_model(name)` | `{"models": [...]}`, `{name, display_name, ...}` |
|
|
||||||
| MCP | `get_mcp_config()`, `update_mcp_config(servers)` | `{"mcp_servers": {...}}` |
|
|
||||||
| Skills | `list_skills()`, `get_skill(name)`, `update_skill(name, enabled)`, `install_skill(path)` | `{"skills": [...]}` |
|
|
||||||
| Goals | `get_goal(thread_id)`, `set_goal(thread_id, objective, max_continuations=8)`, `clear_goal(thread_id)` | `{"goal": {...}}` or `{"goal": None}` |
|
|
||||||
| Memory | `get_memory()`, `reload_memory()`, `get_memory_config()`, `get_memory_status()` | dict |
|
|
||||||
| Uploads | `upload_files(thread_id, files)`, `list_uploads(thread_id)`, `delete_upload(thread_id, filename)` | `{"success": true, "files": [...]}`, `{"files": [...], "count": N}` |
|
|
||||||
| Artifacts | `get_artifact(thread_id, path)` → `(bytes, mime_type)` | tuple |
|
|
||||||
|
|
||||||
**Gateway differences**: Upload takes local `Path`, not `UploadFile`, rejects directories before copying, and reuses one conversion worker inside an active event loop. Artifacts return `(bytes, mime_type)`, not HTTP Response. Gateway alone deletes `.deer-flow/threads/{thread_id}` after LangGraph thread deletion; the client has no equivalent. `update_mcp_config()` and `update_skill()` invalidate the cached agent.
|
|
||||||
|
|
||||||
Upload capture, filename claims and publication: [guide](uploads/AGENTS.md).
|
|
||||||
|
|
||||||
**Tests**: `tests/test_client.py` is offline, including `TestGatewayConformance`.
|
|
||||||
`tests/test_client_live.py` requires root `config.yaml`, valid API credentials,
|
|
||||||
and opt-in via `make test-live` or `DEER_FLOW_RUN_LIVE_TESTS=1`. It calls real
|
|
||||||
APIs (possible costs) and may create local sandboxes, artifacts, and files.
|
|
||||||
Marked `live`, it is excluded from `make test` and skipped in default CI.
|
|
||||||
|
|
||||||
**Gateway Conformance Tests** (`TestGatewayConformance`): Parse every dict-returning client method's output through its Gateway Pydantic model so missing required fields raise `ValidationError` in CI. Covers: `ModelsListResponse`, `ModelResponse`, `SkillsListResponse`, `SkillResponse`, `SkillInstallResponse`, `McpConfigResponse`, `UploadResponse`, `MemoryConfigResponse`, `MemoryStatusResponse`.
|
|
||||||
|
|
||||||
### AIO Sandbox Network Policy
|
|
||||||
|
|
||||||
Restricted AIO keeps sandboxes internal; a per-sandbox, ICC-disabled sidecar
|
|
||||||
handles egress and its token-authenticated API relay. Parse headers strictly;
|
|
||||||
reject policy-denied names before DNS and try all validated answers. Claim the
|
|
||||||
oldest unsurfaced denial; subagent/non-interactive runs drain and deny. Approvals
|
|
||||||
never replay tools; policy labels fence reuse. CONNECT/SNI cannot inspect
|
|
||||||
encrypted authority. Discovery and enumeration are read-only, including on a
|
|
||||||
policy or network-mode mismatch; only the provider may replace it after the
|
|
||||||
orphan grace, local teardown reservation, and cross-instance teardown lease.
|
|
||||||
Destroy the sandbox, sidecar, and both networks together.
|
|
||||||
|
|
||||||
### Tenki `sticky`
|
|
||||||
|
|
||||||
Env values stay strings; parse booleans before SDK calls (`bool("false")` is true).
|
|
||||||
|
|
||||||
### E2B Mount Uploads
|
|
||||||
|
|
||||||
Before changing creation uploads, policy projections, budgets, deadlines, or result
|
|
||||||
reporting, read [the E2B upload contract](community/e2b_sandbox/AGENTS.md#mount-uploads).
|
|
||||||
|
|
||||||
### Workspace Snapshot Cancellation (`workspace_changes/recorder.py`)
|
|
||||||
|
|
||||||
After `_prepare_capture()` hands off roots, cancellation must drain text scans
|
|
||||||
(`include_text=True`) before removing the cache the worker may still access.
|
|
||||||
Metadata scans (`include_text=False`) own no cache: cancel promptly, let the worker
|
|
||||||
continue, and consume/log its outcome in a completion callback. Prepare-stage
|
|
||||||
cancellation retains its handoff/reclaim path. Regressions in
|
|
||||||
`tests/blocking_io/test_workspace_changes_cancellation.py` must cover prompt
|
|
||||||
metadata cancellation and text-cache drain/cleanup.
|
|
||||||
|
|||||||
@ -1,56 +1,60 @@
|
|||||||
### Agent System
|
# Agents (`deerflow/agents/`)
|
||||||
|
|
||||||
Web runs may supply `context.locale` (`en-US` / `zh-CN`) as a response-language
|
The lead agent factory, its system prompt, the thread state and its reducers.
|
||||||
preference; explicit user language requests win. DynamicContext projects it as
|
Scoped guides: [middlewares](middlewares/AGENTS.md), [memory](memory/AGENTS.md),
|
||||||
request-only human data, leaving the static system prompt independent of locale.
|
[task continuity](task_continuity/AGENTS.md). Runtime options and state fields
|
||||||
Clients without a supported locale retain message-language inference.
|
are in `lead_agent/agent.py` and `thread_state.py`.
|
||||||
|
|
||||||
`AppConfig.lead_prompt_overlay` wraps the fully rendered lead system prompt with
|
## Verify
|
||||||
literal operator prepend/append text. Do not template-format these extensions
|
|
||||||
or source them from run context. The graph and assembly descriptor must see the
|
|
||||||
same resulting prompt. Empty overlays preserve bytes.
|
|
||||||
|
|
||||||
**Lead Agent** (`packages/harness/deerflow/agents/lead_agent/agent.py`):
|
```bash
|
||||||
- `make_lead_agent(config: RunnableConfig)` is the published `langgraph.json`
|
cd backend
|
||||||
entry point; preserve its signature and bare-graph return type.
|
python -m pytest tests -q -k "lead_agent or thread_state or assembly or prompt"
|
||||||
- Gateway calls `assemble_lead_agent(config, *, app_config=None)` for a
|
```
|
||||||
`LeadAgentAssembly(graph, descriptor, effective_model)`; `make_lead_agent` returns `.graph`.
|
|
||||||
`effective_model` is available even when no observer requests a descriptor,
|
|
||||||
so graph-root tracing can tag the selected default or fallback model.
|
|
||||||
`assembly_descriptor.py::build_assembly_descriptor()` records the model after
|
|
||||||
runtime overrides, rendered prompt hash, authorized tools and middleware order.
|
|
||||||
Consumers must unwrap via `runtime/runs/worker.py::_agent_graph` to also accept
|
|
||||||
third-party factories returning bare graphs.
|
|
||||||
The hashed skill catalog preserves `allowed_tools=None` (field omitted), `()` (declared empty), and non-empty allowlists as distinct compatibility-metadata states; allowlist ordering does not affect the fingerprint. The field is exported metadata only and never filters tools.
|
|
||||||
- Dynamic model selection via `create_chat_model()` with thinking/vision support
|
|
||||||
- Tools loaded via `get_available_tools()` - combines sandbox, built-in, MCP, community, and subagent tools
|
|
||||||
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
|
|
||||||
- Custom Agent `memory_enabled: false` disables memory reads, writes, tools, and compaction flushes while retaining date context; manual compaction trusts the state-producing checkpoint's agent binding, and global disable stays authoritative. Embedded clients cache the named policy by agent/user until `reset_agent()`; unreadable configs keep the legacy enabled default and log a warning.
|
|
||||||
- **Prompt trust**: framework authority uses the system channel; user/model-influenced text uses the sanitized `HumanMessage` data channel. Never interpolate untrusted values into system text—even escaped tags do not neutralize natural-language injection (PR #5090).
|
|
||||||
- Pass the same rendered prompt and middleware objects to the graph and assembly descriptor so observers describe the live graph, including Custom Agent `allowed_subagents` scope.
|
|
||||||
|
|
||||||
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
|
## Lead agent
|
||||||
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`, `goal`, `promoted`, `delegations`, `skill_context`, `summary_text`
|
|
||||||
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear), `merge_goal` (preserve the active goal across ordinary state updates unless the goal writer replaces it), `merge_promoted` (union deferred tools within one catalog/user turn; replace on scope change; hash-only tool updates inherit scope), `merge_delegations` (append task delegation entries, same (run,id) wins, terminal status never downgraded, capped to the most recent entries), and `merge_skill_context` (dedupe active-skill references by path, keep the most recently read entries; entries store a name/path/description reference, not the SKILL.md body). `summary_text` is a LastValue channel updated by summarization and projected into model requests as durable context data instead of being stored as a `messages` item.
|
|
||||||
- Delta-mode `merge_message_writes` normalizes the current message state once,
|
|
||||||
then folds normalized writes in order with message-ID position indexes and
|
|
||||||
deferred tombstone compaction. It preserves public `add_messages` behavior,
|
|
||||||
including duplicate IDs, replacement position, removal errors,
|
|
||||||
`REMOVE_ALL_MESSAGES`, null-write errors, and missing-ID allocation order,
|
|
||||||
without rescanning the accumulated state for every write. Keep this
|
|
||||||
full-parity contract covered by differential tests: LangGraph's private
|
|
||||||
`_messages_delta_reducer` is also linear, but intentionally omits some of
|
|
||||||
those public `add_messages` semantics and cannot be substituted directly.
|
|
||||||
|
|
||||||
**Runtime Configuration** (via `config.configurable`):
|
- **`make_lead_agent(config)` is the published `langgraph.json` entry point.**
|
||||||
- `thinking_enabled` - Enable model's extended thinking
|
Keep its signature and its bare-graph return type. The Gateway calls
|
||||||
- `model_name` - Select specific LLM model
|
`assemble_lead_agent`, which also returns the descriptor and the effective
|
||||||
- `is_plan_mode` - Enable TodoList middleware
|
model.
|
||||||
- `subagent_enabled` - Enable task delegation tool
|
- **A consumer unwraps through `runtime/runs/worker.py::_agent_graph`**, because
|
||||||
- `max_concurrent_subagents` - Per-response `task` call concurrency limit (clamped by `SubagentLimitMiddleware`)
|
a third-party factory can return a bare graph.
|
||||||
- `max_total_subagents` - Per-run total delegation cap override (unset/`null` uses `subagents.max_total_per_run`, clamped to 1-50)
|
- **The graph and the assembly descriptor receive the same rendered prompt and
|
||||||
Gateway and `DeerFlowClient.stream()` always provide the runtime `run_id`; custom
|
the same middleware objects.** Observers then describe the graph that runs.
|
||||||
graph integrations must do the same. If it is absent, enforcement deliberately
|
- **Skill `allowed_tools` in the descriptor is exported metadata.** It never
|
||||||
counts the thread's full delegation ledger (fail-restrictive) and emits a warning.
|
filters tools. Omitted, declared empty and non-empty are three states.
|
||||||
|
- **A custom graph integration supplies the runtime `run_id`.** Without it, the
|
||||||
|
delegation cap counts the thread's whole ledger and logs a warning.
|
||||||
|
- **One `SubagentRuntime` is shared per capacity boundary** when you build the
|
||||||
|
graph directly with `create_deerflow_agent`.
|
||||||
|
|
||||||
**Direct subagent runtime**: `create_deerflow_agent(..., subagent_runtime=runtime)` is the explicit dependency-injection path for direct graph callers. Reuse one `deerflow.subagents.SubagentRuntime` across every graph that belongs to the same application capacity boundary. With the default subagent feature it binds middleware concurrency/total limits, the ordinary `task` tool, one real execution controller, and any active durable-batch submitter to the same snapshot. A caller-owned batch repository requires `await runtime.start()` (or `async with runtime`) before graph construction and `stop()` at shutdown; the factory fails closed while that worker is stopped, and already-built bound batch tools must fail unavailable after it stops rather than falling through to another process-global submitter. The factory never creates SQL infrastructure, renders the caller-owned `system_prompt`, or mounts Gateway API/UI routes. Full middleware takeover cannot be combined with this runtime; direct callers and custom subagent middleware remain responsible for model-visible call-policy wording.
|
## Prompt trust
|
||||||
|
|
||||||
|
- **Framework authority uses the system channel. Text that a user or a model can
|
||||||
|
influence goes into the sanitized `HumanMessage` data channel.** Do not
|
||||||
|
interpolate an untrusted value into system text; escaping tags does not
|
||||||
|
neutralize an instruction written in natural language.
|
||||||
|
- **The locale preference is request-only human data.** The static system prompt
|
||||||
|
stays independent of it, and an explicit language request from the user wins.
|
||||||
|
- **`lead_prompt_overlay` is literal operator text.** Do not template-format it or
|
||||||
|
source it from the run context.
|
||||||
|
|
||||||
|
## Thread state
|
||||||
|
|
||||||
|
- **`merge_message_writes` keeps the public `add_messages` behavior in full.**
|
||||||
|
LangGraph's private delta reducer omits part of it and cannot replace it. The
|
||||||
|
differential tests cover the parity.
|
||||||
|
- **Reducers encode rules that a plain overwrite breaks**: the active goal
|
||||||
|
survives ordinary updates, and a terminal delegation status is never
|
||||||
|
downgraded. Read the reducer before you write its field.
|
||||||
|
|
||||||
|
## Import hygiene
|
||||||
|
|
||||||
|
- **The package roots of `deerflow.agents` and `deerflow.subagents` load the graph
|
||||||
|
and the executor lazily.** A module that needs only a type, a config or a
|
||||||
|
registry imports the concrete submodule. An eager import at the package root
|
||||||
|
pulls the tool graph into every state and schema import.
|
||||||
|
- **`deerflow.agents:make_lead_agent` is a real module-level function**, because
|
||||||
|
LangGraph Server resolves factories from the module dictionary. Its heavy
|
||||||
|
imports stay inside the function.
|
||||||
|
|||||||
@ -30,6 +30,7 @@ class GoalState(TypedDict):
|
|||||||
no_progress_count: int
|
no_progress_count: int
|
||||||
max_no_progress_continuations: int
|
max_no_progress_continuations: int
|
||||||
last_evaluation: NotRequired[dict[str, Any]]
|
last_evaluation: NotRequired[dict[str, Any]]
|
||||||
|
last_batch_wakeup_run_id: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
class GoalOutcomeState(TypedDict):
|
class GoalOutcomeState(TypedDict):
|
||||||
|
|||||||
@ -20,73 +20,16 @@ class RunInteractionMode(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
_INTERACTIVE_CLARIFICATION_SYSTEM = """<clarification_system>
|
_INTERACTIVE_CLARIFICATION_SYSTEM = """<clarification_system>
|
||||||
**WORKFLOW PRIORITY: CLARIFY → PLAN → ACT**
|
Use the request and available context to determine what is needed. Inspect
|
||||||
1. **FIRST**: Analyze the request in your thinking - identify what's unclear, missing, or ambiguous
|
relevant information and continue independent work where possible. Make and
|
||||||
2. **SECOND**: If clarification is needed, call `ask_clarification` tool IMMEDIATELY - do NOT start working
|
state reasonable, reversible assumptions when they do not materially change
|
||||||
3. **THIRD**: Only after all clarifications are resolved, proceed with planning and execution
|
the result. Ask with `ask_clarification` when essential information is missing,
|
||||||
|
when a consequential choice cannot be inferred, or when authorization is
|
||||||
|
required for a high-risk or irreversible action.
|
||||||
|
|
||||||
**CRITICAL RULE: Clarification ALWAYS comes BEFORE action. Never start working and clarify mid-execution.**
|
Once `ask_clarification` is called, execution is interrupted and sibling tool
|
||||||
|
calls in that turn are dropped. Wait for the user's answer before work that
|
||||||
**MANDATORY Clarification Scenarios - You MUST call ask_clarification BEFORE starting work when:**
|
depends on it.
|
||||||
|
|
||||||
1. **Missing Information** (`missing_info`): Required details not provided
|
|
||||||
- Example: User says "create a web scraper" but doesn't specify the target website
|
|
||||||
- Example: "Deploy the app" without specifying environment
|
|
||||||
- **REQUIRED ACTION**: Call ask_clarification to get the missing information
|
|
||||||
|
|
||||||
2. **Ambiguous Requirements** (`ambiguous_requirement`): Multiple valid interpretations exist
|
|
||||||
- Example: "Optimize the code" could mean performance, readability, or memory usage
|
|
||||||
- Example: "Make it better" is unclear what aspect to improve
|
|
||||||
- **REQUIRED ACTION**: Call ask_clarification to clarify the exact requirement
|
|
||||||
|
|
||||||
3. **Approach Choices** (`approach_choice`): Several valid approaches exist
|
|
||||||
- Example: "Add authentication" could use JWT, OAuth, session-based, or API keys
|
|
||||||
- Example: "Store data" could use database, files, cache, etc.
|
|
||||||
- **REQUIRED ACTION**: Call ask_clarification to let user choose the approach
|
|
||||||
|
|
||||||
4. **Risky Operations** (`risk_confirmation`): Destructive actions need confirmation
|
|
||||||
- Example: Deleting files, modifying production configs, database operations
|
|
||||||
- Example: Overwriting existing code or data
|
|
||||||
- **REQUIRED ACTION**: Call ask_clarification to get explicit confirmation
|
|
||||||
|
|
||||||
5. **Suggestions** (`suggestion`): You have a recommendation but want approval
|
|
||||||
- Example: "I recommend refactoring this code. Should I proceed?"
|
|
||||||
- **REQUIRED ACTION**: Call ask_clarification to get approval
|
|
||||||
|
|
||||||
**STRICT ENFORCEMENT:**
|
|
||||||
- ❌ DO NOT start working and then ask for clarification mid-execution - clarify FIRST
|
|
||||||
- ❌ DO NOT skip clarification for "efficiency" - accuracy matters more than speed
|
|
||||||
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
|
|
||||||
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first
|
|
||||||
- ❌ DO NOT call any other tool in the same turn as ask_clarification — sibling calls are dropped
|
|
||||||
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
|
|
||||||
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
|
|
||||||
- ✅ After calling ask_clarification, execution will be interrupted automatically
|
|
||||||
- ✅ Wait for user response - do NOT continue with assumptions
|
|
||||||
|
|
||||||
**How to Use:**
|
|
||||||
```python
|
|
||||||
ask_clarification(
|
|
||||||
question="Your specific question here?",
|
|
||||||
clarification_type="missing_info", # or other type
|
|
||||||
context="Why you need this information", # optional but recommended
|
|
||||||
options=["option1", "option2"] # optional, for choices
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
User: "Deploy the application"
|
|
||||||
You (thinking): Missing environment info - I MUST ask for clarification
|
|
||||||
You (action): ask_clarification(
|
|
||||||
question="Which environment should I deploy to?",
|
|
||||||
clarification_type="approach_choice",
|
|
||||||
context="I need to know the target environment for proper configuration",
|
|
||||||
options=["development", "staging", "production"]
|
|
||||||
)
|
|
||||||
[Execution stops - wait for user response]
|
|
||||||
|
|
||||||
User: "staging"
|
|
||||||
You: "Deploying to staging..." [proceed]
|
|
||||||
</clarification_system>"""
|
</clarification_system>"""
|
||||||
|
|
||||||
_AUTONOMOUS_CLARIFICATION_SYSTEM = """<clarification_system>
|
_AUTONOMOUS_CLARIFICATION_SYSTEM = """<clarification_system>
|
||||||
@ -126,7 +69,7 @@ class RunInteractionPolicy:
|
|||||||
@property
|
@property
|
||||||
def thinking_guidance(self) -> str:
|
def thinking_guidance(self) -> str:
|
||||||
if self.allows_clarification:
|
if self.allows_clarification:
|
||||||
return "- **PRIORITY CHECK: If anything is unclear, missing, or has multiple interpretations, you MUST ask for clarification FIRST - do NOT proceed with work**"
|
return "- **INTERACTION CHECK: Use available context and reasonable reversible assumptions; ask when essential information or authorization is missing.**"
|
||||||
return "- **INTERACTION CHECK: This run has no synchronous human. Resolve ambiguity with the run context, choose the lowest-risk reversible path, and record material assumptions.**"
|
return "- **INTERACTION CHECK: This run has no synchronous human. Resolve ambiguity with the run context, choose the lowest-risk reversible path, and record material assumptions.**"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -139,7 +82,7 @@ class RunInteractionPolicy:
|
|||||||
@property
|
@property
|
||||||
def clarification_reminder(self) -> str:
|
def clarification_reminder(self) -> str:
|
||||||
if self.allows_clarification:
|
if self.allows_clarification:
|
||||||
return "- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess"
|
return "- **Clarification**: Continue independent work and ask when an essential decision or authorization is missing."
|
||||||
return "- **Autonomous Interaction**: Do not wait for a human response; make minimal reversible assumptions, list them, or return a structured `BLOCKED` result for high-risk ambiguity"
|
return "- **Autonomous Interaction**: Do not wait for a human response; make minimal reversible assumptions, list them, or return a structured `BLOCKED` result for high-risk ambiguity"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user