From dbd777fe626f5c2d9afa900f38ee9d2726a53329 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:48:09 +0800 Subject: [PATCH 01/83] chore(deps): bump python-dotenv from 1.2.1 to 1.2.2 in /backend (#2440) Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 1.2.1 to 1.2.2. - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2) --- updated-dependencies: - dependency-name: python-dotenv dependency-version: 1.2.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index a42aa17b7..7c248b253 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -3127,11 +3127,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] From c43c803f66f595d16fb8227fea241d887a0f30dc Mon Sep 17 00:00:00 2001 From: He Wang Date: Thu, 23 Apr 2026 09:56:57 +0800 Subject: [PATCH 02/83] fix: remove mismatched context param in debug.py to suppress Pydantic warning (#2446) * fix: remove mismatched context param in debug.py to suppress Pydantic warning The ainvoke call passed context={"thread_id": ...} but the agent graph has no context_schema (ContextT defaults to None), causing a PydanticSerializationUnexpectedValue warning on every invocation. Align with the production run_agent path by injecting context via Runtime into configurable["__pregel_runtime"] instead. Closes #2445 Made-with: Cursor * refactor: derive runtime thread_id from config to avoid duplication Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Made-with: Cursor --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/debug.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/debug.py b/backend/debug.py index f558d1d71..2413d6e49 100644 --- a/backend/debug.py +++ b/backend/debug.py @@ -20,6 +20,7 @@ import logging from dotenv import load_dotenv from langchain_core.messages import HumanMessage +from langgraph.runtime import Runtime from deerflow.agents import make_lead_agent @@ -52,6 +53,9 @@ async def main(): } } + runtime = Runtime(context={"thread_id": config["configurable"]["thread_id"]}) + config["configurable"]["__pregel_runtime"] = runtime + agent = make_lead_agent(config) print("=" * 50) @@ -70,7 +74,7 @@ async def main(): # Invoke the agent state = {"messages": [HumanMessage(content=user_input)]} - result = await agent.ainvoke(state, config=config, context={"thread_id": "debug-thread-001"}) + result = await agent.ainvoke(state, config=config) # Print the response if result.get("messages"): From 96d00f6073a2cdb8f6049e8b736347a52b5072c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:18:59 +0800 Subject: [PATCH 03/83] chore(deps): bump dompurify from 3.3.1 to 3.4.1 in /frontend (#2462) Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.1 to 3.4.1. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.3.1...3.4.1) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/pnpm-lock.yaml | 301 ++++++++++++++++------------------------ 1 file changed, 121 insertions(+), 180 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0d5fe8d88..65074f4cd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -744,105 +744,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1002,42 +986,36 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/simple-git-linux-arm64-musl@0.1.22': resolution: {integrity: sha512-MOs7fPyJiU/wqOpKzAOmOpxJ/TZfP4JwmvPad/cXTOWYwwyppMlXFRms3i98EU3HOazI/wMU2Ksfda3+TBluWA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/simple-git-linux-ppc64-gnu@0.1.22': resolution: {integrity: sha512-L59dR30VBShRUIZ5/cQHU25upNgKS0AMQ7537J6LCIUEFwwXrKORZKJ8ceR+s3Sr/4jempWVvMdjEpFDE4HYww==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] - libc: [glibc] '@napi-rs/simple-git-linux-s390x-gnu@0.1.22': resolution: {integrity: sha512-4FHkPlCSIZUGC6HiADffbe6NVoTBMd65pIwcd40IDbtFKOgFMBA+pWRqKiQ21FERGH16Zed7XHJJoY3jpOqtmQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] - libc: [glibc] '@napi-rs/simple-git-linux-x64-gnu@0.1.22': resolution: {integrity: sha512-Ei1tM5Ho/dwknF3pOzqkNW9Iv8oFzRxE8uOhrITcdlpxRxVrBVptUF6/0WPdvd7R9747D/q61QG/AVyWsWLFKw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/simple-git-linux-x64-musl@0.1.22': resolution: {integrity: sha512-zRYxg7it0p3rLyEJYoCoL2PQJNgArVLyNavHW03TFUAYkYi5bxQ/UFNVpgxMaXohr5yu7qCBqeo9j4DWeysalg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/simple-git-win32-arm64-msvc@0.1.22': resolution: {integrity: sha512-XGFR1fj+Y9cWACcovV2Ey/R2xQOZKs8t+7KHPerYdJ4PtjVzGznI4c2EBHXtdOIYvkw7tL5rZ7FN1HJKdD5Quw==} @@ -1087,28 +1065,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.1.7': resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.1.7': resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.1.7': resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.1.7': resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==} @@ -1744,28 +1718,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@resvg/resvg-js-linux-arm64-musl@2.6.2': resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@resvg/resvg-js-linux-x64-gnu@2.6.2': resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@resvg/resvg-js-linux-x64-musl@2.6.2': resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@resvg/resvg-js-win32-arm64-msvc@2.6.2': resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} @@ -1793,141 +1763,128 @@ packages: resolution: {integrity: sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==} engines: {node: '>= 10'} - '@rollup/rollup-android-arm-eabi@4.60.1': - resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.1': - resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.1': - resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.1': - resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.1': - resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.1': - resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': - resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.1': - resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.1': - resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.1': - resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.1': - resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.1': - resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.1': - resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.1': - resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.1': - resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.1': - resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.1': - resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.1': - resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.1': - resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] - libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.1': - resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.1': - resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.1': - resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.1': - resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.1': - resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.1': - resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} cpu: [x64] os: [win32] @@ -2053,28 +2010,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2474,49 +2427,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3813,8 +3758,8 @@ packages: resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} engines: {node: '>=6'} - hookable@6.1.0: - resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==} + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -4181,28 +4126,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -4243,8 +4184,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@11.3.3: - resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==} + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} engines: {node: 20 || >=22} lucide-react@0.542.0: @@ -4834,12 +4775,12 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.9: - resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -5125,8 +5066,8 @@ packages: robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - rollup@4.60.1: - resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -7478,79 +7419,79 @@ snapshots: '@resvg/resvg-wasm@2.6.2': {} - '@rollup/rollup-android-arm-eabi@4.60.1': + '@rollup/rollup-android-arm-eabi@4.60.2': optional: true - '@rollup/rollup-android-arm64@4.60.1': + '@rollup/rollup-android-arm64@4.60.2': optional: true - '@rollup/rollup-darwin-arm64@4.60.1': + '@rollup/rollup-darwin-arm64@4.60.2': optional: true - '@rollup/rollup-darwin-x64@4.60.1': + '@rollup/rollup-darwin-x64@4.60.2': optional: true - '@rollup/rollup-freebsd-arm64@4.60.1': + '@rollup/rollup-freebsd-arm64@4.60.2': optional: true - '@rollup/rollup-freebsd-x64@4.60.1': + '@rollup/rollup-freebsd-x64@4.60.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.1': + '@rollup/rollup-linux-arm-musleabihf@4.60.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.1': + '@rollup/rollup-linux-arm64-gnu@4.60.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.1': + '@rollup/rollup-linux-arm64-musl@4.60.2': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.1': + '@rollup/rollup-linux-loong64-gnu@4.60.2': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.1': + '@rollup/rollup-linux-loong64-musl@4.60.2': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.1': + '@rollup/rollup-linux-ppc64-gnu@4.60.2': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.1': + '@rollup/rollup-linux-ppc64-musl@4.60.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.1': + '@rollup/rollup-linux-riscv64-gnu@4.60.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.1': + '@rollup/rollup-linux-riscv64-musl@4.60.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.1': + '@rollup/rollup-linux-s390x-gnu@4.60.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.1': + '@rollup/rollup-linux-x64-gnu@4.60.2': optional: true - '@rollup/rollup-linux-x64-musl@4.60.1': + '@rollup/rollup-linux-x64-musl@4.60.2': optional: true - '@rollup/rollup-openbsd-x64@4.60.1': + '@rollup/rollup-openbsd-x64@4.60.2': optional: true - '@rollup/rollup-openharmony-arm64@4.60.1': + '@rollup/rollup-openharmony-arm64@4.60.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.1': + '@rollup/rollup-win32-arm64-msvc@4.60.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.1': + '@rollup/rollup-win32-ia32-msvc@4.60.2': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.1': + '@rollup/rollup-win32-x64-gnu@4.60.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.1': + '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true '@rtsao/scc@1.1.0': {} @@ -8093,7 +8034,7 @@ snapshots: '@unhead/vue@2.1.4(vue@3.5.28(typescript@5.9.3))': dependencies: - hookable: 6.1.0 + hookable: 6.1.1 unhead: 2.1.4 vue: 3.5.28(typescript@5.9.3) @@ -8244,7 +8185,7 @@ snapshots: '@vue/shared': 3.5.28 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.9 + postcss: 8.5.10 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.28': @@ -9789,7 +9730,7 @@ snapshots: hex-rgb@4.3.0: {} - hookable@6.1.0: {} + hookable@6.1.1: {} html-url-attributes@3.0.1: {} @@ -10158,7 +10099,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@11.3.3: {} + lru-cache@11.3.5: {} lucide-react@0.542.0(react@19.2.4): dependencies: @@ -11152,13 +11093,13 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.6: + postcss@8.5.10: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.9: + postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -11493,35 +11434,35 @@ snapshots: robust-predicates@3.0.2: {} - rollup@4.60.1: + rollup@4.60.2: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.1 - '@rollup/rollup-android-arm64': 4.60.1 - '@rollup/rollup-darwin-arm64': 4.60.1 - '@rollup/rollup-darwin-x64': 4.60.1 - '@rollup/rollup-freebsd-arm64': 4.60.1 - '@rollup/rollup-freebsd-x64': 4.60.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 - '@rollup/rollup-linux-arm-musleabihf': 4.60.1 - '@rollup/rollup-linux-arm64-gnu': 4.60.1 - '@rollup/rollup-linux-arm64-musl': 4.60.1 - '@rollup/rollup-linux-loong64-gnu': 4.60.1 - '@rollup/rollup-linux-loong64-musl': 4.60.1 - '@rollup/rollup-linux-ppc64-gnu': 4.60.1 - '@rollup/rollup-linux-ppc64-musl': 4.60.1 - '@rollup/rollup-linux-riscv64-gnu': 4.60.1 - '@rollup/rollup-linux-riscv64-musl': 4.60.1 - '@rollup/rollup-linux-s390x-gnu': 4.60.1 - '@rollup/rollup-linux-x64-gnu': 4.60.1 - '@rollup/rollup-linux-x64-musl': 4.60.1 - '@rollup/rollup-openbsd-x64': 4.60.1 - '@rollup/rollup-openharmony-arm64': 4.60.1 - '@rollup/rollup-win32-arm64-msvc': 4.60.1 - '@rollup/rollup-win32-ia32-msvc': 4.60.1 - '@rollup/rollup-win32-x64-gnu': 4.60.1 - '@rollup/rollup-win32-x64-msvc': 4.60.1 + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 rou3@0.7.12: {} @@ -12011,7 +11952,7 @@ snapshots: unhead@2.1.4: dependencies: - hookable: 6.1.0 + hookable: 6.1.1 unicode-trie@2.0.0: dependencies: @@ -12119,7 +12060,7 @@ snapshots: chokidar: 5.0.0 destr: 2.0.5 h3: 1.15.11 - lru-cache: 11.3.3 + lru-cache: 11.3.5 node-fetch-native: 1.6.7 ofetch: 1.5.1 ufo: 1.6.3 @@ -12194,8 +12135,8 @@ snapshots: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.9 - rollup: 4.60.1 + postcss: 8.5.10 + rollup: 4.60.2 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 20.19.33 From b90f219bd179766227a02f8e33cfa57ba5086d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?d=20=F0=9F=94=B9?= Date: Thu, 23 Apr 2026 14:06:14 +0800 Subject: [PATCH 04/83] fix(skills): validate bundled SKILL.md front-matter in CI (fixes #2443) (#2457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(skills): validate bundled SKILL.md front-matter in CI (fixes #2443) Adds a parametrized backend test that runs `_validate_skill_frontmatter` against every bundled SKILL.md under `skills/public/`, so a broken front-matter fails CI with a per-skill error message instead of surfacing as a runtime gateway-load warning. The new test caught two pre-existing breakages on `main` and fixes them: * `bootstrap/SKILL.md`: the unquoted description had a second `:` mid-line ("Also trigger for updates: ..."), which YAML parses as a nested mapping ("mapping values are not allowed here"). Rewrites the description as a folded scalar (`>-`), which preserves the original wording (including the embedded colon, double quotes, and apostrophes) without further escaping. This complements PR #2436 (single-file colon→hyphen patch) with a more general convention that survives future edits. * `chart-visualization/SKILL.md`: used `dependency:` which is not in `ALLOWED_FRONTMATTER_PROPERTIES`. Renamed to `compatibility:`, the documented field for "Required tools, dependencies" per skill-creator. No code reads `dependency` (verified by grep across backend/). * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix the lint error --------- Co-authored-by: Willem Jiang Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/tests/test_skills_bundled.py | 31 ++++++++++++++++++++++ skills/public/bootstrap/SKILL.md | 8 +++++- skills/public/chart-visualization/SKILL.md | 2 +- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_skills_bundled.py diff --git a/backend/tests/test_skills_bundled.py b/backend/tests/test_skills_bundled.py new file mode 100644 index 000000000..0e99997a2 --- /dev/null +++ b/backend/tests/test_skills_bundled.py @@ -0,0 +1,31 @@ +"""Validate every bundled SKILL.md under skills/public/. + +Catches regressions like #2443 — a SKILL.md whose YAML front-matter fails to +parse (e.g. an unquoted description containing a colon, which YAML interprets +as a nested mapping). Each bundled skill is checked individually so the +failure message identifies the exact file. +""" + +from pathlib import Path + +import pytest + +from deerflow.skills.validation import _validate_skill_frontmatter + +SKILLS_PUBLIC_DIR = Path(__file__).resolve().parents[2] / "skills" / "public" +BUNDLED_SKILL_DIRS = sorted(p.parent for p in SKILLS_PUBLIC_DIR.rglob("SKILL.md")) + + +@pytest.mark.parametrize( + "skill_dir", + BUNDLED_SKILL_DIRS, + ids=lambda p: str(p.relative_to(SKILLS_PUBLIC_DIR)), +) +def test_bundled_skill_frontmatter_is_valid(skill_dir: Path) -> None: + valid, msg, name = _validate_skill_frontmatter(skill_dir) + assert valid, f"{skill_dir.relative_to(SKILLS_PUBLIC_DIR)}: {msg}" + assert name, f"{skill_dir.relative_to(SKILLS_PUBLIC_DIR)}: no name extracted" + + +def test_skills_public_dir_has_skills() -> None: + assert BUNDLED_SKILL_DIRS, f"no SKILL.md found under {SKILLS_PUBLIC_DIR}" diff --git a/skills/public/bootstrap/SKILL.md b/skills/public/bootstrap/SKILL.md index 38698d2d4..ab328d5fb 100644 --- a/skills/public/bootstrap/SKILL.md +++ b/skills/public/bootstrap/SKILL.md @@ -1,6 +1,12 @@ --- name: bootstrap -description: Generate a personalized SOUL.md through a warm, adaptive onboarding conversation. Trigger when the user wants to create, set up, or initialize their AI partner's identity — e.g., "create my SOUL.md", "bootstrap my agent", "set up my AI partner", "define who you are", "let's do onboarding", "personalize this AI", "make you mine", or when a SOUL.md is missing. Also trigger for updates: "update my SOUL.md", "change my AI's personality", "tweak the soul". +description: >- + Generate a personalized SOUL.md through a warm, adaptive onboarding conversation. + Trigger when the user wants to create, set up, or initialize their AI partner's + identity — e.g., "create my SOUL.md", "bootstrap my agent", "set up my AI + partner", "define who you are", "let's do onboarding", "personalize this AI", + "make you mine", or when a SOUL.md is missing. Also trigger for updates: + "update my SOUL.md", "change my AI's personality", "tweak the soul". --- # Bootstrap Soul diff --git a/skills/public/chart-visualization/SKILL.md b/skills/public/chart-visualization/SKILL.md index 7bc91344f..d7c6358d8 100644 --- a/skills/public/chart-visualization/SKILL.md +++ b/skills/public/chart-visualization/SKILL.md @@ -1,7 +1,7 @@ --- name: chart-visualization description: This skill should be used when the user wants to visualize data. It intelligently selects the most suitable chart type from 26 available options, extracts parameters based on detailed specifications, and generates a chart image using a JavaScript script. -dependency: +compatibility: nodejs: ">=18.0.0" --- From bd35cd39aa036a4b6d7cb035e537494fc829e717 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:47:15 +0800 Subject: [PATCH 05/83] chore(deps): bump uuid from 13.0.0 to 14.0.0 in /frontend (#2467) Bumps [uuid](https://github.com/uuidjs/uuid) from 13.0.0 to 14.0.0. - [Release notes](https://github.com/uuidjs/uuid/releases) - [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md) - [Commits](https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0) --- updated-dependencies: - dependency-name: uuid dependency-version: 14.0.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 198dba37b..ed8b0a950 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -89,7 +89,7 @@ "tokenlens": "^1.3.1", "unist-util-visit": "^5.0.0", "use-stick-to-bottom": "^1.1.1", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 65074f4cd..fc79edd09 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -219,8 +219,8 @@ importers: specifier: ^1.1.1 version: 1.1.3(react@19.2.4) uuid: - specifier: ^13.0.0 - version: 13.0.0 + specifier: ^14.0.0 + version: 14.0.0 zod: specifier: ^3.24.2 version: 3.25.76 @@ -5626,6 +5626,10 @@ packages: resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -12115,6 +12119,8 @@ snapshots: uuid@13.0.0: {} + uuid@14.0.0: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 From c42ae3af79430c7637277118838dbf6cfd3ae881 Mon Sep 17 00:00:00 2001 From: He Wang Date: Thu, 23 Apr 2026 17:49:18 +0800 Subject: [PATCH 06/83] feat: add optional prompt-toolkit support to debug.py (#2461) * feat: add optional prompt-toolkit support to debug.py Use PromptSession.prompt_async() for arrow-key navigation and input history when prompt-toolkit is available, falling back to plain input() with a helpful install tip otherwise. Made-with: Cursor * fix: handle EOFError gracefully in debug.py Catch EOFError alongside KeyboardInterrupt so that Ctrl-D exits cleanly instead of printing a traceback. Made-with: Cursor --- backend/debug.py | 21 ++++++++++++++++++--- backend/pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/backend/debug.py b/backend/debug.py index 2413d6e49..2031557c1 100644 --- a/backend/debug.py +++ b/backend/debug.py @@ -24,6 +24,14 @@ from langgraph.runtime import Runtime from deerflow.agents import make_lead_agent +try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import InMemoryHistory + + _HAS_PROMPT_TOOLKIT = True +except ImportError: + _HAS_PROMPT_TOOLKIT = False + load_dotenv() logging.basicConfig( @@ -58,14 +66,21 @@ async def main(): agent = make_lead_agent(config) + session = PromptSession(history=InMemoryHistory()) if _HAS_PROMPT_TOOLKIT else None + print("=" * 50) print("Lead Agent Debug Mode") print("Type 'quit' or 'exit' to stop") + if not _HAS_PROMPT_TOOLKIT: + print("Tip: `uv sync --group dev` to enable arrow-key & history support") print("=" * 50) while True: try: - user_input = input("\nYou: ").strip() + if session: + user_input = (await session.prompt_async("\nYou: ")).strip() + else: + user_input = input("\nYou: ").strip() if not user_input: continue if user_input.lower() in ("quit", "exit"): @@ -81,8 +96,8 @@ async def main(): last_message = result["messages"][-1] print(f"\nAgent: {last_message.content}") - except KeyboardInterrupt: - print("\nInterrupted. Goodbye!") + except (KeyboardInterrupt, EOFError): + print("\nGoodbye!") break except Exception as e: print(f"\nError: {e}") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2b2e43baa..220ac23d6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ ] [dependency-groups] -dev = ["pytest>=9.0.3", "ruff>=0.14.11"] +dev = ["prompt-toolkit>=3.0.0", "pytest>=9.0.3", "ruff>=0.14.11"] [tool.uv.workspace] members = ["packages/harness"] From 4e72410154ebb2c1e055d21b52211ae56c79c3d2 Mon Sep 17 00:00:00 2001 From: JerryChaox Date: Thu, 23 Apr 2026 19:41:26 +0800 Subject: [PATCH 07/83] fix(gateway): bound lifespan shutdown hooks to prevent worker hang under uvicorn reload (#2331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway): bound lifespan shutdown hooks to prevent worker hang Gateway worker can hang indefinitely in `uvicorn --reload` mode with the listening socket still bound — all /api/* requests return 504, and SIGKILL is the only recovery. Root cause (py-spy dump from a reproduction showed 16+ stacked frames of signal_handler -> Event.set -> threading.Lock.__enter__ on the main thread): CPython's `threading.Event` uses `Condition(Lock())` where the inner Lock is non-reentrant. uvicorn's BaseReload signal handler calls `should_exit.set()` directly from signal context; if a second signal (SIGTERM/SIGHUP from the reload supervisor, or watchfiles-triggered reload) arrives while the first handler holds the Lock, the reentrant call deadlocks on itself. The reload supervisor keeps sending those signals only when the worker fails to exit promptly. DeerFlow's lifespan currently awaits `stop_channel_service()` with no timeout; if a channel's `stop()` stalls (e.g. Feishu/Slack WebSocket waiting for an ack), the worker can't exit, the supervisor keeps signaling, and the deadlock becomes reachable. This is a defense-in-depth fix — it does not repair the upstream uvicorn/CPython issue, but it ensures DeerFlow's lifespan exits within a bounded window so the supervisor has no reason to keep firing signals. No behavior change on the happy path. Wraps the shutdown hook in `asyncio.wait_for(timeout=5.0)` and logs a warning on timeout before proceeding to worker exit. Co-Authored-By: Claude Opus 4.7 (1M context) * Update backend/app/gateway/app.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * style: apply make format (ruff) to test assertions Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Willem Jiang Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/app/gateway/app.py | 18 ++++- .../tests/test_gateway_lifespan_shutdown.py | 68 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_gateway_lifespan_shutdown.py diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 39d17498f..92f50b324 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -1,3 +1,4 @@ +import asyncio import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager @@ -32,6 +33,11 @@ logging.basicConfig( logger = logging.getLogger(__name__) +# Upper bound (seconds) each lifespan shutdown hook is allowed to run. +# Bounds worker exit time so uvicorn's reload supervisor does not keep +# firing signals into a worker that is stuck waiting for shutdown cleanup. +_SHUTDOWN_HOOK_TIMEOUT_SECONDS = 5.0 + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: @@ -63,11 +69,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: yield - # Stop channel service on shutdown + # Stop channel service on shutdown (bounded to prevent worker hang) try: from app.channels.service import stop_channel_service - await stop_channel_service() + await asyncio.wait_for( + stop_channel_service(), + timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS, + ) + except TimeoutError: + logger.warning( + "Channel service shutdown exceeded %.1fs; proceeding with worker exit.", + _SHUTDOWN_HOOK_TIMEOUT_SECONDS, + ) except Exception: logger.exception("Failed to stop channel service") diff --git a/backend/tests/test_gateway_lifespan_shutdown.py b/backend/tests/test_gateway_lifespan_shutdown.py new file mode 100644 index 000000000..9319c6268 --- /dev/null +++ b/backend/tests/test_gateway_lifespan_shutdown.py @@ -0,0 +1,68 @@ +"""Regression tests for Gateway lifespan shutdown. + +These tests guard the invariant that lifespan shutdown is *bounded*: a +misbehaving channel whose ``stop()`` blocks forever must not keep the +uvicorn worker alive. A hung worker is the precondition for the +signal-reentrancy deadlock described in +``app.gateway.app._SHUTDOWN_HOOK_TIMEOUT_SECONDS``. +""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from unittest.mock import MagicMock, patch + +from fastapi import FastAPI + + +@asynccontextmanager +async def _noop_langgraph_runtime(_app): + yield + + +async def _run_lifespan_with_hanging_stop() -> float: + """Drive the lifespan context with stop_channel_service hanging forever. + + Returns the elapsed wall-clock seconds. + """ + from app.gateway.app import _SHUTDOWN_HOOK_TIMEOUT_SECONDS, lifespan + + async def hang_forever() -> None: + await asyncio.sleep(3600) + + app = FastAPI() + + fake_service = MagicMock() + fake_service.get_status = MagicMock(return_value={}) + + async def fake_start(): + return fake_service + + with ( + patch("app.gateway.app.get_app_config"), + patch("app.gateway.app.get_gateway_config", return_value=MagicMock(host="x", port=0)), + patch("app.gateway.app.langgraph_runtime", _noop_langgraph_runtime), + patch("app.channels.service.start_channel_service", side_effect=fake_start), + patch("app.channels.service.stop_channel_service", side_effect=hang_forever), + ): + loop = asyncio.get_event_loop() + start = loop.time() + async with lifespan(app): + pass + elapsed = loop.time() - start + + assert _SHUTDOWN_HOOK_TIMEOUT_SECONDS < 30.0, "Timeout constant must stay modest" + return elapsed + + +def test_shutdown_is_bounded_when_channel_stop_hangs(): + """Lifespan exit must complete near the configured timeout, not hang.""" + from app.gateway.app import _SHUTDOWN_HOOK_TIMEOUT_SECONDS + + elapsed = asyncio.run(_run_lifespan_with_hanging_stop()) + + # Generous upper bound: timeout + 2s slack for scheduling overhead. + assert elapsed < _SHUTDOWN_HOOK_TIMEOUT_SECONDS + 2.0, f"Lifespan shutdown took {elapsed:.2f}s; expected <= {_SHUTDOWN_HOOK_TIMEOUT_SECONDS + 2.0:.1f}s" + # Lower bound: the wait_for should actually have waited. + assert elapsed >= _SHUTDOWN_HOOK_TIMEOUT_SECONDS - 0.5, f"Lifespan exited too quickly ({elapsed:.2f}s); wait_for may not have been invoked." From 30d619de08291fe5657559c00bf0a389c9ea74a6 Mon Sep 17 00:00:00 2001 From: Xinmin Zeng <135568692+fancyboi999@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:59:47 +0800 Subject: [PATCH 08/83] feat(subagents): support per-subagent skill loading and custom subagent types (#2253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(subagents): support per-subagent skill loading and custom subagent types (#2230) Add per-subagent skill configuration and custom subagent type registration, aligned with Codex's role-based config layering and per-session skill injection. Backend: - SubagentConfig gains `skills` field (None=all, []=none, list=whitelist) - New CustomSubagentConfig for user-defined subagent types in config.yaml - SubagentsAppConfig gains `custom_agents` section and `get_skills_for()` - Registry resolves custom agents with three-layer config precedence - SubagentExecutor loads skills per-session as conversation items (Codex pattern) - task_tool no longer appends skills to system_prompt - Lead agent system prompt dynamically lists all registered subagent types - setup_agent tool accepts optional skills parameter - Gateway agents API transparently passes skills in CRUD operations Frontend: - Agent/CreateAgentRequest/UpdateAgentRequest types include skills field - Agent card displays skills as badges alongside tool_groups Config: - config.example.yaml documents custom_agents and per-agent skills override Tests: - 40 new tests covering all skill config, custom agents, and registry logic - Existing tests updated for new get_skills_prompt_section signature Closes #2230 * fix: address review feedback on skills PR - Remove stale get_skills_prompt_section monkeypatches from test_task_tool_core_logic.py (task_tool no longer imports this function after skill injection moved to executor) - Add key prefixes (tg:/sk:) to agent-card badges to prevent React key collisions between tool_groups and skills * fix(ci): resolve lint and test failures - Format agent-card.tsx with prettier (lint-frontend) - Remove stale "Skills Appendix" system_prompt assertion — skills are now loaded per-session by SubagentExecutor, not appended to system_prompt * fix(ci): sort imports in test_subagent_skills_config.py (ruff I001) * fix(ci): use nullish coalescing in agent-card badge condition (eslint) * fix: address review feedback on skills PR - Use model_fields_set in AgentUpdateRequest to distinguish "field omitted" from "explicitly set to null" — fixes skills=None ambiguity where None means "inherit all" but was treated as "don't change" - Move lazy import of get_subagent_config outside loop in _build_available_subagents_description to avoid repeated import overhead --------- Co-authored-by: Willem Jiang --- backend/app/gateway/routers/agents.py | 25 +- .../deerflow/agents/lead_agent/prompt.py | 43 +- .../deerflow/config/subagents_config.py | 70 +- .../harness/deerflow/subagents/config.py | 3 + .../harness/deerflow/subagents/executor.py | 73 ++- .../harness/deerflow/subagents/registry.py | 124 +++- .../tools/builtins/setup_agent_tool.py | 4 + .../deerflow/tools/builtins/task_tool.py | 14 +- .../tests/test_subagent_prompt_security.py | 4 +- backend/tests/test_subagent_skills_config.py | 596 ++++++++++++++++++ backend/tests/test_task_tool_core_logic.py | 28 +- config.example.yaml | 28 +- .../workspace/agents/agent-card.tsx | 19 +- frontend/src/core/agents/types.ts | 3 + 14 files changed, 962 insertions(+), 72 deletions(-) create mode 100644 backend/tests/test_subagent_skills_config.py diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index 92002d75b..ff4476893 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -25,6 +25,7 @@ class AgentResponse(BaseModel): description: str = Field(default="", description="Agent description") model: str | None = Field(default=None, description="Optional model override") tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") + skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all, []=none)") soul: str | None = Field(default=None, description="SOUL.md content") @@ -41,6 +42,7 @@ class AgentCreateRequest(BaseModel): description: str = Field(default="", description="Agent description") model: str | None = Field(default=None, description="Optional model override") tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") + skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all enabled, []=none)") soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails") @@ -50,6 +52,7 @@ class AgentUpdateRequest(BaseModel): description: str | None = Field(default=None, description="Updated description") model: str | None = Field(default=None, description="Updated model override") tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist") + skills: list[str] | None = Field(default=None, description="Updated skill whitelist (None=all, []=none)") soul: str | None = Field(default=None, description="Updated SOUL.md content") @@ -94,6 +97,7 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False description=agent_cfg.description, model=agent_cfg.model, tool_groups=agent_cfg.tool_groups, + skills=agent_cfg.skills, soul=soul, ) @@ -215,6 +219,8 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse: config_data["model"] = request.model if request.tool_groups is not None: config_data["tool_groups"] = request.tool_groups + if request.skills is not None: + config_data["skills"] = request.skills config_file = agent_dir / "config.yaml" with open(config_file, "w", encoding="utf-8") as f: @@ -271,21 +277,32 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: try: # Update config if any config fields changed - config_changed = any(v is not None for v in [request.description, request.model, request.tool_groups]) + # Use model_fields_set to distinguish "field omitted" from "explicitly set to null". + # This is critical for skills where None means "inherit all" (not "don't change"). + fields_set = request.model_fields_set + config_changed = bool(fields_set & {"description", "model", "tool_groups", "skills"}) if config_changed: updated: dict = { "name": agent_cfg.name, - "description": request.description if request.description is not None else agent_cfg.description, + "description": request.description if "description" in fields_set else agent_cfg.description, } - new_model = request.model if request.model is not None else agent_cfg.model + new_model = request.model if "model" in fields_set else agent_cfg.model if new_model is not None: updated["model"] = new_model - new_tool_groups = request.tool_groups if request.tool_groups is not None else agent_cfg.tool_groups + new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups if new_tool_groups is not None: updated["tool_groups"] = new_tool_groups + # skills: None = inherit all, [] = no skills, ["a","b"] = whitelist + if "skills" in fields_set: + new_skills = request.skills + else: + new_skills = agent_cfg.skills + if new_skills is not None: + updated["skills"] = new_skills + config_file = agent_dir / "config.yaml" with open(config_file, "w", encoding="utf-8") as f: yaml.dump(updated, f, default_flow_style=False, allow_unicode=True) diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index dda49a1de..2ccacac68 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -164,6 +164,36 @@ Skip simple one-off tasks. """ +def _build_available_subagents_description(available_names: list[str], bash_available: bool) -> str: + """Dynamically build subagent type descriptions from registry. + + Mirrors Codex's pattern where agent_type_description is dynamically generated + from all registered roles, so the LLM knows about every available type. + """ + # Built-in descriptions (kept for backward compatibility with existing prompt quality) + builtin_descriptions = { + "general-purpose": "For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.", + "bash": ( + "For command execution (git, build, test, deploy operations)" if bash_available else "Not available in the current sandbox configuration. Use direct file/web tools or switch to AioSandboxProvider for isolated shell access." + ), + } + + # Lazy import moved outside loop to avoid repeated import overhead + from deerflow.subagents.registry import get_subagent_config + + lines = [] + for name in available_names: + if name in builtin_descriptions: + lines.append(f"- **{name}**: {builtin_descriptions[name]}") + else: + config = get_subagent_config(name) + if config is not None: + desc = config.description.split("\n")[0].strip() # First line only for brevity + lines.append(f"- **{name}**: {desc}") + + return "\n".join(lines) + + def _build_subagent_section(max_concurrent: int) -> str: """Build the subagent system prompt section with dynamic concurrency limit. @@ -174,13 +204,12 @@ def _build_subagent_section(max_concurrent: int) -> str: Formatted subagent section string. """ n = max_concurrent - bash_available = "bash" in get_available_subagent_names() - available_subagents = ( - "- **general-purpose**: For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.\n- **bash**: For command execution (git, build, test, deploy operations)" - if bash_available - else "- **general-purpose**: For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.\n" - "- **bash**: Not available in the current sandbox configuration. Use direct file/web tools or switch to AioSandboxProvider for isolated shell access." - ) + available_names = get_available_subagent_names() + bash_available = "bash" in available_names + + # Dynamically build subagent type descriptions from registry (aligned with Codex's + # agent_type_description pattern where all registered roles are listed in the tool spec). + available_subagents = _build_available_subagents_description(available_names, bash_available) direct_tool_examples = "bash, ls, read_file, web_search, etc." if bash_available else "ls, read_file, web_search, etc." direct_execution_example = ( '# User asks: "Run the tests"\n# Thinking: Cannot decompose into parallel sub-tasks\n# → Execute directly\n\nbash("npm test") # Direct execution, not task()' diff --git a/backend/packages/harness/deerflow/config/subagents_config.py b/backend/packages/harness/deerflow/config/subagents_config.py index b5f885d5a..e7219284d 100644 --- a/backend/packages/harness/deerflow/config/subagents_config.py +++ b/backend/packages/harness/deerflow/config/subagents_config.py @@ -25,6 +25,47 @@ class SubagentOverrideConfig(BaseModel): min_length=1, description="Model name for this subagent (None = inherit from parent agent)", ) + skills: list[str] | None = Field( + default=None, + description="Skill names whitelist for this subagent (None = inherit all enabled skills, [] = no skills)", + ) + + +class CustomSubagentConfig(BaseModel): + """User-defined subagent type declared in config.yaml.""" + + description: str = Field( + description="When the lead agent should delegate to this subagent", + ) + system_prompt: str = Field( + description="System prompt that guides the subagent's behavior", + ) + tools: list[str] | None = Field( + default=None, + description="Tool names whitelist (None = inherit all tools from parent)", + ) + disallowed_tools: list[str] | None = Field( + default_factory=lambda: ["task", "ask_clarification", "present_files"], + description="Tool names to deny", + ) + skills: list[str] | None = Field( + default=None, + description="Skill names whitelist (None = inherit all enabled skills, [] = no skills)", + ) + model: str = Field( + default="inherit", + description="Model to use - 'inherit' uses parent's model", + ) + max_turns: int = Field( + default=50, + ge=1, + description="Maximum number of agent turns before stopping", + ) + timeout_seconds: int = Field( + default=900, + ge=1, + description="Maximum execution time in seconds", + ) class SubagentsAppConfig(BaseModel): @@ -44,6 +85,10 @@ class SubagentsAppConfig(BaseModel): default_factory=dict, description="Per-agent configuration overrides keyed by agent name", ) + custom_agents: dict[str, CustomSubagentConfig] = Field( + default_factory=dict, + description="User-defined subagent types keyed by agent name", + ) def get_timeout_for(self, agent_name: str) -> int: """Get the effective timeout for a specific agent. @@ -82,6 +127,20 @@ class SubagentsAppConfig(BaseModel): return self.max_turns return builtin_default + def get_skills_for(self, agent_name: str) -> list[str] | None: + """Get the skills override for a specific agent. + + Args: + agent_name: The name of the subagent. + + Returns: + Skill names whitelist if overridden, None otherwise (subagent will inherit all enabled skills). + """ + override = self.agents.get(agent_name) + if override is not None and override.skills is not None: + return override.skills + return None + _subagents_config: SubagentsAppConfig = SubagentsAppConfig() @@ -105,15 +164,20 @@ def load_subagents_config_from_dict(config_dict: dict) -> None: parts.append(f"max_turns={override.max_turns}") if override.model is not None: parts.append(f"model={override.model}") + if override.skills is not None: + parts.append(f"skills={override.skills}") if parts: overrides_summary[name] = ", ".join(parts) - if overrides_summary: + custom_agents_names = list(_subagents_config.custom_agents.keys()) + + if overrides_summary or custom_agents_names: logger.info( - "Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s", + "Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s, custom_agents=%s", _subagents_config.timeout_seconds, _subagents_config.max_turns, - overrides_summary, + overrides_summary or "none", + custom_agents_names or "none", ) else: logger.info( diff --git a/backend/packages/harness/deerflow/subagents/config.py b/backend/packages/harness/deerflow/subagents/config.py index 8554e7d4d..a2c961b9d 100644 --- a/backend/packages/harness/deerflow/subagents/config.py +++ b/backend/packages/harness/deerflow/subagents/config.py @@ -13,6 +13,8 @@ class SubagentConfig: system_prompt: The system prompt that guides the subagent's behavior. tools: Optional list of tool names to allow. If None, inherits all tools. disallowed_tools: Optional list of tool names to deny. + skills: Optional list of skill names to load. If None, inherits all enabled skills. + If an empty list, no skills are loaded. model: Model to use - 'inherit' uses parent's model. max_turns: Maximum number of agent turns before stopping. timeout_seconds: Maximum execution time in seconds (default: 900 = 15 minutes). @@ -23,6 +25,7 @@ class SubagentConfig: system_prompt: str tools: list[str] | None = None disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"]) + skills: list[str] | None = None model: str = "inherit" max_turns: int = 50 timeout_seconds: int = 900 diff --git a/backend/packages/harness/deerflow/subagents/executor.py b/backend/packages/harness/deerflow/subagents/executor.py index 5529bec2c..b42cebacf 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -13,7 +13,7 @@ from typing import Any from langchain.agents import create_agent from langchain.tools import BaseTool -from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from langchain_core.runnables import RunnableConfig from deerflow.agents.thread_state import SandboxState, ThreadDataState, ThreadState @@ -184,7 +184,63 @@ class SubagentExecutor: state_schema=ThreadState, ) - def _build_initial_state(self, task: str) -> dict[str, Any]: + async def _load_skill_messages(self) -> list[SystemMessage]: + """Load skill content as conversation items based on config.skills. + + Aligned with Codex's pattern: each subagent loads its own skills + per-session and injects them as conversation items (developer messages), + not as system prompt text. The config.skills whitelist controls which + skills are loaded: + - None: load all enabled skills + - []: no skills + - ["skill-a", "skill-b"]: only these skills + + Returns: + List of SystemMessages containing skill content. + """ + if self.config.skills is not None and len(self.config.skills) == 0: + logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} skills=[] — skipping skill loading") + return [] + + try: + from deerflow.skills.loader import load_skills + + # Use asyncio.to_thread to avoid blocking the event loop (LangGraph ASGI requirement) + all_skills = await asyncio.to_thread(load_skills, enabled_only=True) + logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} loaded {len(all_skills)} enabled skills from disk") + except Exception: + logger.warning(f"[trace={self.trace_id}] Failed to load skills for subagent {self.config.name}", exc_info=True) + return [] + + if not all_skills: + logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} no enabled skills found") + return [] + + # Filter by config.skills whitelist + if self.config.skills is not None: + allowed = set(self.config.skills) + skills = [s for s in all_skills if s.name in allowed] + else: + skills = all_skills + + if not skills: + return [] + + # Read each skill's SKILL.md content and create conversation items + messages = [] + for skill in skills: + try: + content = await asyncio.to_thread(skill.skill_file.read_text, encoding="utf-8") + content = content.strip() + if content: + messages.append(SystemMessage(content=f'\n{content}\n')) + logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} loaded skill: {skill.name}") + except Exception: + logger.debug(f"[trace={self.trace_id}] Failed to read skill {skill.name}", exc_info=True) + + return messages + + async def _build_initial_state(self, task: str) -> dict[str, Any]: """Build the initial state for agent execution. Args: @@ -193,8 +249,17 @@ class SubagentExecutor: Returns: Initial state dictionary. """ + # Load skills as conversation items (Codex pattern) + skill_messages = await self._load_skill_messages() + + messages: list = [] + # Skill content injected as developer/system messages before the task + messages.extend(skill_messages) + # Then the actual task + messages.append(HumanMessage(content=task)) + state: dict[str, Any] = { - "messages": [HumanMessage(content=task)], + "messages": messages, } # Pass through sandbox and thread data from parent @@ -230,7 +295,7 @@ class SubagentExecutor: try: agent = self._create_agent() - state = self._build_initial_state(task) + state = await self._build_initial_state(task) # Build config with thread_id for sandbox access and recursion limit run_config: RunnableConfig = { diff --git a/backend/packages/harness/deerflow/subagents/registry.py b/backend/packages/harness/deerflow/subagents/registry.py index e54f69f76..b34d7e9bd 100644 --- a/backend/packages/harness/deerflow/subagents/registry.py +++ b/backend/packages/harness/deerflow/subagents/registry.py @@ -10,53 +10,100 @@ from deerflow.subagents.config import SubagentConfig logger = logging.getLogger(__name__) +def _build_custom_subagent_config(name: str) -> SubagentConfig | None: + """Build a SubagentConfig from config.yaml custom_agents section. + + Args: + name: The name of the custom subagent. + + Returns: + SubagentConfig if found in custom_agents, None otherwise. + """ + from deerflow.config.subagents_config import get_subagents_app_config + + app_config = get_subagents_app_config() + custom = app_config.custom_agents.get(name) + if custom is None: + return None + + return SubagentConfig( + name=name, + description=custom.description, + system_prompt=custom.system_prompt, + tools=custom.tools, + disallowed_tools=custom.disallowed_tools, + skills=custom.skills, + model=custom.model, + max_turns=custom.max_turns, + timeout_seconds=custom.timeout_seconds, + ) + + def get_subagent_config(name: str) -> SubagentConfig | None: """Get a subagent configuration by name, with config.yaml overrides applied. + Resolution order (mirrors Codex's config layering): + 1. Built-in subagents (general-purpose, bash) + 2. Custom subagents from config.yaml custom_agents section + 3. Per-agent overrides from config.yaml agents section (timeout, max_turns, model, skills) + Args: name: The name of the subagent. Returns: SubagentConfig if found (with any config.yaml overrides applied), None otherwise. """ + # Step 1: Look up built-in, then fall back to custom_agents config = BUILTIN_SUBAGENTS.get(name) + if config is None: + config = _build_custom_subagent_config(name) if config is None: return None - # Apply runtime overrides (timeout, max_turns, model) from config.yaml + # Step 2: Apply per-agent overrides from config.yaml agents section. + # Only explicit per-agent overrides are applied here. Global defaults + # (timeout_seconds, max_turns at the top level) apply to built-in agents + # but must NOT override custom agents' own values — custom agents define + # their own defaults in the custom_agents section. # Lazy import to avoid circular deps. from deerflow.config.subagents_config import get_subagents_app_config app_config = get_subagents_app_config() - effective_timeout = app_config.get_timeout_for(name) - effective_max_turns = app_config.get_max_turns_for(name, config.max_turns) + is_builtin = name in BUILTIN_SUBAGENTS + agent_override = app_config.agents.get(name) overrides = {} - if effective_timeout != config.timeout_seconds: - logger.debug( - "Subagent '%s': timeout overridden by config.yaml (%ss -> %ss)", - name, - config.timeout_seconds, - effective_timeout, - ) - overrides["timeout_seconds"] = effective_timeout - if effective_max_turns != config.max_turns: - logger.debug( - "Subagent '%s': max_turns overridden by config.yaml (%s -> %s)", - name, - config.max_turns, - effective_max_turns, - ) - overrides["max_turns"] = effective_max_turns + + # Timeout: per-agent override > global default (builtins only) > config's own value + if agent_override is not None and agent_override.timeout_seconds is not None: + if agent_override.timeout_seconds != config.timeout_seconds: + logger.debug("Subagent '%s': timeout overridden (%ss -> %ss)", name, config.timeout_seconds, agent_override.timeout_seconds) + overrides["timeout_seconds"] = agent_override.timeout_seconds + elif is_builtin and app_config.timeout_seconds != config.timeout_seconds: + logger.debug("Subagent '%s': timeout from global default (%ss -> %ss)", name, config.timeout_seconds, app_config.timeout_seconds) + overrides["timeout_seconds"] = app_config.timeout_seconds + + # Max turns: per-agent override > global default (builtins only) > config's own value + if agent_override is not None and agent_override.max_turns is not None: + if agent_override.max_turns != config.max_turns: + logger.debug("Subagent '%s': max_turns overridden (%s -> %s)", name, config.max_turns, agent_override.max_turns) + overrides["max_turns"] = agent_override.max_turns + elif is_builtin and app_config.max_turns is not None and app_config.max_turns != config.max_turns: + logger.debug("Subagent '%s': max_turns from global default (%s -> %s)", name, config.max_turns, app_config.max_turns) + overrides["max_turns"] = app_config.max_turns + + # Model: per-agent override only (no global default for model) effective_model = app_config.get_model_for(name) if effective_model is not None and effective_model != config.model: - logger.debug( - "Subagent '%s': model overridden by config.yaml (%s -> %s)", - name, - config.model, - effective_model, - ) + logger.debug("Subagent '%s': model overridden (%s -> %s)", name, config.model, effective_model) overrides["model"] = effective_model + + # Skills: per-agent override only (no global default for skills) + effective_skills = app_config.get_skills_for(name) + if effective_skills is not None and effective_skills != config.skills: + logger.debug("Subagent '%s': skills overridden (%s -> %s)", name, config.skills, effective_skills) + overrides["skills"] = effective_skills + if overrides: config = replace(config, **overrides) @@ -67,18 +114,33 @@ def list_subagents() -> list[SubagentConfig]: """List all available subagent configurations (with config.yaml overrides applied). Returns: - List of all registered SubagentConfig instances. + List of all registered SubagentConfig instances (built-in + custom). """ - return [get_subagent_config(name) for name in BUILTIN_SUBAGENTS] + configs = [] + for name in get_subagent_names(): + config = get_subagent_config(name) + if config is not None: + configs.append(config) + return configs def get_subagent_names() -> list[str]: - """Get all available subagent names. + """Get all available subagent names (built-in + custom). Returns: List of subagent names. """ - return list(BUILTIN_SUBAGENTS.keys()) + names = list(BUILTIN_SUBAGENTS.keys()) + + # Merge custom_agents from config.yaml + from deerflow.config.subagents_config import get_subagents_app_config + + app_config = get_subagents_app_config() + for custom_name in app_config.custom_agents: + if custom_name not in names: + names.append(custom_name) + + return names def get_available_subagent_names() -> list[str]: @@ -87,11 +149,11 @@ def get_available_subagent_names() -> list[str]: Returns: List of subagent names visible to the current sandbox configuration. """ - names = list(BUILTIN_SUBAGENTS.keys()) + names = get_subagent_names() try: host_bash_allowed = is_host_bash_allowed() except Exception: - logger.debug("Could not determine host bash availability; exposing all built-in subagents") + logger.debug("Could not determine host bash availability; exposing all subagents") return names if not host_bash_allowed: diff --git a/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py b/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py index a42f8bbef..793ccb13a 100644 --- a/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py @@ -17,12 +17,14 @@ def setup_agent( soul: str, description: str, runtime: ToolRuntime, + skills: list[str] | None = None, ) -> Command: """Setup the custom DeerFlow agent. Args: soul: Full SOUL.md content defining the agent's personality and behavior. description: One-line description of what the agent does. + skills: Optional list of skill names this agent should use. None means use all enabled skills, empty list means no skills. """ agent_name: str | None = runtime.context.get("agent_name") if runtime.context else None @@ -41,6 +43,8 @@ def setup_agent( config_data: dict = {"name": agent_name} if description: config_data["description"] = description + if skills is not None: + config_data["skills"] = skills config_file = agent_dir / "config.yaml" with open(config_file, "w", encoding="utf-8") as f: diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index 437fb37ac..fbe41ded7 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -10,7 +10,6 @@ from langchain.tools import InjectedToolCallId, ToolRuntime, tool from langgraph.config import get_stream_writer from langgraph.typing import ContextT -from deerflow.agents.lead_agent.prompt import get_skills_prompt_section from deerflow.agents.thread_state import ThreadState from deerflow.sandbox.security import LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE, is_host_bash_allowed from deerflow.subagents import SubagentExecutor, get_available_subagent_names, get_subagent_config @@ -35,7 +34,7 @@ async def task_tool( - Handle complex multi-step tasks autonomously - Execute commands or operations in isolated contexts - Available subagent types depend on the active sandbox configuration: + Built-in subagent types: - **general-purpose**: A capable agent for complex, multi-step tasks that require both exploration and action. Use when the task requires complex reasoning, multiple dependent steps, or would benefit from isolated context. @@ -43,6 +42,11 @@ async def task_tool( available when host bash is explicitly allowed or when using an isolated shell sandbox such as `AioSandboxProvider`. + Additional custom subagent types may be defined in config.yaml under + `subagents.custom_agents`. Each custom type can have its own system prompt, + tools, skills, model, and timeout configuration. If an unknown subagent_type + is provided, the error message will list all available types. + When to use this tool: - Complex tasks requiring multiple steps or tools - Tasks that produce verbose output @@ -72,9 +76,9 @@ async def task_tool( # Build config overrides overrides: dict = {} - skills_section = get_skills_prompt_section() - if skills_section: - overrides["system_prompt"] = config.system_prompt + "\n\n" + skills_section + # Skills are loaded by SubagentExecutor per-session (aligned with Codex's pattern: + # each subagent loads its own skills based on config, injected as conversation items). + # No longer appended to system_prompt here. if max_turns is not None: overrides["max_turns"] = max_turns diff --git a/backend/tests/test_subagent_prompt_security.py b/backend/tests/test_subagent_prompt_security.py index d0e5a949f..015206877 100644 --- a/backend/tests/test_subagent_prompt_security.py +++ b/backend/tests/test_subagent_prompt_security.py @@ -25,7 +25,9 @@ def test_build_subagent_section_hides_bash_examples_when_unavailable(monkeypatch section = prompt_module._build_subagent_section(3) - assert "Not available in the current sandbox configuration" in section + # When bash is not available, it should not appear at all (aligned with Codex: + # unavailable roles are omitted, not listed as disabled) + assert "**bash**" not in section assert 'bash("npm test")' not in section assert 'read_file("/mnt/user-data/workspace/README.md")' in section assert "available tools (ls, read_file, web_search, etc.)" in section diff --git a/backend/tests/test_subagent_skills_config.py b/backend/tests/test_subagent_skills_config.py new file mode 100644 index 000000000..f121ccf25 --- /dev/null +++ b/backend/tests/test_subagent_skills_config.py @@ -0,0 +1,596 @@ +"""Tests for subagent per-agent skill configuration and custom subagent types. + +Covers: +- SubagentConfig.skills field +- SubagentOverrideConfig.skills field +- CustomSubagentConfig model validation +- SubagentsAppConfig.custom_agents and get_skills_for() +- Registry: custom agent lookup, skills override, merged available names +- Skills filter passthrough in task_tool config assembly +""" + +import pytest + +from deerflow.config.subagents_config import ( + CustomSubagentConfig, + SubagentOverrideConfig, + SubagentsAppConfig, + get_subagents_app_config, + load_subagents_config_from_dict, +) +from deerflow.subagents.config import SubagentConfig + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _reset_subagents_config(**kwargs) -> None: + """Reset global subagents config to a known state.""" + load_subagents_config_from_dict(kwargs) + + +# --------------------------------------------------------------------------- +# SubagentConfig.skills field +# --------------------------------------------------------------------------- + + +class TestSubagentConfigSkills: + def test_default_skills_is_none(self): + config = SubagentConfig(name="test", description="test", system_prompt="test") + assert config.skills is None + + def test_skills_whitelist(self): + config = SubagentConfig( + name="test", + description="test", + system_prompt="test", + skills=["data-analysis", "visualization"], + ) + assert config.skills == ["data-analysis", "visualization"] + + def test_skills_empty_list_means_no_skills(self): + config = SubagentConfig( + name="test", + description="test", + system_prompt="test", + skills=[], + ) + assert config.skills == [] + + +# --------------------------------------------------------------------------- +# SubagentOverrideConfig.skills field +# --------------------------------------------------------------------------- + + +class TestSubagentOverrideConfigSkills: + def test_default_skills_is_none(self): + override = SubagentOverrideConfig() + assert override.skills is None + + def test_skills_whitelist(self): + override = SubagentOverrideConfig(skills=["web-search", "data-analysis"]) + assert override.skills == ["web-search", "data-analysis"] + + def test_skills_empty_list(self): + override = SubagentOverrideConfig(skills=[]) + assert override.skills == [] + + def test_skills_coexists_with_other_fields(self): + override = SubagentOverrideConfig( + timeout_seconds=300, + model="gpt-5", + skills=["my-skill"], + ) + assert override.timeout_seconds == 300 + assert override.model == "gpt-5" + assert override.skills == ["my-skill"] + + +# --------------------------------------------------------------------------- +# CustomSubagentConfig model +# --------------------------------------------------------------------------- + + +class TestCustomSubagentConfig: + def test_minimal_valid(self): + config = CustomSubagentConfig( + description="A test agent", + system_prompt="You are a test agent.", + ) + assert config.description == "A test agent" + assert config.system_prompt == "You are a test agent." + assert config.tools is None + assert config.disallowed_tools == ["task", "ask_clarification", "present_files"] + assert config.skills is None + assert config.model == "inherit" + assert config.max_turns == 50 + assert config.timeout_seconds == 900 + + def test_full_configuration(self): + config = CustomSubagentConfig( + description="Data analysis specialist", + system_prompt="You are a data analysis subagent.", + tools=["bash", "read_file", "write_file"], + disallowed_tools=["task"], + skills=["data-analysis", "visualization"], + model="qwen3:32b", + max_turns=80, + timeout_seconds=600, + ) + assert config.tools == ["bash", "read_file", "write_file"] + assert config.skills == ["data-analysis", "visualization"] + assert config.model == "qwen3:32b" + assert config.max_turns == 80 + assert config.timeout_seconds == 600 + + def test_skills_empty_list_no_skills(self): + config = CustomSubagentConfig( + description="test", + system_prompt="test", + skills=[], + ) + assert config.skills == [] + + def test_rejects_zero_max_turns(self): + with pytest.raises(ValueError): + CustomSubagentConfig( + description="test", + system_prompt="test", + max_turns=0, + ) + + def test_rejects_zero_timeout(self): + with pytest.raises(ValueError): + CustomSubagentConfig( + description="test", + system_prompt="test", + timeout_seconds=0, + ) + + +# --------------------------------------------------------------------------- +# SubagentsAppConfig.custom_agents and get_skills_for() +# --------------------------------------------------------------------------- + + +class TestSubagentsAppConfigCustomAgents: + def test_default_custom_agents_empty(self): + config = SubagentsAppConfig() + assert config.custom_agents == {} + + def test_custom_agents_loaded(self): + config = SubagentsAppConfig( + custom_agents={ + "analysis": CustomSubagentConfig( + description="Analysis agent", + system_prompt="You analyze data.", + skills=["data-analysis"], + ), + } + ) + assert "analysis" in config.custom_agents + assert config.custom_agents["analysis"].skills == ["data-analysis"] + + def test_multiple_custom_agents(self): + config = SubagentsAppConfig( + custom_agents={ + "analysis": CustomSubagentConfig( + description="Analysis", + system_prompt="analyze", + skills=["data-analysis"], + ), + "researcher": CustomSubagentConfig( + description="Research", + system_prompt="research", + skills=["web-search"], + ), + } + ) + assert len(config.custom_agents) == 2 + + +class TestGetSkillsFor: + def test_returns_none_when_no_override(self): + config = SubagentsAppConfig() + assert config.get_skills_for("general-purpose") is None + assert config.get_skills_for("unknown") is None + + def test_returns_skills_whitelist(self): + config = SubagentsAppConfig( + agents={ + "general-purpose": SubagentOverrideConfig(skills=["web-search", "coding"]), + } + ) + assert config.get_skills_for("general-purpose") == ["web-search", "coding"] + + def test_returns_empty_list_for_no_skills(self): + config = SubagentsAppConfig( + agents={ + "bash": SubagentOverrideConfig(skills=[]), + } + ) + assert config.get_skills_for("bash") == [] + + def test_returns_none_for_unrelated_agent(self): + config = SubagentsAppConfig( + agents={ + "bash": SubagentOverrideConfig(skills=["web-search"]), + } + ) + assert config.get_skills_for("general-purpose") is None + + def test_returns_none_when_skills_not_set(self): + config = SubagentsAppConfig( + agents={ + "bash": SubagentOverrideConfig(timeout_seconds=300), + } + ) + assert config.get_skills_for("bash") is None + + +# --------------------------------------------------------------------------- +# load_subagents_config_from_dict with skills and custom_agents +# --------------------------------------------------------------------------- + + +class TestLoadSubagentsConfigWithSkills: + def teardown_method(self): + _reset_subagents_config() + + def test_load_with_skills_override(self): + load_subagents_config_from_dict( + { + "timeout_seconds": 900, + "agents": { + "general-purpose": {"skills": ["web-search", "data-analysis"]}, + }, + } + ) + cfg = get_subagents_app_config() + assert cfg.get_skills_for("general-purpose") == ["web-search", "data-analysis"] + + def test_load_with_empty_skills(self): + load_subagents_config_from_dict( + { + "timeout_seconds": 900, + "agents": { + "bash": {"skills": []}, + }, + } + ) + cfg = get_subagents_app_config() + assert cfg.get_skills_for("bash") == [] + + def test_load_with_custom_agents(self): + load_subagents_config_from_dict( + { + "timeout_seconds": 900, + "custom_agents": { + "analysis": { + "description": "Data analysis specialist", + "system_prompt": "You are a data analysis subagent.", + "skills": ["data-analysis", "visualization"], + "tools": ["bash", "read_file"], + "max_turns": 80, + "timeout_seconds": 600, + }, + }, + } + ) + cfg = get_subagents_app_config() + assert "analysis" in cfg.custom_agents + custom = cfg.custom_agents["analysis"] + assert custom.skills == ["data-analysis", "visualization"] + assert custom.tools == ["bash", "read_file"] + assert custom.max_turns == 80 + assert custom.timeout_seconds == 600 + + def test_load_with_both_overrides_and_custom(self): + load_subagents_config_from_dict( + { + "timeout_seconds": 900, + "agents": { + "general-purpose": {"skills": ["web-search"]}, + }, + "custom_agents": { + "analysis": { + "description": "Analysis", + "system_prompt": "Analyze.", + "skills": ["data-analysis"], + }, + }, + } + ) + cfg = get_subagents_app_config() + assert cfg.get_skills_for("general-purpose") == ["web-search"] + assert cfg.custom_agents["analysis"].skills == ["data-analysis"] + + +# --------------------------------------------------------------------------- +# Registry: custom agent lookup +# --------------------------------------------------------------------------- + + +class TestRegistryCustomAgentLookup: + def teardown_method(self): + _reset_subagents_config() + + def test_custom_agent_found(self): + from deerflow.subagents.registry import get_subagent_config + + load_subagents_config_from_dict( + { + "custom_agents": { + "analysis": { + "description": "Data analysis specialist", + "system_prompt": "You are a data analysis subagent.", + "skills": ["data-analysis"], + "tools": ["bash", "read_file"], + "max_turns": 80, + "timeout_seconds": 600, + }, + }, + } + ) + config = get_subagent_config("analysis") + assert config is not None + assert config.name == "analysis" + assert config.skills == ["data-analysis"] + assert config.tools == ["bash", "read_file"] + assert config.max_turns == 80 + assert config.timeout_seconds == 600 + assert config.model == "inherit" + + def test_custom_agent_not_found(self): + from deerflow.subagents.registry import get_subagent_config + + _reset_subagents_config() + assert get_subagent_config("nonexistent") is None + + def test_builtin_takes_priority_over_custom(self): + """If a custom agent has the same name as a builtin, builtin wins.""" + from deerflow.subagents.builtins import BUILTIN_SUBAGENTS + from deerflow.subagents.registry import get_subagent_config + + load_subagents_config_from_dict( + { + "custom_agents": { + "general-purpose": { + "description": "Custom override attempt", + "system_prompt": "Should not be used", + }, + }, + } + ) + config = get_subagent_config("general-purpose") + # Should get the builtin description, not the custom one + assert config.description == BUILTIN_SUBAGENTS["general-purpose"].description + + def test_custom_agent_with_override(self): + """Per-agent overrides also apply to custom agents.""" + from deerflow.subagents.registry import get_subagent_config + + load_subagents_config_from_dict( + { + "custom_agents": { + "analysis": { + "description": "Analysis", + "system_prompt": "Analyze.", + "timeout_seconds": 600, + }, + }, + "agents": { + "analysis": {"timeout_seconds": 300, "skills": ["overridden-skill"]}, + }, + } + ) + config = get_subagent_config("analysis") + assert config is not None + assert config.timeout_seconds == 300 # Override applied + assert config.skills == ["overridden-skill"] # Override applied + + +# --------------------------------------------------------------------------- +# Registry: skills override on builtin agents +# --------------------------------------------------------------------------- + + +class TestRegistrySkillsOverride: + def teardown_method(self): + _reset_subagents_config() + + def test_skills_override_applied_to_builtin(self): + from deerflow.subagents.registry import get_subagent_config + + load_subagents_config_from_dict( + { + "agents": { + "general-purpose": {"skills": ["web-search", "data-analysis"]}, + }, + } + ) + config = get_subagent_config("general-purpose") + assert config.skills == ["web-search", "data-analysis"] + + def test_empty_skills_override(self): + from deerflow.subagents.registry import get_subagent_config + + load_subagents_config_from_dict( + { + "agents": { + "bash": {"skills": []}, + }, + } + ) + config = get_subagent_config("bash") + assert config.skills == [] + + def test_no_skills_override_keeps_default(self): + from deerflow.subagents.registry import get_subagent_config + + _reset_subagents_config() + config = get_subagent_config("general-purpose") + assert config.skills is None # Default: inherit all + + def test_skills_override_does_not_mutate_builtin(self): + from deerflow.subagents.builtins import BUILTIN_SUBAGENTS + from deerflow.subagents.registry import get_subagent_config + + load_subagents_config_from_dict( + { + "agents": { + "general-purpose": {"skills": ["web-search"]}, + }, + } + ) + _ = get_subagent_config("general-purpose") + assert BUILTIN_SUBAGENTS["general-purpose"].skills is None + + +# --------------------------------------------------------------------------- +# Registry: get_available_subagent_names merges custom types +# --------------------------------------------------------------------------- + + +class TestRegistryAvailableNames: + def teardown_method(self): + _reset_subagents_config() + + def test_includes_builtin_names(self): + from deerflow.subagents.registry import get_subagent_names + + _reset_subagents_config() + names = get_subagent_names() + assert "general-purpose" in names + assert "bash" in names + + def test_includes_custom_names(self): + from deerflow.subagents.registry import get_subagent_names + + load_subagents_config_from_dict( + { + "custom_agents": { + "analysis": { + "description": "Analysis", + "system_prompt": "Analyze.", + }, + "researcher": { + "description": "Research", + "system_prompt": "Research.", + }, + }, + } + ) + names = get_subagent_names() + assert "general-purpose" in names + assert "bash" in names + assert "analysis" in names + assert "researcher" in names + + def test_no_duplicates_when_custom_name_matches_builtin(self): + from deerflow.subagents.registry import get_subagent_names + + load_subagents_config_from_dict( + { + "custom_agents": { + "general-purpose": { + "description": "Duplicate name", + "system_prompt": "test", + }, + }, + } + ) + names = get_subagent_names() + assert names.count("general-purpose") == 1 + + +# --------------------------------------------------------------------------- +# Registry: list_subagents includes custom agents +# --------------------------------------------------------------------------- + + +class TestRegistryListSubagentsWithCustom: + def teardown_method(self): + _reset_subagents_config() + + def test_list_includes_custom_agents(self): + from deerflow.subagents.registry import list_subagents + + load_subagents_config_from_dict( + { + "custom_agents": { + "analysis": { + "description": "Analysis", + "system_prompt": "Analyze.", + "skills": ["data-analysis"], + }, + }, + } + ) + configs = list_subagents() + names = {c.name for c in configs} + assert "general-purpose" in names + assert "bash" in names + assert "analysis" in names + + def test_list_custom_agent_has_correct_skills(self): + from deerflow.subagents.registry import list_subagents + + load_subagents_config_from_dict( + { + "custom_agents": { + "analysis": { + "description": "Analysis", + "system_prompt": "Analyze.", + "skills": ["data-analysis", "visualization"], + }, + }, + } + ) + by_name = {c.name: c for c in list_subagents()} + assert by_name["analysis"].skills == ["data-analysis", "visualization"] + + +# --------------------------------------------------------------------------- +# Skills filter passthrough: verify config.skills is used in task_tool assembly +# --------------------------------------------------------------------------- + + +class TestSkillsFilterPassthrough: + """Test that SubagentConfig.skills is correctly passed to get_skills_prompt_section.""" + + def test_none_skills_passes_none_to_prompt(self): + """When config.skills is None, available_skills=None should be passed (inherit all).""" + config = SubagentConfig( + name="test", + description="test", + system_prompt="test", + skills=None, + ) + # Verify: set(None) would raise, so the code must check for None first + available = set(config.skills) if config.skills is not None else None + assert available is None + + def test_empty_skills_passes_empty_set(self): + """When config.skills is [], available_skills=set() should be passed (no skills).""" + config = SubagentConfig( + name="test", + description="test", + system_prompt="test", + skills=[], + ) + available = set(config.skills) if config.skills is not None else None + assert available == set() + + def test_skills_whitelist_passes_correct_set(self): + """When config.skills has values, those should be passed as available_skills.""" + config = SubagentConfig( + name="test", + description="test", + system_prompt="test", + skills=["data-analysis", "web-search"], + ) + available = set(config.skills) if config.skills is not None else None + assert available == {"data-analysis", "web-search"} diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index 5251c69ed..1358c5bec 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -143,7 +143,7 @@ def test_task_tool_emits_running_and_completed_events(monkeypatch): monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "Skills Appendix") + monkeypatch.setattr(task_tool_module, "get_background_task_result", lambda _: next(responses)) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) @@ -165,7 +165,9 @@ def test_task_tool_emits_running_and_completed_events(monkeypatch): assert captured["executor_kwargs"]["thread_id"] == "thread-1" assert captured["executor_kwargs"]["parent_model"] == "ark-model" assert captured["executor_kwargs"]["config"].max_turns == 7 - assert "Skills Appendix" in captured["executor_kwargs"]["config"].system_prompt + # Skills are no longer appended to system_prompt; they are loaded per-session + # by SubagentExecutor and injected as conversation items (Codex pattern). + assert captured["executor_kwargs"]["config"].system_prompt == "Base system prompt" get_available_tools.assert_called_once_with(model_name="ark-model", groups=None, subagent_enabled=False) @@ -311,7 +313,7 @@ def test_task_tool_runtime_none_passes_groups_none(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -345,7 +347,7 @@ def test_task_tool_returns_timed_out_message(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -381,7 +383,7 @@ def test_task_tool_polling_safety_timeout(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -417,7 +419,7 @@ def test_cleanup_called_on_completed(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -457,7 +459,7 @@ def test_cleanup_called_on_failed(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -497,7 +499,7 @@ def test_cleanup_called_on_timed_out(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -544,7 +546,7 @@ def test_cleanup_not_called_on_polling_safety_timeout(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -597,7 +599,7 @@ def test_cleanup_scheduled_on_cancellation(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr(task_tool_module, "get_background_task_result", get_result) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", cancel_on_first_sleep) @@ -648,7 +650,7 @@ def test_cancelled_cleanup_stops_after_timeout(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -703,7 +705,7 @@ def test_cancellation_calls_request_cancel(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -761,7 +763,7 @@ def test_task_tool_returns_cancelled_message(monkeypatch): type("DummyExecutor", (), {"__init__": lambda self, **kwargs: None, "execute_async": lambda self, prompt, task_id=None: task_id}), ) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") + monkeypatch.setattr(task_tool_module, "get_background_task_result", lambda _: next(responses)) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) diff --git a/config.example.yaml b/config.example.yaml index fc3d9c8c1..1e649bba9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -577,15 +577,41 @@ sandbox: # # Optional global max-turn override for all subagents # # max_turns: 120 # -# # Optional per-agent overrides +# # Optional per-agent overrides (applies to both built-in and custom agents) # agents: # general-purpose: # timeout_seconds: 1800 # 30 minutes for complex multi-step tasks # max_turns: 160 # # model: qwen3:32b # Use a specific model (default: inherit from lead agent) +# # skills: # Skill whitelist (default: inherit all enabled skills) +# # - web-search +# # - data-analysis # bash: # timeout_seconds: 300 # 5 minutes for quick command execution # max_turns: 80 +# # skills: [] # No skills for bash agent +# +# # Custom subagent types: define specialized agents with their own prompts, +# # tools, skills, and model configuration. Custom agents are available via +# # the `task` tool alongside built-in types (general-purpose, bash). +# # custom_agents: +# # analysis: +# # description: "Data analysis specialist for processing datasets and generating insights" +# # system_prompt: | +# # You are a data analysis subagent. Focus on: +# # - Processing and analyzing datasets +# # - Generating visualizations +# # - Providing statistical insights +# # tools: # Tool whitelist (null = inherit all) +# # - bash +# # - read_file +# # - write_file +# # skills: # Skill whitelist (null = inherit all, [] = none) +# # - data-analysis +# # - visualization +# # model: inherit # 'inherit' uses parent's model +# # max_turns: 80 +# # timeout_seconds: 600 # # # Model override: by default, subagents inherit the lead agent's model. # # Set `model` to use a different model (e.g., a local Ollama model for cost savings). diff --git a/frontend/src/components/workspace/agents/agent-card.tsx b/frontend/src/components/workspace/agents/agent-card.tsx index 6b2a510bf..ce1d8ce18 100644 --- a/frontend/src/components/workspace/agents/agent-card.tsx +++ b/frontend/src/components/workspace/agents/agent-card.tsx @@ -79,14 +79,27 @@ export function AgentCard({ agent }: AgentCardProps) { )} - {agent.tool_groups && agent.tool_groups.length > 0 && ( + {(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
- {agent.tool_groups.map((group) => ( - + {agent.tool_groups?.map((group) => ( + {group} ))} + {agent.skills?.map((skill) => ( + + {skill} + + ))}
)} diff --git a/frontend/src/core/agents/types.ts b/frontend/src/core/agents/types.ts index 0ff0efff1..53e09ba66 100644 --- a/frontend/src/core/agents/types.ts +++ b/frontend/src/core/agents/types.ts @@ -3,6 +3,7 @@ export interface Agent { description: string; model: string | null; tool_groups: string[] | null; + skills: string[] | null; soul?: string | null; } @@ -11,6 +12,7 @@ export interface CreateAgentRequest { description?: string; model?: string | null; tool_groups?: string[] | null; + skills?: string[] | null; soul?: string; } @@ -18,5 +20,6 @@ export interface UpdateAgentRequest { description?: string | null; model?: string | null; tool_groups?: string[] | null; + skills?: string[] | null; soul?: string | null; } From cd12821134f39f06c3ecf0a3598351dc303dbd65 Mon Sep 17 00:00:00 2001 From: Willem Jiang Date: Fri, 24 Apr 2026 14:55:13 +0800 Subject: [PATCH 09/83] fix(backend): Updated the uv.lock with new added dependency --- backend/uv.lock | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/uv.lock b/backend/uv.lock index 7c248b253..716b7e07a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -686,6 +686,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "prompt-toolkit" }, { name = "pytest" }, { name = "ruff" }, ] @@ -708,6 +709,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "prompt-toolkit", specifier = ">=3.0.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "ruff", specifier = ">=0.14.11" }, ] @@ -2707,6 +2709,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/dd/f0183ed0145e58cf9d286c1b2c14f63ccee987a4ff79ac85acc31b5d86bd/primp-0.15.0-cp38-abi3-win_amd64.whl", hash = "sha256:aeb6bd20b06dfc92cfe4436939c18de88a58c640752cf7f30d9e4ae893cdec32", size = 3149967, upload-time = "2025-04-17T11:41:07.067Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -3960,6 +3974,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + [[package]] name = "webencodings" version = "0.5.1" From 80a7446fd68651df4ea70cd5d0cb6f86008a26f9 Mon Sep 17 00:00:00 2001 From: Willem Jiang Date: Fri, 24 Apr 2026 14:56:03 +0800 Subject: [PATCH 10/83] fix(backend): fix the unit test error in backend --- backend/tests/test_task_tool_core_logic.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index 1358c5bec..b39f09dad 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -201,7 +201,6 @@ def test_task_tool_propagates_tool_groups_to_subagent(monkeypatch): monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -242,7 +241,6 @@ def test_task_tool_no_tool_groups_passes_none(monkeypatch): monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") monkeypatch.setattr( task_tool_module, "get_background_task_result", @@ -281,7 +279,6 @@ def test_task_tool_runtime_none_passes_groups_none(monkeypatch): monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) - monkeypatch.setattr(task_tool_module, "get_skills_prompt_section", lambda: "") monkeypatch.setattr( task_tool_module, "get_background_task_result", From e8572b9d0c39fbfcf6b20fdf3d5871912345593a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?d=20=F0=9F=94=B9?= Date: Fri, 24 Apr 2026 16:00:14 +0800 Subject: [PATCH 11/83] fix(jina): log transient failures at WARNING without traceback (#2484) (#2485) The exception handler in JinaClient.crawl used logger.exception, which emits an ERROR-level record with the full httpx/httpcore/anyio traceback for every transient network failure (timeout, connection refused). Other search/crawl providers in the project log the same class of recoverable failures as a single line. One offline/slow-network session could produce dozens of multi-frame ERROR stack traces, drowning out real problems. Switch to logger.warning with a concise message that includes the exception type and its str, matching the style used elsewhere for recoverable transient failures (aio_sandbox, ddg, etc.). The exception type now also surfaces into the returned "Error: ..." string so callers retain diagnostic signal. Adds a regression test that asserts the log record is WARNING, carries no exc_info, and includes the exception class name. Co-authored-by: voidborne-d Co-authored-by: Willem Jiang --- .../deerflow/community/jina_ai/jina_client.py | 4 ++-- backend/tests/test_jina_client.py | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/backend/packages/harness/deerflow/community/jina_ai/jina_client.py b/backend/packages/harness/deerflow/community/jina_ai/jina_client.py index 3adc5458a..c4fc1ac81 100644 --- a/backend/packages/harness/deerflow/community/jina_ai/jina_client.py +++ b/backend/packages/harness/deerflow/community/jina_ai/jina_client.py @@ -38,6 +38,6 @@ class JinaClient: return response.text except Exception as e: - error_message = f"Request to Jina API failed: {str(e)}" - logger.exception(error_message) + error_message = f"Request to Jina API failed: {type(e).__name__}: {e}" + logger.warning(error_message) return f"Error: {error_message}" diff --git a/backend/tests/test_jina_client.py b/backend/tests/test_jina_client.py index 5a1d6f6fa..b1856e4ae 100644 --- a/backend/tests/test_jina_client.py +++ b/backend/tests/test_jina_client.py @@ -80,6 +80,28 @@ async def test_crawl_network_error(jina_client, monkeypatch): assert "failed" in result.lower() +@pytest.mark.anyio +async def test_crawl_transient_failure_logs_without_traceback(jina_client, monkeypatch, caplog): + """Transient network failures must log at WARNING without a traceback and include the exception type.""" + + async def mock_post(self, url, **kwargs): + raise httpx.ConnectTimeout("timed out") + + monkeypatch.setattr(httpx.AsyncClient, "post", mock_post) + + with caplog.at_level(logging.DEBUG, logger="deerflow.community.jina_ai.jina_client"): + result = await jina_client.crawl("https://example.com") + + jina_records = [r for r in caplog.records if r.name == "deerflow.community.jina_ai.jina_client"] + assert len(jina_records) == 1, f"expected exactly one log record, got {len(jina_records)}" + record = jina_records[0] + assert record.levelno == logging.WARNING, f"expected WARNING, got {record.levelname}" + assert record.exc_info is None, "transient failures must not attach a traceback" + assert "ConnectTimeout" in record.getMessage() + assert result.startswith("Error:") + assert "ConnectTimeout" in result + + @pytest.mark.anyio async def test_crawl_passes_headers(jina_client, monkeypatch): """Test that correct headers are sent.""" From 11f557a2c691bf77be76e5b1d914c1ddb55fde05 Mon Sep 17 00:00:00 2001 From: Airene Fang Date: Fri, 24 Apr 2026 17:06:55 +0800 Subject: [PATCH 12/83] feat(trace):Add run_name to the trace info for system agents. (#2492) * feat(trace): Add `run_name` to the trace info for suggestions and memory. before(in langsmith): CodexChatModel CodexChatModel lead_agent after: suggest_agent memory_agent lead_agent feat(trace): Add `run_name` to the trace info for suggestions and memory. before(in langsmith): CodexChatModel CodexChatModel lead_agent after: suggest_agent memory_agent lead_agent * feat(trace): Add `run_name` to the trace info for system agents. before(in langsmith): CodexChatModel CodexChatModel CodexChatModel CodexChatModel lead_agent after: suggest_agent title_agent security_agent memory_agent lead_agent * chore(code format):code format --------- Co-authored-by: Willem Jiang --- backend/app/gateway/routers/suggestions.py | 2 +- .../harness/deerflow/agents/memory/updater.py | 2 +- .../agents/middlewares/title_middleware.py | 2 +- .../deerflow/skills/security_scanner.py | 3 ++- backend/tests/test_memory_updater.py | 1 + backend/tests/test_security_scanner.py | 21 +++++++++++++++++++ backend/tests/test_suggestions_router.py | 6 ++++++ .../tests/test_title_middleware_core_logic.py | 1 + 8 files changed, 34 insertions(+), 4 deletions(-) diff --git a/backend/app/gateway/routers/suggestions.py b/backend/app/gateway/routers/suggestions.py index ac54e674d..bfda01491 100644 --- a/backend/app/gateway/routers/suggestions.py +++ b/backend/app/gateway/routers/suggestions.py @@ -121,7 +121,7 @@ async def generate_suggestions(thread_id: str, request: SuggestionsRequest) -> S try: model = create_chat_model(name=request.model_name, thinking_enabled=False) - response = await model.ainvoke([SystemMessage(content=system_instruction), HumanMessage(content=user_content)]) + response = await model.ainvoke([SystemMessage(content=system_instruction), HumanMessage(content=user_content)], config={"run_name": "suggest_agent"}) raw = _extract_response_text(response.content) suggestions = _parse_json_string_list(raw) or [] cleaned = [s.replace("\n", " ").strip() for s in suggestions if s.strip()] diff --git a/backend/packages/harness/deerflow/agents/memory/updater.py b/backend/packages/harness/deerflow/agents/memory/updater.py index 0966b8c48..7e782dcbc 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -409,7 +409,7 @@ class MemoryUpdater: current_memory, prompt = prepared model = self._get_model() - response = await model.ainvoke(prompt) + response = await model.ainvoke(prompt, config={"run_name": "memory_agent"}) return await asyncio.to_thread( self._finalize_update, current_memory=current_memory, diff --git a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py index dd131ac28..c17b46387 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -127,7 +127,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): model = create_chat_model(name=config.model_name, thinking_enabled=False) else: model = create_chat_model(thinking_enabled=False) - response = await model.ainvoke(prompt) + response = await model.ainvoke(prompt, config={"run_name": "title_agent"}) title = self._parse_title(response.content) if title: return {"title": title} diff --git a/backend/packages/harness/deerflow/skills/security_scanner.py b/backend/packages/harness/deerflow/skills/security_scanner.py index 51986cc71..a8fc90a4e 100644 --- a/backend/packages/harness/deerflow/skills/security_scanner.py +++ b/backend/packages/harness/deerflow/skills/security_scanner.py @@ -54,7 +54,8 @@ async def scan_skill_content(content: str, *, executable: bool = False, location [ {"role": "system", "content": rubric}, {"role": "user", "content": prompt}, - ] + ], + config={"run_name": "security_agent"}, ) parsed = _extract_json_object(str(getattr(response, "content", "") or "")) if parsed and parsed.get("decision") in {"allow", "warn", "block"}: diff --git a/backend/tests/test_memory_updater.py b/backend/tests/test_memory_updater.py index fce8cd0fb..37e81c471 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -598,6 +598,7 @@ class TestUpdateMemoryStructuredResponse: assert result is True model.ainvoke.assert_awaited_once() + assert model.ainvoke.await_args.kwargs["config"] == {"run_name": "memory_agent"} def test_correction_hint_injected_when_detected(self): updater = MemoryUpdater() diff --git a/backend/tests/test_security_scanner.py b/backend/tests/test_security_scanner.py index 4dcaa691c..088cb2c11 100644 --- a/backend/tests/test_security_scanner.py +++ b/backend/tests/test_security_scanner.py @@ -5,6 +5,27 @@ import pytest from deerflow.skills.security_scanner import scan_skill_content +@pytest.mark.anyio +async def test_scan_skill_content_passes_run_name_to_model(monkeypatch): + config = SimpleNamespace(skill_evolution=SimpleNamespace(moderation_model_name=None)) + fake_response = SimpleNamespace(content='{"decision":"allow","reason":"ok"}') + + class FakeModel: + async def ainvoke(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + return fake_response + + model = FakeModel() + monkeypatch.setattr("deerflow.skills.security_scanner.get_app_config", lambda: config) + monkeypatch.setattr("deerflow.skills.security_scanner.create_chat_model", lambda **kwargs: model) + + result = await scan_skill_content("---\nname: demo-skill\ndescription: demo\n---\n", executable=False) + + assert result.decision == "allow" + assert model.kwargs["config"] == {"run_name": "security_agent"} + + @pytest.mark.anyio async def test_scan_skill_content_blocks_when_model_unavailable(monkeypatch): config = SimpleNamespace(skill_evolution=SimpleNamespace(moderation_model_name=None)) diff --git a/backend/tests/test_suggestions_router.py b/backend/tests/test_suggestions_router.py index fee07dd44..0e70b45d6 100644 --- a/backend/tests/test_suggestions_router.py +++ b/backend/tests/test_suggestions_router.py @@ -49,6 +49,8 @@ def test_generate_suggestions_parses_and_limits(monkeypatch): result = asyncio.run(suggestions.generate_suggestions("t1", req)) assert result.suggestions == ["Q1", "Q2", "Q3"] + fake_model.ainvoke.assert_awaited_once() + assert fake_model.ainvoke.await_args.kwargs["config"] == {"run_name": "suggest_agent"} def test_generate_suggestions_parses_list_block_content(monkeypatch): @@ -67,6 +69,8 @@ def test_generate_suggestions_parses_list_block_content(monkeypatch): result = asyncio.run(suggestions.generate_suggestions("t1", req)) assert result.suggestions == ["Q1", "Q2"] + fake_model.ainvoke.assert_awaited_once() + assert fake_model.ainvoke.await_args.kwargs["config"] == {"run_name": "suggest_agent"} def test_generate_suggestions_parses_output_text_block_content(monkeypatch): @@ -85,6 +89,8 @@ def test_generate_suggestions_parses_output_text_block_content(monkeypatch): result = asyncio.run(suggestions.generate_suggestions("t1", req)) assert result.suggestions == ["Q1", "Q2"] + fake_model.ainvoke.assert_awaited_once() + assert fake_model.ainvoke.await_args.kwargs["config"] == {"run_name": "suggest_agent"} def test_generate_suggestions_returns_empty_on_model_error(monkeypatch): diff --git a/backend/tests/test_title_middleware_core_logic.py b/backend/tests/test_title_middleware_core_logic.py index ce7376e2e..684de2345 100644 --- a/backend/tests/test_title_middleware_core_logic.py +++ b/backend/tests/test_title_middleware_core_logic.py @@ -93,6 +93,7 @@ class TestTitleMiddlewareCoreLogic: assert title == "短标题" title_middleware_module.create_chat_model.assert_called_once_with(thinking_enabled=False) model.ainvoke.assert_awaited_once() + assert model.ainvoke.await_args.kwargs["config"] == {"run_name": "title_agent"} def test_generate_title_normalizes_structured_message_content(self, monkeypatch): _set_test_title_config(max_chars=20) From 3a61126824e9542b630d4181bac50fd101f55010 Mon Sep 17 00:00:00 2001 From: He Wang Date: Fri, 24 Apr 2026 17:09:41 +0800 Subject: [PATCH 13/83] fix: keep debug.py interactive terminal free from background log noise (#2466) * fix(debug): keep terminal clean by redirecting all logs to file - Redirect all logs to debug.log file to prevent background task logs from interfering with interactive terminal prompts - Honor AppConfig.log_level setting instead of hard-coding to INFO - Make logging setup idempotent by clearing pre-existing handlers - Defer deerflow imports until after logging is configured to ensure import-time side effects are captured in debug.log - Display active log level in startup banner - Add prompt_toolkit installation tip for enhanced readline support Made-with: Cursor * attaching the file handler before importing/calling get_app_config() Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitignore | 1 + backend/debug.py | 68 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 4e46d2e71..0076848e0 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ coverage/ skills/custom/* logs/ log/ +debug.log # Local git hooks (keep only on this machine, do not push) .githooks/ diff --git a/backend/debug.py b/backend/debug.py index 2031557c1..3e0694cef 100644 --- a/backend/debug.py +++ b/backend/debug.py @@ -19,10 +19,6 @@ import asyncio import logging from dotenv import load_dotenv -from langchain_core.messages import HumanMessage -from langgraph.runtime import Runtime - -from deerflow.agents import make_lead_agent try: from prompt_toolkit import PromptSession @@ -34,18 +30,67 @@ except ImportError: load_dotenv() -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", -) +_LOG_FMT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +_LOG_DATEFMT = "%Y-%m-%d %H:%M:%S" + + +def _logging_level_from_config(name: str) -> int: + """Map ``config.yaml`` ``log_level`` string to a ``logging`` level constant.""" + mapping = logging.getLevelNamesMapping() + return mapping.get((name or "info").strip().upper(), logging.INFO) + + +def _setup_logging(log_level: str) -> None: + """Send application logs to ``debug.log`` at *log_level*; do not print them on the console. + + Idempotent: any pre-existing handlers on the root logger (e.g. installed by + ``logging.basicConfig`` in transitively imported modules) are removed so the + debug session output only lands in ``debug.log``. + """ + level = _logging_level_from_config(log_level) + root = logging.root + for h in list(root.handlers): + root.removeHandler(h) + h.close() + root.setLevel(level) + + file_handler = logging.FileHandler("debug.log", mode="a", encoding="utf-8") + file_handler.setLevel(level) + file_handler.setFormatter(logging.Formatter(_LOG_FMT, datefmt=_LOG_DATEFMT)) + root.addHandler(file_handler) + + +def _update_logging_level(log_level: str) -> None: + """Update the root logger and existing handlers to *log_level*.""" + level = _logging_level_from_config(log_level) + root = logging.root + root.setLevel(level) + for handler in root.handlers: + handler.setLevel(level) async def main(): + # Install file logging first so warnings emitted while loading config do not + # leak onto the interactive terminal via Python's lastResort handler. + _setup_logging("info") + + from deerflow.config import get_app_config + + app_config = get_app_config() + _update_logging_level(app_config.log_level) + + # Delay the rest of the deerflow imports until *after* logging is installed + # so that any import-time side effects (e.g. deerflow.agents starts a + # background skill-loader thread on import) emit logs to debug.log instead + # of leaking onto the interactive terminal via Python's lastResort handler. + from langchain_core.messages import HumanMessage + from langgraph.runtime import Runtime + + from deerflow.agents import make_lead_agent + from deerflow.mcp import initialize_mcp_tools + # Initialize MCP tools at startup try: - from deerflow.mcp import initialize_mcp_tools - await initialize_mcp_tools() except Exception as e: print(f"Warning: Failed to initialize MCP tools: {e}") @@ -71,6 +116,7 @@ async def main(): print("=" * 50) print("Lead Agent Debug Mode") print("Type 'quit' or 'exit' to stop") + print(f"Logs: debug.log (log_level={app_config.log_level})") if not _HAS_PROMPT_TOOLKIT: print("Tip: `uv sync --group dev` to enable arrow-key & history support") print("=" * 50) From c2332bb7908e5774763c49cfb77a229832f4f57b Mon Sep 17 00:00:00 2001 From: Admire <64821731+LittleChenLiya@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:29:55 +0800 Subject: [PATCH 14/83] fix memory settings layout overflow (#2420) Co-authored-by: Willem Jiang --- .../workspace/settings/memory-settings-page.tsx | 16 ++++++++-------- .../workspace/settings/settings-dialog.tsx | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/workspace/settings/memory-settings-page.tsx b/frontend/src/components/workspace/settings/memory-settings-page.tsx index ae15fa47d..ce01d3c27 100644 --- a/frontend/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend/src/components/workspace/settings/memory-settings-page.tsx @@ -555,8 +555,8 @@ export function MemorySettingsPage() { ) : null} -
-
+
+
setQuery(event.target.value)} @@ -579,7 +579,7 @@ export function MemorySettingsPage() {
-
+
+
{summaryReadOnly}
{summariesToMarkdown(memory, filteredSectionGroups, t)} @@ -638,7 +638,7 @@ export function MemorySettingsPage() { ) : null} {shouldRenderFactsBlock ? ( -
+

{t.settings.memory.markdown.facts} @@ -661,7 +661,7 @@ export function MemorySettingsPage() { key={fact.id} className="flex flex-col gap-3 rounded-md border p-3 sm:flex-row sm:items-start sm:justify-between" > -
+
@@ -697,7 +697,7 @@ export function MemorySettingsPage() { )}
-

+

{fact.content}

diff --git a/frontend/src/components/workspace/settings/settings-dialog.tsx b/frontend/src/components/workspace/settings/settings-dialog.tsx index 3a111564b..fadc25fa6 100644 --- a/frontend/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend/src/components/workspace/settings/settings-dialog.tsx @@ -97,7 +97,7 @@ export function SettingsDialog(props: SettingsDialogProps) { {t.settings.description}

-
+
- -
+ +
{activeSection === "appearance" && } {activeSection === "memory" && } {activeSection === "tools" && } From f9ff3a698ddc64dc8dbc7404e0a2f7ef886ef0f8 Mon Sep 17 00:00:00 2001 From: Nan Gao <88081804+ggnnggez@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:19:46 +0200 Subject: [PATCH 15/83] fix(middleware): avoid rescuing non-skill tool outputs during summarization (#2458) * fix(middelware): narrow skill rescue to skill-related tool outputs * fix(summarization): address skill rescue review feedback * fix: wire summarization skill rescue config * fix: remove dead skill tool helper * fix(lint): fix format --------- Co-authored-by: Willem Jiang --- backend/docs/summarization.md | 28 ++ .../deerflow/agents/lead_agent/agent.py | 19 +- .../middlewares/summarization_middleware.py | 206 ++++++++++- .../deerflow/config/summarization_config.py | 19 + .../tests/test_lead_agent_model_resolution.py | 24 ++ .../tests/test_summarization_middleware.py | 327 +++++++++++++++++- config.example.yaml | 15 +- 7 files changed, 629 insertions(+), 9 deletions(-) diff --git a/backend/docs/summarization.md b/backend/docs/summarization.md index ca1e8dda1..773d27e3d 100644 --- a/backend/docs/summarization.md +++ b/backend/docs/summarization.md @@ -41,6 +41,13 @@ summarization: # Custom summary prompt (optional) summary_prompt: null + + # Tool names treated as skill file reads for skill rescue + skill_file_read_tool_names: + - read_file + - read + - view + - cat ``` ### Configuration Options @@ -125,6 +132,26 @@ keep: - **Default**: `null` (uses LangChain's default prompt) - **Description**: Custom prompt template for generating summaries. The prompt should guide the model to extract the most important context. +#### `preserve_recent_skill_count` +- **Type**: Integer (≥ 0) +- **Default**: `5` +- **Description**: Number of most-recently-loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`, e.g. `/mnt/skills/...`) that are rescued from summarization. Prevents the agent from losing skill instructions after compression. Set to `0` to disable skill rescue entirely. + +#### `preserve_recent_skill_tokens` +- **Type**: Integer (≥ 0) +- **Default**: `25000` +- **Description**: Total token budget reserved for rescued skill reads. Once this budget is exhausted, older skill bundles are allowed to be summarized. + +#### `preserve_recent_skill_tokens_per_skill` +- **Type**: Integer (≥ 0) +- **Default**: `5000` +- **Description**: Per-skill token cap. Any individual skill read whose tool result exceeds this size is not rescued (it falls through to the summarizer like ordinary content). + +#### `skill_file_read_tool_names` +- **Type**: List of strings +- **Default**: `["read_file", "read", "view", "cat"]` +- **Description**: Tool names treated as skill file reads during summarization rescue. A tool call is only eligible for skill rescue when its name appears in this list and its target path is under `skills.container_path`. + **Default Prompt Behavior:** The default LangChain prompt instructs the model to: - Extract highest quality/most relevant context @@ -147,6 +174,7 @@ The default LangChain prompt instructs the model to: - A single summary message is added - Recent messages are preserved 6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together +7. **Skill Rescue**: Before the summary is generated, the most recently loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`) are lifted out of the summarization set and prepended to the preserved tail. Selection walks newest-first under three budgets: `preserve_recent_skill_count`, `preserve_recent_skill_tokens`, and `preserve_recent_skill_tokens_per_skill`. The triggering AIMessage and all of its paired ToolMessages move together so tool_call ↔ tool_result pairing stays intact. ### Token Counting diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index de3ff6766..f17aab6ce 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -84,7 +84,24 @@ def _create_summarization_middleware() -> DeerFlowSummarizationMiddleware | None if get_memory_config().enabled: hooks.append(memory_flush_hook) - return DeerFlowSummarizationMiddleware(**kwargs, before_summarization=hooks) + # The logic below relies on two assumptions holding true: this factory is + # the sole entry point for DeerFlowSummarizationMiddleware, and the runtime + # config is not expected to change after startup. + try: + skills_container_path = get_app_config().skills.container_path or "/mnt/skills" + except Exception: + logger.exception("Failed to resolve skills container path; falling back to default") + skills_container_path = "/mnt/skills" + + return DeerFlowSummarizationMiddleware( + **kwargs, + skills_container_path=skills_container_path, + skill_file_read_tool_names=config.skill_file_read_tool_names, + before_summarization=hooks, + preserve_recent_skill_count=config.preserve_recent_skill_count, + preserve_recent_skill_tokens=config.preserve_recent_skill_tokens, + preserve_recent_skill_tokens_per_skill=config.preserve_recent_skill_tokens_per_skill, + ) def _create_todo_list_middleware(is_plan_mode: bool) -> TodoMiddleware | None: diff --git a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py index fba44c215..651b64a72 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @@ -3,12 +3,13 @@ from __future__ import annotations import logging +from collections.abc import Collection from dataclasses import dataclass -from typing import Protocol, runtime_checkable +from typing import Any, Protocol, runtime_checkable from langchain.agents import AgentState from langchain.agents.middleware import SummarizationMiddleware -from langchain_core.messages import AnyMessage, RemoveMessage +from langchain_core.messages import AIMessage, AnyMessage, RemoveMessage, ToolMessage from langgraph.config import get_config from langgraph.graph.message import REMOVE_ALL_MESSAGES from langgraph.runtime import Runtime @@ -58,17 +59,63 @@ def _resolve_agent_name(runtime: Runtime) -> str | None: return agent_name +def _tool_call_path(tool_call: dict[str, Any]) -> str | None: + """Best-effort extraction of a file path argument from a read_file-like tool call.""" + args = tool_call.get("args") or {} + if not isinstance(args, dict): + return None + for key in ("path", "file_path", "filepath"): + value = args.get(key) + if isinstance(value, str) and value: + return value + return None + + +def _clone_ai_message( + message: AIMessage, + tool_calls: list[dict[str, Any]], + *, + content: Any | None = None, +) -> AIMessage: + """Clone an AIMessage while replacing its tool_calls list and optional content.""" + update: dict[str, Any] = {"tool_calls": tool_calls} + if content is not None: + update["content"] = content + return message.model_copy(update=update) + + +@dataclass +class _SkillBundle: + """Skill-related tool calls and tool results associated with one AIMessage.""" + + ai_index: int + skill_tool_indices: tuple[int, ...] + skill_tool_call_ids: frozenset[str] + skill_tool_tokens: int + skill_key: str + + class DeerFlowSummarizationMiddleware(SummarizationMiddleware): - """Summarization middleware with pre-compression hook dispatch.""" + """Summarization middleware with pre-compression hook dispatch and skill rescue.""" def __init__( self, *args, + skills_container_path: str | None = None, + skill_file_read_tool_names: Collection[str] | None = None, before_summarization: list[BeforeSummarizationHook] | None = None, + preserve_recent_skill_count: int = 5, + preserve_recent_skill_tokens: int = 25_000, + preserve_recent_skill_tokens_per_skill: int = 5_000, **kwargs, ) -> None: super().__init__(*args, **kwargs) + self._skills_container_path = skills_container_path or "/mnt/skills" + self._skill_file_read_tool_names = frozenset(skill_file_read_tool_names or {"read_file", "read", "view", "cat"}) self._before_summarization_hooks = before_summarization or [] + self._preserve_recent_skill_count = max(0, preserve_recent_skill_count) + self._preserve_recent_skill_tokens = max(0, preserve_recent_skill_tokens) + self._preserve_recent_skill_tokens_per_skill = max(0, preserve_recent_skill_tokens_per_skill) def before_model(self, state: AgentState, runtime: Runtime) -> dict | None: return self._maybe_summarize(state, runtime) @@ -88,7 +135,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): if cutoff_index <= 0: return None - messages_to_summarize, preserved_messages = self._partition_messages(messages, cutoff_index) + messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index) self._fire_hooks(messages_to_summarize, preserved_messages, runtime) summary = self._create_summary(messages_to_summarize) new_messages = self._build_new_messages(summary) @@ -113,7 +160,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): if cutoff_index <= 0: return None - messages_to_summarize, preserved_messages = self._partition_messages(messages, cutoff_index) + messages_to_summarize, preserved_messages = self._partition_with_skill_rescue(messages, cutoff_index) self._fire_hooks(messages_to_summarize, preserved_messages, runtime) summary = await self._acreate_summary(messages_to_summarize) new_messages = self._build_new_messages(summary) @@ -126,6 +173,155 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): ] } + def _partition_with_skill_rescue( + self, + messages: list[AnyMessage], + cutoff_index: int, + ) -> tuple[list[AnyMessage], list[AnyMessage]]: + """Partition like the parent, then rescue recently-loaded skill bundles.""" + to_summarize, preserved = self._partition_messages(messages, cutoff_index) + + if self._preserve_recent_skill_count == 0 or self._preserve_recent_skill_tokens == 0 or not to_summarize: + return to_summarize, preserved + + try: + bundles = self._find_skill_bundles(to_summarize, self._skills_container_path) + except Exception: + logger.exception("Skill-preserving summarization rescue failed; falling back to default partition") + return to_summarize, preserved + + if not bundles: + return to_summarize, preserved + + rescue_bundles = self._select_bundles_to_rescue(bundles) + if not rescue_bundles: + return to_summarize, preserved + + bundles_by_ai_index = {bundle.ai_index: bundle for bundle in rescue_bundles} + rescue_tool_indices = {idx for bundle in rescue_bundles for idx in bundle.skill_tool_indices} + rescued: list[AnyMessage] = [] + remaining: list[AnyMessage] = [] + for i, msg in enumerate(to_summarize): + bundle = bundles_by_ai_index.get(i) + if bundle is not None and isinstance(msg, AIMessage): + rescued_tool_calls = [tc for tc in msg.tool_calls if tc.get("id") in bundle.skill_tool_call_ids] + remaining_tool_calls = [tc for tc in msg.tool_calls if tc.get("id") not in bundle.skill_tool_call_ids] + + if rescued_tool_calls: + rescued.append(_clone_ai_message(msg, rescued_tool_calls, content="")) + if remaining_tool_calls or msg.content: + remaining.append(_clone_ai_message(msg, remaining_tool_calls)) + continue + + if i in rescue_tool_indices: + rescued.append(msg) + continue + + remaining.append(msg) + + return remaining, rescued + preserved + + def _find_skill_bundles( + self, + messages: list[AnyMessage], + skills_root: str, + ) -> list[_SkillBundle]: + """Locate AIMessage + paired ToolMessage groups that load skill files.""" + bundles: list[_SkillBundle] = [] + n = len(messages) + i = 0 + while i < n: + msg = messages[i] + if not (isinstance(msg, AIMessage) and msg.tool_calls): + i += 1 + continue + + tool_calls = list(msg.tool_calls) + skill_paths_by_id: dict[str, str] = {} + for tc in tool_calls: + if self._is_skill_tool_call(tc, skills_root): + tc_id = tc.get("id") + path = _tool_call_path(tc) + if tc_id and path: + skill_paths_by_id[tc_id] = path + + if not skill_paths_by_id: + i += 1 + continue + + skill_tool_tokens = 0 + skill_key_parts: list[str] = [] + skill_tool_indices: list[int] = [] + matched_skill_call_ids: set[str] = set() + + j = i + 1 + while j < n and isinstance(messages[j], ToolMessage): + j += 1 + + for k in range(i + 1, j): + tool_msg = messages[k] + if isinstance(tool_msg, ToolMessage) and tool_msg.tool_call_id in skill_paths_by_id: + skill_tool_tokens += self.token_counter([tool_msg]) + skill_key_parts.append(skill_paths_by_id[tool_msg.tool_call_id]) + skill_tool_indices.append(k) + matched_skill_call_ids.add(tool_msg.tool_call_id) + + if not skill_tool_indices: + i = j + continue + + bundles.append( + _SkillBundle( + ai_index=i, + skill_tool_indices=tuple(skill_tool_indices), + skill_tool_call_ids=frozenset(matched_skill_call_ids), + skill_tool_tokens=skill_tool_tokens, + skill_key="|".join(sorted(skill_key_parts)), + ) + ) + i = j + + return bundles + + def _select_bundles_to_rescue(self, bundles: list[_SkillBundle]) -> list[_SkillBundle]: + """Pick bundles to keep, walking newest-first under count/token budgets.""" + selected: list[_SkillBundle] = [] + if not bundles: + return selected + + seen_skill_keys: set[str] = set() + total_tokens = 0 + kept = 0 + + for bundle in reversed(bundles): + if kept >= self._preserve_recent_skill_count: + break + if bundle.skill_key in seen_skill_keys: + continue + if bundle.skill_tool_tokens > self._preserve_recent_skill_tokens_per_skill: + continue + if total_tokens + bundle.skill_tool_tokens > self._preserve_recent_skill_tokens: + continue + + selected.append(bundle) + total_tokens += bundle.skill_tool_tokens + kept += 1 + seen_skill_keys.add(bundle.skill_key) + + selected.reverse() + return selected + + def _is_skill_tool_call(self, tool_call: dict[str, Any], skills_root: str) -> bool: + """Return True when ``tool_call`` reads a file under the configured skills root.""" + name = tool_call.get("name") or "" + if name not in self._skill_file_read_tool_names: + return False + path = _tool_call_path(tool_call) + if not path: + return False + normalized_root = skills_root.rstrip("/") + return path == normalized_root or path.startswith(normalized_root + "/") + def _fire_hooks( self, messages_to_summarize: list[AnyMessage], diff --git a/backend/packages/harness/deerflow/config/summarization_config.py b/backend/packages/harness/deerflow/config/summarization_config.py index f132e58cd..fab268ec5 100644 --- a/backend/packages/harness/deerflow/config/summarization_config.py +++ b/backend/packages/harness/deerflow/config/summarization_config.py @@ -51,6 +51,25 @@ class SummarizationConfig(BaseModel): default=None, description="Custom prompt template for generating summaries. If not provided, uses the default LangChain prompt.", ) + preserve_recent_skill_count: int = Field( + default=5, + ge=0, + description="Number of most-recently-loaded skill files to exclude from summarization. Set to 0 to disable skill preservation.", + ) + preserve_recent_skill_tokens: int = Field( + default=25000, + ge=0, + description="Total token budget reserved for recently-loaded skill files that must be preserved across summarization.", + ) + preserve_recent_skill_tokens_per_skill: int = Field( + default=5000, + ge=0, + description="Per-skill token cap when preserving skill files across summarization. Skill reads above this size are not rescued.", + ) + skill_file_read_tool_names: list[str] = Field( + default_factory=lambda: ["read_file", "read", "view", "cat"], + description="Tool names treated as skill file reads when preserving recently-loaded skills across summarization.", + ) # Global configuration instance diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index 12a4d0143..dc95dc4da 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -207,3 +207,27 @@ def test_create_summarization_middleware_registers_memory_flush_hook_when_memory lead_agent_module._create_summarization_middleware() assert captured["before_summarization"] == [lead_agent_module.memory_flush_hook] + + +def test_create_summarization_middleware_passes_skill_read_tool_names(monkeypatch): + app_config = _make_app_config([_make_model("default-model", supports_thinking=False)]) + monkeypatch.setattr( + lead_agent_module, + "get_summarization_config", + lambda: SummarizationConfig(enabled=True, skill_file_read_tool_names=["read_file", "cat"]), + ) + monkeypatch.setattr(lead_agent_module, "get_memory_config", lambda: MemoryConfig(enabled=False)) + monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) + monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object()) + + captured: dict[str, object] = {} + + def _fake_middleware(**kwargs): + captured.update(kwargs) + return kwargs + + monkeypatch.setattr(lead_agent_module, "DeerFlowSummarizationMiddleware", _fake_middleware) + + lead_agent_module._create_summarization_middleware() + + assert captured["skill_file_read_tool_names"] == ["read_file", "cat"] diff --git a/backend/tests/test_summarization_middleware.py b/backend/tests/test_summarization_middleware.py index d327c94c4..79ca8b01c 100644 --- a/backend/tests/test_summarization_middleware.py +++ b/backend/tests/test_summarization_middleware.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage +from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, ToolMessage from deerflow.agents.memory.summarization_hook import memory_flush_hook from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, SummarizationEvent @@ -29,7 +29,16 @@ def _runtime(thread_id: str | None = "thread-1", agent_name: str | None = None) return SimpleNamespace(context=context) -def _middleware(*, before_summarization=None, trigger=("messages", 4), keep=("messages", 2)) -> DeerFlowSummarizationMiddleware: +def _middleware( + *, + before_summarization=None, + trigger=("messages", 4), + keep=("messages", 2), + skill_file_read_tool_names=None, + preserve_recent_skill_count: int = 0, + preserve_recent_skill_tokens: int = 0, + preserve_recent_skill_tokens_per_skill: int = 0, +) -> DeerFlowSummarizationMiddleware: model = MagicMock() model.invoke.return_value = SimpleNamespace(text="compressed summary") return DeerFlowSummarizationMiddleware( @@ -38,9 +47,34 @@ def _middleware(*, before_summarization=None, trigger=("messages", 4), keep=("me keep=keep, token_counter=len, before_summarization=before_summarization, + skill_file_read_tool_names=skill_file_read_tool_names, + preserve_recent_skill_count=preserve_recent_skill_count, + preserve_recent_skill_tokens=preserve_recent_skill_tokens, + preserve_recent_skill_tokens_per_skill=preserve_recent_skill_tokens_per_skill, ) +def _skill_read_call(tool_id: str, skill: str) -> dict: + return { + "name": "read_file", + "id": tool_id, + "args": {"path": f"/mnt/skills/public/{skill}/SKILL.md"}, + } + + +def _skill_conversation() -> list: + return [ + HumanMessage(content="u1"), + AIMessage(content="", tool_calls=[_skill_read_call("t1", "alpha")]), + ToolMessage(content="alpha skill body", tool_call_id="t1"), + HumanMessage(content="u2"), + AIMessage(content="", tool_calls=[_skill_read_call("t2", "beta")]), + ToolMessage(content="beta skill body", tool_call_id="t2"), + HumanMessage(content="u3"), + AIMessage(content="final"), + ] + + def test_before_summarization_hook_receives_messages_before_compression() -> None: captured: list[SummarizationEvent] = [] middleware = _middleware(before_summarization=[captured.append]) @@ -167,6 +201,295 @@ def test_memory_flush_hook_enqueues_filtered_messages_and_flushes(monkeypatch: p assert add_kwargs["reinforcement_detected"] is False +def test_skill_rescue_keeps_recent_skill_reads_out_of_summary() -> None: + captured: list[SummarizationEvent] = [] + middleware = _middleware( + before_summarization=[captured.append], + trigger=("messages", 4), + keep=("messages", 2), + preserve_recent_skill_count=5, + preserve_recent_skill_tokens=10_000, + preserve_recent_skill_tokens_per_skill=10_000, + ) + + result = middleware.before_model({"messages": _skill_conversation()}, _runtime()) + + assert len(captured) == 1 + summarized_ids = {id(m) for m in captured[0].messages_to_summarize} + preserved = captured[0].preserved_messages + + # Both skill-read bundles should be rescued into preserved_messages, + # tool_call ↔ tool_result pairs stay intact. + assert any(isinstance(m, ToolMessage) and m.content == "alpha skill body" for m in preserved) + assert any(isinstance(m, ToolMessage) and m.content == "beta skill body" for m in preserved) + for m in preserved: + if isinstance(m, ToolMessage) and m.content in {"alpha skill body", "beta skill body"}: + assert id(m) not in summarized_ids + + # Preserved output order: rescued bundles first, then the tail kept by parent cutoff. + contents = [getattr(m, "content", None) for m in preserved] + assert contents[-2:] == ["u3", "final"] + + # The final emitted state should start with RemoveMessage + summary, then preserved messages. + emitted = result["messages"] + assert isinstance(emitted[0], RemoveMessage) + assert emitted[1].content.startswith("Here is a summary") + assert list(emitted[-2:]) == list(preserved[-2:]) + + +def test_skill_rescue_respects_count_budget() -> None: + captured: list[SummarizationEvent] = [] + middleware = _middleware( + before_summarization=[captured.append], + trigger=("messages", 4), + keep=("messages", 2), + preserve_recent_skill_count=1, + preserve_recent_skill_tokens=10_000, + preserve_recent_skill_tokens_per_skill=10_000, + ) + + middleware.before_model({"messages": _skill_conversation()}, _runtime()) + + preserved = captured[0].preserved_messages + summarized = captured[0].messages_to_summarize + # Newest skill (beta) rescued; older skill (alpha) falls into summary. + assert any(isinstance(m, ToolMessage) and m.content == "beta skill body" for m in preserved) + assert not any(isinstance(m, ToolMessage) and m.content == "alpha skill body" for m in preserved) + assert any(isinstance(m, ToolMessage) and m.content == "alpha skill body" for m in summarized) + + +def test_skill_rescue_uses_injected_skills_container_path() -> None: + captured: list[SummarizationEvent] = [] + middleware = _middleware( + before_summarization=[captured.append], + trigger=("messages", 4), + keep=("messages", 2), + preserve_recent_skill_count=5, + preserve_recent_skill_tokens=10_000, + preserve_recent_skill_tokens_per_skill=10_000, + ) + middleware._skills_container_path = "/custom/skills" + messages = [ + HumanMessage(content="u1"), + AIMessage(content="", tool_calls=[{"name": "read_file", "id": "t1", "args": {"path": "/custom/skills/demo/SKILL.md"}}]), + ToolMessage(content="demo skill body", tool_call_id="t1"), + HumanMessage(content="u2"), + AIMessage(content="final"), + ] + + middleware.before_model({"messages": messages}, _runtime()) + + preserved = captured[0].preserved_messages + assert any(isinstance(m, ToolMessage) and m.content == "demo skill body" for m in preserved) + + +def test_skill_rescue_uses_configured_skill_read_tool_names() -> None: + captured: list[SummarizationEvent] = [] + middleware = _middleware( + before_summarization=[captured.append], + trigger=("messages", 4), + keep=("messages", 2), + skill_file_read_tool_names=["custom_read"], + preserve_recent_skill_count=5, + preserve_recent_skill_tokens=10_000, + preserve_recent_skill_tokens_per_skill=10_000, + ) + middleware._skills_container_path = "/custom/skills" + messages = [ + HumanMessage(content="u1"), + AIMessage(content="", tool_calls=[{"name": "custom_read", "id": "t1", "args": {"path": "/custom/skills/demo/SKILL.md"}}]), + ToolMessage(content="demo skill body", tool_call_id="t1"), + HumanMessage(content="u2"), + AIMessage(content="final"), + ] + + middleware.before_model({"messages": messages}, _runtime()) + + preserved = captured[0].preserved_messages + assert any(isinstance(m, ToolMessage) and m.content == "demo skill body" for m in preserved) + + +def test_skill_rescue_respects_per_skill_token_cap() -> None: + captured: list[SummarizationEvent] = [] + middleware = _middleware( + before_summarization=[captured.append], + trigger=("messages", 4), + keep=("messages", 2), + preserve_recent_skill_count=5, + preserve_recent_skill_tokens=10_000, + # token_counter=len counts one token per message; per-skill cap of 0 rejects every bundle. + preserve_recent_skill_tokens_per_skill=0, + ) + + middleware.before_model({"messages": _skill_conversation()}, _runtime()) + + preserved = captured[0].preserved_messages + assert not any(isinstance(m, ToolMessage) and m.content in {"alpha skill body", "beta skill body"} for m in preserved) + + +def test_skill_rescue_disabled_when_count_zero() -> None: + captured: list[SummarizationEvent] = [] + middleware = _middleware( + before_summarization=[captured.append], + trigger=("messages", 4), + keep=("messages", 2), + preserve_recent_skill_count=0, + preserve_recent_skill_tokens=10_000, + preserve_recent_skill_tokens_per_skill=10_000, + ) + + middleware.before_model({"messages": _skill_conversation()}, _runtime()) + + preserved = captured[0].preserved_messages + assert not any(isinstance(m, ToolMessage) for m in preserved) + + +def test_skill_rescue_ignores_non_skill_tool_reads() -> None: + captured: list[SummarizationEvent] = [] + middleware = _middleware( + before_summarization=[captured.append], + trigger=("messages", 4), + keep=("messages", 2), + preserve_recent_skill_count=5, + preserve_recent_skill_tokens=10_000, + preserve_recent_skill_tokens_per_skill=10_000, + ) + + messages = [ + HumanMessage(content="u1"), + AIMessage( + content="", + tool_calls=[{"name": "read_file", "id": "t1", "args": {"path": "/mnt/user-data/workspace/notes.md"}}], + ), + ToolMessage(content="user notes", tool_call_id="t1"), + HumanMessage(content="u2"), + AIMessage(content="done"), + ] + + middleware.before_model({"messages": messages}, _runtime()) + + preserved = captured[0].preserved_messages + assert not any(isinstance(m, ToolMessage) and m.content == "user notes" for m in preserved) + + +def test_skill_rescue_does_not_preserve_non_skill_outputs_from_mixed_tool_calls() -> None: + captured: list[SummarizationEvent] = [] + middleware = _middleware( + before_summarization=[captured.append], + trigger=("messages", 4), + keep=("messages", 2), + preserve_recent_skill_count=5, + preserve_recent_skill_tokens=10_000, + preserve_recent_skill_tokens_per_skill=10_000, + ) + + messages = [ + HumanMessage(content="u1"), + AIMessage( + content="", + tool_calls=[ + _skill_read_call("skill-1", "alpha"), + {"name": "read_file", "id": "file-1", "args": {"path": "/mnt/user-data/workspace/notes.md"}}, + ], + ), + ToolMessage(content="alpha skill body", tool_call_id="skill-1"), + ToolMessage(content="user notes", tool_call_id="file-1"), + HumanMessage(content="u2"), + AIMessage(content="done"), + ] + + middleware.before_model({"messages": messages}, _runtime()) + + preserved = captured[0].preserved_messages + summarized = captured[0].messages_to_summarize + + preserved_ai = next(m for m in preserved if isinstance(m, AIMessage) and m.tool_calls) + summarized_ai = next(m for m in summarized if isinstance(m, AIMessage) and m.tool_calls) + + assert [tc["id"] for tc in preserved_ai.tool_calls] == ["skill-1"] + assert [tc["id"] for tc in summarized_ai.tool_calls] == ["file-1"] + assert any(isinstance(m, ToolMessage) and m.content == "alpha skill body" for m in preserved) + assert not any(isinstance(m, ToolMessage) and m.content == "user notes" for m in preserved) + assert any(isinstance(m, ToolMessage) and m.content == "user notes" for m in summarized) + + +def test_skill_rescue_clears_content_on_rescued_ai_clone() -> None: + captured: list[SummarizationEvent] = [] + middleware = _middleware( + before_summarization=[captured.append], + trigger=("messages", 4), + keep=("messages", 2), + preserve_recent_skill_count=5, + preserve_recent_skill_tokens=10_000, + preserve_recent_skill_tokens_per_skill=10_000, + ) + + messages = [ + HumanMessage(content="u1"), + AIMessage( + content="reading skill and notes", + tool_calls=[ + _skill_read_call("skill-1", "alpha"), + {"name": "read_file", "id": "file-1", "args": {"path": "/mnt/user-data/workspace/notes.md"}}, + ], + ), + ToolMessage(content="alpha skill body", tool_call_id="skill-1"), + ToolMessage(content="user notes", tool_call_id="file-1"), + HumanMessage(content="u2"), + AIMessage(content="done"), + ] + + middleware.before_model({"messages": messages}, _runtime()) + + preserved = captured[0].preserved_messages + summarized = captured[0].messages_to_summarize + + preserved_ai = next(m for m in preserved if isinstance(m, AIMessage) and m.tool_calls) + summarized_ai = next(m for m in summarized if isinstance(m, AIMessage) and m.tool_calls) + + assert preserved_ai.content == "" + assert summarized_ai.content == "reading skill and notes" + + +def test_skill_rescue_only_preserves_skill_calls_with_matched_tool_results() -> None: + captured: list[SummarizationEvent] = [] + middleware = _middleware( + before_summarization=[captured.append], + trigger=("messages", 4), + keep=("messages", 2), + preserve_recent_skill_count=5, + preserve_recent_skill_tokens=10_000, + preserve_recent_skill_tokens_per_skill=10_000, + ) + + messages = [ + HumanMessage(content="u1"), + AIMessage( + content="", + tool_calls=[ + _skill_read_call("skill-1", "alpha"), + _skill_read_call("skill-2", "beta"), + ], + ), + ToolMessage(content="alpha skill body", tool_call_id="skill-1"), + HumanMessage(content="u2"), + AIMessage(content="done"), + ] + + middleware.before_model({"messages": messages}, _runtime()) + + preserved = captured[0].preserved_messages + summarized = captured[0].messages_to_summarize + + preserved_ai = next(m for m in preserved if isinstance(m, AIMessage) and m.tool_calls) + summarized_ai = next(m for m in summarized if isinstance(m, AIMessage) and m.tool_calls) + + assert [tc["id"] for tc in preserved_ai.tool_calls] == ["skill-1"] + assert [tc["id"] for tc in summarized_ai.tool_calls] == ["skill-2"] + assert any(isinstance(m, ToolMessage) and m.content == "alpha skill body" for m in preserved) + assert not any(isinstance(m, ToolMessage) and getattr(m, "tool_call_id", None) == "skill-2" for m in preserved) + + def test_memory_flush_hook_preserves_agent_scoped_memory(monkeypatch: pytest.MonkeyPatch) -> None: queue = MagicMock() monkeypatch.setattr("deerflow.agents.memory.summarization_hook.get_memory_config", lambda: MemoryConfig(enabled=True)) diff --git a/config.example.yaml b/config.example.yaml index 1e649bba9..1c5bf4129 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -12,7 +12,7 @@ # ============================================================================ # Bump this number when the config schema changes. # Run `make config-upgrade` to merge new fields into your local config.yaml. -config_version: 7 +config_version: 8 # ============================================================================ # Logging @@ -726,6 +726,19 @@ summarization: # The prompt should guide the model to extract important context summary_prompt: null + # Recently-loaded skill files are excluded from summarization so the agent + # does not lose skill instructions after a compression pass. Claude Code uses + # a similar strategy (keep the most recent ~5 skills, ~25k total tokens, with + # a ~5k cap per skill). Set preserve_recent_skill_count to 0 to disable. + preserve_recent_skill_count: 5 + preserve_recent_skill_tokens: 25000 + preserve_recent_skill_tokens_per_skill: 5000 + skill_file_read_tool_names: + - read_file + - read + - view + - cat + # ============================================================================ # Memory Configuration # ============================================================================ From d78ed5c8f2673da21f1855c74341d7bda15776aa Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:24:42 +0800 Subject: [PATCH 16/83] fix: inherit subagent skill allowlists (#2514) --- .../deerflow/agents/lead_agent/agent.py | 1 + .../deerflow/tools/builtins/task_tool.py | 21 ++++- backend/tests/test_task_tool_core_logic.py | 84 +++++++++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index f17aab6ce..1d1efe5b0 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -350,6 +350,7 @@ def make_lead_agent(config: RunnableConfig): "is_plan_mode": is_plan_mode, "subagent_enabled": subagent_enabled, "tool_groups": agent_config.tool_groups if agent_config else None, + "available_skills": ["bootstrap"] if is_bootstrap else (agent_config.skills if agent_config and agent_config.skills is not None else None), } ) diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index fbe41ded7..59613272c 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -18,6 +18,17 @@ from deerflow.subagents.executor import SubagentStatus, cleanup_background_task, logger = logging.getLogger(__name__) +def _merge_skill_allowlists(parent: list[str] | None, child: list[str] | None) -> list[str] | None: + """Return the effective subagent skill allowlist under the parent policy.""" + if parent is None: + return child + if child is None: + return list(parent) + + parent_set = set(parent) + return [skill for skill in child if skill in parent_set] + + @tool("task", parse_docstring=True) async def task_tool( runtime: ToolRuntime[ContextT, ThreadState], @@ -83,9 +94,6 @@ async def task_tool( if max_turns is not None: overrides["max_turns"] = max_turns - if overrides: - config = replace(config, **overrides) - # Extract parent context from runtime sandbox_state = None thread_data = None @@ -108,6 +116,13 @@ async def task_tool( # Get or generate trace_id for distributed tracing trace_id = metadata.get("trace_id") or str(uuid.uuid4())[:8] + parent_available_skills = metadata.get("available_skills") + if parent_available_skills is not None: + overrides["skills"] = _merge_skill_allowlists(list(parent_available_skills), config.skills) + + if overrides: + config = replace(config, **overrides) + # Get available tools (excluding task tool to prevent nesting) # Lazy import to avoid circular dependency from deerflow.tools import get_available_tools diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index b39f09dad..1ae008df2 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -223,6 +223,90 @@ def test_task_tool_propagates_tool_groups_to_subagent(monkeypatch): get_available_tools.assert_called_once_with(model_name="ark-model", groups=parent_tool_groups, subagent_enabled=False) +def test_task_tool_inherits_parent_skill_allowlist_for_default_subagent(monkeypatch): + config = _make_subagent_config() + runtime = _make_runtime() + runtime.config["metadata"]["available_skills"] = ["safe-skill"] + events = [] + captured = {} + + class DummyExecutor: + def __init__(self, **kwargs): + captured["config"] = kwargs["config"] + + def execute_async(self, prompt, task_id=None): + return task_id or "generated-task-id" + + monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) + monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) + monkeypatch.setattr( + task_tool_module, + "get_background_task_result", + lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done"), + ) + monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) + monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) + monkeypatch.setattr("deerflow.tools.get_available_tools", MagicMock(return_value=[])) + + output = _run_task_tool( + runtime=runtime, + description="执行任务", + prompt="use skills", + subagent_type="general-purpose", + tool_call_id="tc-skills", + ) + + assert output == "Task Succeeded. Result: done" + assert captured["config"].skills == ["safe-skill"] + + +def test_task_tool_intersects_parent_and_subagent_skill_allowlists(monkeypatch): + config = _make_subagent_config() + config = SubagentConfig( + name=config.name, + description=config.description, + system_prompt=config.system_prompt, + max_turns=config.max_turns, + timeout_seconds=config.timeout_seconds, + skills=["safe-skill", "other-skill"], + ) + runtime = _make_runtime() + runtime.config["metadata"]["available_skills"] = ["safe-skill"] + events = [] + captured = {} + + class DummyExecutor: + def __init__(self, **kwargs): + captured["config"] = kwargs["config"] + + def execute_async(self, prompt, task_id=None): + return task_id or "generated-task-id" + + monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) + monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) + monkeypatch.setattr( + task_tool_module, + "get_background_task_result", + lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done"), + ) + monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) + monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) + monkeypatch.setattr("deerflow.tools.get_available_tools", MagicMock(return_value=[])) + + output = _run_task_tool( + runtime=runtime, + description="执行任务", + prompt="use skills", + subagent_type="general-purpose", + tool_call_id="tc-skills-intersection", + ) + + assert output == "Task Succeeded. Result: done" + assert captured["config"].skills == ["safe-skill"] + + def test_task_tool_no_tool_groups_passes_none(monkeypatch): """Verify that when metadata has no tool_groups, groups=None is passed (backward compat).""" config = _make_subagent_config() From ec8a8cae38456ece2b0f9a6b32c42382127c5f0e Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:45:41 +0800 Subject: [PATCH 17/83] fix: gate deferred MCP tool execution (#2513) * fix: gate deferred MCP tool execution * style: format deferred tool middleware * fix: address deferred tool review feedback --- .../deferred_tool_filter_middleware.py | 49 +++++++++- .../deerflow/tools/builtins/tool_search.py | 9 ++ backend/tests/test_tool_search.py | 98 +++++++++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/agents/middlewares/deferred_tool_filter_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/deferred_tool_filter_middleware.py index 604cdf37c..f92d90158 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/deferred_tool_filter_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/deferred_tool_filter_middleware.py @@ -16,6 +16,9 @@ from typing import override from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse +from langchain_core.messages import ToolMessage +from langgraph.prebuilt.tool_node import ToolCallRequest +from langgraph.types import Command logger = logging.getLogger(__name__) @@ -35,7 +38,7 @@ class DeferredToolFilterMiddleware(AgentMiddleware[AgentState]): if not registry: return request - deferred_names = {e.name for e in registry.entries} + deferred_names = registry.deferred_names active_tools = [t for t in request.tools if getattr(t, "name", None) not in deferred_names] if len(active_tools) < len(request.tools): @@ -43,6 +46,28 @@ class DeferredToolFilterMiddleware(AgentMiddleware[AgentState]): return request.override(tools=active_tools) + def _blocked_tool_message(self, request: ToolCallRequest) -> ToolMessage | None: + from deerflow.tools.builtins.tool_search import get_deferred_registry + + registry = get_deferred_registry() + if not registry: + return None + + tool_name = str(request.tool_call.get("name") or "") + if not tool_name: + return None + + if not registry.contains(tool_name): + return None + + tool_call_id = str(request.tool_call.get("id") or "missing_tool_call_id") + return ToolMessage( + content=(f"Error: Tool '{tool_name}' is deferred and has not been promoted yet. Call tool_search first to expose and promote this tool's schema, then retry."), + tool_call_id=tool_call_id, + name=tool_name, + status="error", + ) + @override def wrap_model_call( self, @@ -51,6 +76,17 @@ class DeferredToolFilterMiddleware(AgentMiddleware[AgentState]): ) -> ModelCallResult: return handler(self._filter_tools(request)) + @override + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + blocked = self._blocked_tool_message(request) + if blocked is not None: + return blocked + return handler(request) + @override async def awrap_model_call( self, @@ -58,3 +94,14 @@ class DeferredToolFilterMiddleware(AgentMiddleware[AgentState]): handler: Callable[[ModelRequest], Awaitable[ModelResponse]], ) -> ModelCallResult: return await handler(self._filter_tools(request)) + + @override + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + blocked = self._blocked_tool_message(request) + if blocked is not None: + return blocked + return await handler(request) diff --git a/backend/packages/harness/deerflow/tools/builtins/tool_search.py b/backend/packages/harness/deerflow/tools/builtins/tool_search.py index ffbe2060f..88f4e3112 100644 --- a/backend/packages/harness/deerflow/tools/builtins/tool_search.py +++ b/backend/packages/harness/deerflow/tools/builtins/tool_search.py @@ -112,6 +112,15 @@ class DeferredToolRegistry: def entries(self) -> list[DeferredToolEntry]: return list(self._entries) + @property + def deferred_names(self) -> set[str]: + """Names of tools that are still hidden from model binding.""" + return {entry.name for entry in self._entries} + + def contains(self, name: str) -> bool: + """Return whether *name* is still deferred.""" + return any(entry.name == name for entry in self._entries) + def __len__(self) -> int: return len(self._entries) diff --git a/backend/tests/test_tool_search.py b/backend/tests/test_tool_search.py index 8f71144c5..428bfec3d 100644 --- a/backend/tests/test_tool_search.py +++ b/backend/tests/test_tool_search.py @@ -2,8 +2,10 @@ import json import sys +from types import SimpleNamespace import pytest +from langchain_core.messages import ToolMessage from langchain_core.tools import tool as langchain_tool from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict @@ -83,6 +85,16 @@ class TestDeferredToolRegistry: assert "github_create_issue" in names assert "slack_send_message" in names + def test_deferred_names(self, registry): + names = registry.deferred_names + assert "github_create_issue" in names + assert "slack_send_message" in names + assert len(names) == 6 + + def test_contains(self, registry): + assert registry.contains("github_create_issue") is True + assert registry.contains("not_registered") is False + def test_search_select_single(self, registry): results = registry.search("select:github_create_issue") assert len(results) == 1 @@ -509,3 +521,89 @@ class TestToolSearchPromotion: assert "slack_send_message" not in remaining assert "slack_list_channels" not in remaining assert len(registry) == 4 + + +class TestDeferredToolExecutionGate: + def test_unpromoted_deferred_tool_call_is_blocked(self, registry): + from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware + + set_deferred_registry(registry) + middleware = DeferredToolFilterMiddleware() + request = SimpleNamespace(tool_call={"name": "github_create_issue", "id": "call-1"}) + called = False + + def handler(_request): + nonlocal called + called = True + return ToolMessage(content="executed", tool_call_id="call-1", name="github_create_issue") + + result = middleware.wrap_tool_call(request, handler) + + assert called is False + assert isinstance(result, ToolMessage) + assert result.status == "error" + assert result.tool_call_id == "call-1" + assert "tool_search" in result.content + assert "github_create_issue" in result.content + + def test_promoted_deferred_tool_call_is_allowed(self, registry): + from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware + + registry.promote({"github_create_issue"}) + set_deferred_registry(registry) + middleware = DeferredToolFilterMiddleware() + request = SimpleNamespace(tool_call={"name": "github_create_issue", "id": "call-1"}) + called = False + + def handler(_request): + nonlocal called + called = True + return ToolMessage(content="executed", tool_call_id="call-1", name="github_create_issue") + + result = middleware.wrap_tool_call(request, handler) + + assert called is True + assert isinstance(result, ToolMessage) + assert result.content == "executed" + + def test_non_deferred_tool_call_is_allowed(self, registry): + from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware + + set_deferred_registry(registry) + middleware = DeferredToolFilterMiddleware() + request = SimpleNamespace(tool_call={"name": "local_tool", "id": "call-1"}) + called = False + + def handler(_request): + nonlocal called + called = True + return ToolMessage(content="executed", tool_call_id="call-1", name="local_tool") + + result = middleware.wrap_tool_call(request, handler) + + assert called is True + assert isinstance(result, ToolMessage) + assert result.content == "executed" + + @pytest.mark.anyio + async def test_unpromoted_deferred_tool_call_is_blocked_async(self, registry): + from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware + + set_deferred_registry(registry) + middleware = DeferredToolFilterMiddleware() + request = SimpleNamespace(tool_call={"name": "github_create_issue", "id": "call-1"}) + called = False + + async def handler(_request): + nonlocal called + called = True + return ToolMessage(content="executed", tool_call_id="call-1", name="github_create_issue") + + result = await middleware.awrap_tool_call(request, handler) + + assert called is False + assert isinstance(result, ToolMessage) + assert result.status == "error" + assert result.tool_call_id == "call-1" + assert "tool_search" in result.content + assert "github_create_issue" in result.content From b9709934255b2f7f951fd4b2300543ef764e1473 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:46:51 +0800 Subject: [PATCH 18/83] fix: read lead agent options from context (#2515) * fix: read lead agent options from context * fix: validate runtime context config --- backend/app/gateway/services.py | 47 ++++++++++++------ .../deerflow/agents/lead_agent/agent.py | 18 +++++-- backend/tests/test_gateway_services.py | 45 +++++++++++++++++ .../tests/test_lead_agent_model_resolution.py | 48 +++++++++++++++++++ 4 files changed, 139 insertions(+), 19 deletions(-) diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index 7dc22a9ef..3b3c40a27 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -12,6 +12,7 @@ import json import logging import re import time +from collections.abc import Mapping from typing import Any from fastapi import HTTPException, Request @@ -101,9 +102,10 @@ def resolve_agent_factory(assistant_id: str | None): """Resolve the agent factory callable from config. Custom agents are implemented as ``lead_agent`` + an ``agent_name`` - injected into ``configurable`` — see :func:`build_run_config`. All - ``assistant_id`` values therefore map to the same factory; the routing - happens inside ``make_lead_agent`` when it reads ``cfg["agent_name"]``. + injected into ``configurable`` or ``context`` — see + :func:`build_run_config`. All ``assistant_id`` values therefore map to the + same factory; the routing happens inside ``make_lead_agent`` when it reads + ``cfg["agent_name"]``. """ from deerflow.agents.lead_agent.agent import make_lead_agent @@ -120,10 +122,12 @@ def build_run_config( """Build a RunnableConfig dict for the agent. When *assistant_id* refers to a custom agent (anything other than - ``"lead_agent"`` / ``None``), the name is forwarded as - ``configurable["agent_name"]``. ``make_lead_agent`` reads this key to - load the matching ``agents//SOUL.md`` and per-agent config — - without it the agent silently runs as the default lead agent. + ``"lead_agent"`` / ``None``), the name is forwarded as ``agent_name`` in + whichever runtime options container is active: ``context`` for + LangGraph >= 0.6.0 requests, otherwise ``configurable``. + ``make_lead_agent`` reads this key to load the matching + ``agents//SOUL.md`` and per-agent config — without it the agent + silently runs as the default lead agent. This mirrors the channel manager's ``_resolve_run_params`` logic so that the LangGraph Platform-compatible HTTP API and the IM channel path behave @@ -142,7 +146,14 @@ def build_run_config( thread_id, list(request_config.get("configurable", {}).keys()), ) - config["context"] = request_config["context"] + context_value = request_config["context"] + if context_value is None: + context = {} + elif isinstance(context_value, Mapping): + context = dict(context_value) + else: + raise ValueError("request config 'context' must be a mapping or null.") + config["context"] = context else: configurable = {"thread_id": thread_id} configurable.update(request_config.get("configurable", {})) @@ -154,13 +165,19 @@ def build_run_config( config["configurable"] = {"thread_id": thread_id} # Inject custom agent name when the caller specified a non-default assistant. - # Honour an explicit configurable["agent_name"] in the request if already set. - if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID and "configurable" in config: - if "agent_name" not in config["configurable"]: - normalized = assistant_id.strip().lower().replace("_", "-") - 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.") - config["configurable"]["agent_name"] = normalized + # Honour an explicit agent_name in the active runtime options container. + if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID: + normalized = assistant_id.strip().lower().replace("_", "-") + 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.") + if "configurable" in config: + target = config["configurable"] + elif "context" in config: + target = config["context"] + else: + target = config.setdefault("configurable", {}) + if target is not None and "agent_name" not in target: + target["agent_name"] = normalized if metadata: config.setdefault("metadata", {}).update(metadata) return config diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 1d1efe5b0..3b336a377 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -26,6 +26,15 @@ from deerflow.models import create_chat_model logger = logging.getLogger(__name__) +def _get_runtime_config(config: RunnableConfig) -> dict: + """Merge legacy configurable options with LangGraph runtime context.""" + cfg = dict(config.get("configurable", {}) or {}) + context = config.get("context", {}) or {} + if isinstance(context, dict): + cfg.update(context) + return cfg + + def _resolve_model_name(requested_model_name: str | None = None) -> str: """Resolve a runtime model name safely, falling back to default if invalid. Returns None if no models are configured.""" app_config = get_app_config() @@ -248,7 +257,8 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam middlewares.append(summarization_middleware) # Add TodoList middleware if plan mode is enabled - is_plan_mode = config.get("configurable", {}).get("is_plan_mode", False) + cfg = _get_runtime_config(config) + is_plan_mode = cfg.get("is_plan_mode", False) todo_list_middleware = _create_todo_list_middleware(is_plan_mode) if todo_list_middleware is not None: middlewares.append(todo_list_middleware) @@ -277,9 +287,9 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_nam middlewares.append(DeferredToolFilterMiddleware()) # Add SubagentLimitMiddleware to truncate excess parallel task calls - subagent_enabled = config.get("configurable", {}).get("subagent_enabled", False) + subagent_enabled = cfg.get("subagent_enabled", False) if subagent_enabled: - max_concurrent_subagents = config.get("configurable", {}).get("max_concurrent_subagents", 3) + max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents)) # LoopDetectionMiddleware — detect and break repetitive tool call loops @@ -299,7 +309,7 @@ def make_lead_agent(config: RunnableConfig): from deerflow.tools import get_available_tools from deerflow.tools.builtins import setup_agent - cfg = config.get("configurable", {}) + cfg = _get_runtime_config(config) thinking_enabled = cfg.get("thinking_enabled", True) reasoning_effort = cfg.get("reasoning_effort", None) diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index 782306e38..e0fcda294 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -145,6 +145,21 @@ def test_build_run_config_explicit_agent_name_not_overwritten(): assert config["configurable"]["agent_name"] == "explicit-agent" +def test_build_run_config_context_custom_agent_injects_agent_name(): + """Custom assistant_id must be forwarded as context['agent_name'] in context mode.""" + from app.gateway.services import build_run_config + + config = build_run_config( + "thread-1", + {"context": {"model_name": "deepseek-v3"}}, + None, + assistant_id="finalis", + ) + + assert config["context"]["agent_name"] == "finalis" + assert "configurable" not in config + + def test_resolve_agent_factory_returns_make_lead_agent(): """resolve_agent_factory always returns make_lead_agent regardless of assistant_id.""" from app.gateway.services import resolve_agent_factory @@ -298,6 +313,36 @@ def test_build_run_config_with_context(): assert config["recursion_limit"] == 100 +def test_build_run_config_null_context_becomes_empty_context(): + """When caller sends context=null, treat it as an empty context object.""" + from app.gateway.services import build_run_config + + config = build_run_config("thread-1", {"context": None}, None) + + assert config["context"] == {} + assert "configurable" not in config + + +def test_build_run_config_rejects_non_mapping_context(): + """When caller sends a non-object context, raise a clear error instead of a TypeError.""" + import pytest + + from app.gateway.services import build_run_config + + with pytest.raises(ValueError, match="context"): + build_run_config("thread-1", {"context": "bad-context"}, None) + + +def test_build_run_config_null_context_custom_agent_injects_agent_name(): + """Custom assistant_id can still be injected when context=null starts context mode.""" + from app.gateway.services import build_run_config + + config = build_run_config("thread-1", {"context": None}, None, assistant_id="finalis") + + assert config["context"] == {"agent_name": "finalis"} + assert "configurable" not in config + + def test_build_run_config_context_plus_configurable_warns(caplog): """When caller sends both 'context' and 'configurable', prefer 'context' and log a warning.""" import logging diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index dc95dc4da..a3bc21cfb 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -113,6 +113,54 @@ def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkey assert result["model"] is not None +def test_make_lead_agent_reads_runtime_options_from_context(monkeypatch): + app_config = _make_app_config( + [ + _make_model("default-model", supports_thinking=False), + _make_model("context-model", supports_thinking=True), + ] + ) + + import deerflow.tools as tools_module + + get_available_tools = MagicMock(return_value=[]) + monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) + monkeypatch.setattr(tools_module, "get_available_tools", get_available_tools) + monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name, agent_name=None: []) + + captured: dict[str, object] = {} + + def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None): + captured["name"] = name + captured["thinking_enabled"] = thinking_enabled + captured["reasoning_effort"] = reasoning_effort + return object() + + monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model) + monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs) + + result = lead_agent_module.make_lead_agent( + { + "context": { + "model_name": "context-model", + "thinking_enabled": False, + "reasoning_effort": "high", + "is_plan_mode": True, + "subagent_enabled": True, + "max_concurrent_subagents": 7, + } + } + ) + + assert captured == { + "name": "context-model", + "thinking_enabled": False, + "reasoning_effort": "high", + } + get_available_tools.assert_called_once_with(model_name="context-model", groups=None, subagent_enabled=True) + assert result["model"] is not None + + def test_make_lead_agent_rejects_invalid_bootstrap_agent_name(monkeypatch): app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)]) From 2bb1a2dfa28fb79a308b5f980fabd44693bcd0f7 Mon Sep 17 00:00:00 2001 From: pyp0327 <108285878+pyp0327@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:59:03 +0800 Subject: [PATCH 19/83] feat(models): Provider for MindIE model engine (#2483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(models): 适配 MindIE引擎的模型 * test: add unit tests for MindIEChatModel adapter and fix PR review comments * chore: update uv.lock with pytest-asyncio * build: add pytest-asyncio to test dependencies * fix: address PR review comments (lazy import, cache clients, safe newline escape, strict xml regex) --------- Co-authored-by: Willem Jiang --- .../harness/deerflow/models/factory.py | 6 + .../deerflow/models/mindie_provider.py | 237 +++++++++++ backend/pyproject.toml | 7 +- backend/tests/test_mindie_provider.py | 397 ++++++++++++++++++ backend/uv.lock | 15 + config.example.yaml | 21 + 6 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 backend/packages/harness/deerflow/models/mindie_provider.py create mode 100644 backend/tests/test_mindie_provider.py diff --git a/backend/packages/harness/deerflow/models/factory.py b/backend/packages/harness/deerflow/models/factory.py index bd2828e94..aec9b291a 100644 --- a/backend/packages/harness/deerflow/models/factory.py +++ b/backend/packages/harness/deerflow/models/factory.py @@ -131,6 +131,12 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, * elif "reasoning_effort" not in model_settings_from_config: model_settings_from_config["reasoning_effort"] = "medium" + # For MindIE models: enforce conservative retry defaults. + # Timeout normalization is handled inside MindIEChatModel itself. + if getattr(model_class, "__name__", "") == "MindIEChatModel": + # Enforce max_retries constraint to prevent cascading timeouts. + model_settings_from_config["max_retries"] = model_settings_from_config.get("max_retries", 1) + model_instance = model_class(**{**model_settings_from_config, **kwargs}) callbacks = build_tracing_callbacks() diff --git a/backend/packages/harness/deerflow/models/mindie_provider.py b/backend/packages/harness/deerflow/models/mindie_provider.py new file mode 100644 index 000000000..5f0d12e83 --- /dev/null +++ b/backend/packages/harness/deerflow/models/mindie_provider.py @@ -0,0 +1,237 @@ +import ast +import json +import re +import uuid +from collections.abc import Iterator + +import httpx +from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage +from langchain_core.outputs import ChatGenerationChunk, ChatResult +from langchain_openai import ChatOpenAI + + +def _fix_messages(messages: list) -> list: + """Sanitize incoming messages for MindIE compatibility. + + MindIE's chat template may fail to parse LangChain's native tool_calls + or ToolMessage roles, resulting in 0-token generation errors. This function + flattens multi-modal list contents into strings and converts tool-related + messages into raw text with XML tags expected by the underlying model. + """ + fixed = [] + for msg in messages: + # Flatten content if it's a list of blocks + if isinstance(msg.content, list): + parts = [] + for block in msg.content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + text = "".join(parts) + else: + text = msg.content or "" + + # Convert AIMessage with tool_calls to raw XML text format + if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", []): + xml_parts = [] + for tool in msg.tool_calls: + args_xml = " ".join(f"{json.dumps(v, ensure_ascii=False)}" for k, v in tool.get("args", {}).items()) + xml_parts.append(f" {args_xml} ") + full_text = f"{text}\n" + "\n".join(xml_parts) if text else "\n".join(xml_parts) + fixed.append(AIMessage(content=full_text.strip() or " ")) + continue + + # Wrap tool execution results in XML tags and convert to HumanMessage + if isinstance(msg, ToolMessage): + tool_result_text = f"\n{text}\n" + fixed.append(HumanMessage(content=tool_result_text)) + continue + + # Fallback to prevent completely empty message content + if not text.strip(): + text = " " + + fixed.append(msg.model_copy(update={"content": text})) + + return fixed + + +def _parse_xml_tool_call_to_dict(content: str) -> tuple[str, list[dict]]: + """Parse XML-style tool calls from model output into LangChain dicts. + + Args: + content: The raw text output from the model. + + Returns: + A tuple containing the cleaned text (with XML blocks removed) and + a list of tool call dictionaries formatted for LangChain. + """ + if not isinstance(content, str) or "" not in content: + return content, [] + + tool_calls = [] + clean_parts: list[str] = [] + cursor = 0 + for start, end, inner_content in _iter_tool_call_blocks(content): + clean_parts.append(content[cursor:start]) + cursor = end + + func_match = re.search(r"]+)>", inner_content) + if not func_match: + continue + function_name = func_match.group(1).strip() + + args = {} + param_pattern = re.compile(r"]+)>(.*?)", re.DOTALL) + for param_match in param_pattern.finditer(inner_content): + key = param_match.group(1).strip() + raw_value = param_match.group(2).strip() + + # Attempt to deserialize string values into native Python types + # to satisfy downstream Pydantic validation. + parsed_value = raw_value + if raw_value.startswith(("[", "{")) or raw_value in ("true", "false", "null") or raw_value.isdigit(): + try: + parsed_value = json.loads(raw_value) + except json.JSONDecodeError: + try: + parsed_value = ast.literal_eval(raw_value) + except (ValueError, SyntaxError): + pass + + args[key] = parsed_value + + tool_calls.append({"name": function_name, "args": args, "id": f"call_{uuid.uuid4().hex[:10]}"}) + clean_parts.append(content[cursor:]) + + return "".join(clean_parts).strip(), tool_calls + + +def _iter_tool_call_blocks(content: str) -> Iterator[tuple[int, int, str]]: + """Iterate `...` blocks and tolerate nesting.""" + token_pattern = re.compile(r"") + depth = 0 + block_start = -1 + + for match in token_pattern.finditer(content): + token = match.group(0) + if token == "": + if depth == 0: + block_start = match.start() + depth += 1 + continue + + if depth == 0: + continue + + depth -= 1 + if depth == 0 and block_start != -1: + block_end = match.end() + inner_start = block_start + len("") + inner_end = match.start() + yield block_start, block_end, content[inner_start:inner_end] + block_start = -1 + + +def _decode_escaped_newlines_outside_fences(content: str) -> str: + """Decode literal `\\n` outside fenced code blocks.""" + if "\\n" not in content: + return content + + parts = re.split(r"(```[\s\S]*?```)", content) + for idx, part in enumerate(parts): + if part.startswith("```"): + continue + parts[idx] = part.replace("\\n", "\n") + return "".join(parts) + + +class MindIEChatModel(ChatOpenAI): + """Chat model adapter for MindIE engine. + + Addresses compatibility issues including: + - Flattening multimodal list contents to strings. + - Intercepting and parsing hardcoded XML tool calls into LangChain standard. + - Handling stream=True dropping choices when tools are present by falling back + to non-streaming generation and yielding simulated chunks. + - Fixing over-escaped newline characters from gateway responses. + """ + + def __init__(self, **kwargs): + """Normalize timeout kwargs without creating long-lived clients.""" + connect_timeout = kwargs.pop("connect_timeout", 30.0) + read_timeout = kwargs.pop("read_timeout", 900.0) + write_timeout = kwargs.pop("write_timeout", 60.0) + pool_timeout = kwargs.pop("pool_timeout", 30.0) + + kwargs.setdefault( + "timeout", + httpx.Timeout( + connect=connect_timeout, + read=read_timeout, + write=write_timeout, + pool=pool_timeout, + ), + ) + super().__init__(**kwargs) + + def _patch_result_with_tools(self, result: ChatResult) -> ChatResult: + """Apply post-generation fixes to the model result.""" + for gen in result.generations: + msg = gen.message + + if isinstance(msg.content, str): + # Keep escaped newlines inside fenced code blocks untouched. + msg.content = _decode_escaped_newlines_outside_fences(msg.content) + + if "" in msg.content: + clean_content, extracted_tools = _parse_xml_tool_call_to_dict(msg.content) + + if extracted_tools: + msg.content = clean_content + if getattr(msg, "tool_calls", None) is None: + msg.tool_calls = [] + msg.tool_calls.extend(extracted_tools) + return result + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + result = super()._generate(_fix_messages(messages), stop=stop, run_manager=run_manager, **kwargs) + return self._patch_result_with_tools(result) + + async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): + result = await super()._agenerate(_fix_messages(messages), stop=stop, run_manager=run_manager, **kwargs) + return self._patch_result_with_tools(result) + + async def _astream(self, messages, stop=None, run_manager=None, **kwargs): + # Route standard queries to native streaming for lower TTFB + if not kwargs.get("tools"): + async for chunk in super()._astream(_fix_messages(messages), stop=stop, run_manager=run_manager, **kwargs): + if isinstance(chunk.message.content, str): + chunk.message.content = _decode_escaped_newlines_outside_fences(chunk.message.content) + yield chunk + return + + # Fallback for tool-enabled requests: + # MindIE currently drops choices when stream=True and tools are present. + # We await the full generation and yield chunks to simulate streaming. + result = await self._agenerate(messages, stop=stop, run_manager=run_manager, **kwargs) + + for gen in result.generations: + msg = gen.message + content = msg.content + standard_tool_calls = getattr(msg, "tool_calls", []) + + # Yield text in chunks to allow downstream UI/Markdown parsers to render smoothly + if isinstance(content, str) and content: + chunk_size = 15 + for i in range(0, len(content), chunk_size): + chunk_text = content[i : i + chunk_size] + chunk_msg = AIMessageChunk(content=chunk_text, id=msg.id, response_metadata=msg.response_metadata if i == 0 else {}) + yield ChatGenerationChunk(message=chunk_msg, generation_info=gen.generation_info if i == 0 else None) + + if standard_tool_calls: + yield ChatGenerationChunk(message=AIMessageChunk(content="", id=msg.id, tool_calls=standard_tool_calls, invalid_tool_calls=getattr(msg, "invalid_tool_calls", []))) + else: + chunk_msg = AIMessageChunk(content=content, id=msg.id, tool_calls=standard_tool_calls, invalid_tool_calls=getattr(msg, "invalid_tool_calls", [])) + yield ChatGenerationChunk(message=chunk_msg, generation_info=gen.generation_info) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 220ac23d6..fe280d701 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,7 +20,12 @@ dependencies = [ ] [dependency-groups] -dev = ["prompt-toolkit>=3.0.0", "pytest>=9.0.3", "ruff>=0.14.11"] +dev = [ + "prompt-toolkit>=3.0.0", + "pytest>=9.0.3", + "pytest-asyncio>=1.3.0", + "ruff>=0.14.11", +] [tool.uv.workspace] members = ["packages/harness"] diff --git a/backend/tests/test_mindie_provider.py b/backend/tests/test_mindie_provider.py new file mode 100644 index 000000000..552966c37 --- /dev/null +++ b/backend/tests/test_mindie_provider.py @@ -0,0 +1,397 @@ +""" +Unit tests for MindIEChatModel adapter. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult + +# ── Import the module under test ────────────────────────────────────────────── +from deerflow.models.mindie_provider import ( + MindIEChatModel, + _fix_messages, + _parse_xml_tool_call_to_dict, +) + +# ═════════════════════════════════════════════════════════════════════════════ +# Helpers +# ═════════════════════════════════════════════════════════════════════════════ + + +def _make_chat_result(content: str, tool_calls=None) -> ChatResult: + msg = AIMessage(content=content) + if tool_calls: + msg.tool_calls = tool_calls + gen = ChatGeneration(message=msg) + return ChatResult(generations=[gen]) + + +# ═════════════════════════════════════════════════════════════════════════════ +# 1. _fix_messages +# ═════════════════════════════════════════════════════════════════════════════ + + +class TestFixMessages: + # ── list content → str ──────────────────────────────────────────────────── + + def test_list_content_extracted_to_str(self): + msg = HumanMessage( + content=[ + {"type": "text", "text": "Hello"}, + {"type": "text", "text": " world"}, + ] + ) + result = _fix_messages([msg]) + assert result[0].content == "Hello world" + + def test_list_content_ignores_non_text_blocks(self): + msg = HumanMessage( + content=[ + {"type": "image_url", "image_url": "http://x.com/img.png"}, + {"type": "text", "text": "caption"}, + ] + ) + result = _fix_messages([msg]) + assert result[0].content == "caption" + + def test_empty_list_content_becomes_space(self): + msg = HumanMessage(content=[]) + result = _fix_messages([msg]) + assert result[0].content == " " + + # ── plain str content ───────────────────────────────────────────────────── + + def test_plain_string_content_preserved(self): + msg = HumanMessage(content="hi there") + result = _fix_messages([msg]) + assert result[0].content == "hi there" + + def test_empty_string_content_becomes_space(self): + msg = HumanMessage(content="") + result = _fix_messages([msg]) + assert result[0].content == " " + + # ── AIMessage with tool_calls → XML ─────────────────────────────────────── + + def test_ai_message_with_tool_calls_serialised_to_xml(self): + msg = AIMessage( + content="Sure", + tool_calls=[ + { + "name": "get_weather", + "args": {"city": "London"}, + "id": "call_abc", + } + ], + ) + result = _fix_messages([msg]) + out = result[0] + assert isinstance(out, AIMessage) + assert "" in out.content + assert "" in out.content + assert '"London"' in out.content + assert not getattr(out, "tool_calls", []) + + def test_ai_message_text_preserved_before_xml(self): + msg = AIMessage( + content="Here you go", + tool_calls=[{"name": "search", "args": {"q": "pytest"}, "id": "x"}], + ) + result = _fix_messages([msg]) + assert result[0].content.startswith("Here you go") + + def test_ai_message_multiple_tool_calls(self): + msg = AIMessage( + content="", + tool_calls=[ + {"name": "tool_a", "args": {"x": 1}, "id": "id1"}, + {"name": "tool_b", "args": {"y": 2}, "id": "id2"}, + ], + ) + result = _fix_messages([msg]) + content = result[0].content + assert content.count("") == 2 + assert "" in content + assert "" in content + + # ── ToolMessage → HumanMessage ──────────────────────────────────────────── + + def test_tool_message_becomes_human_message(self): + msg = ToolMessage(content="42 degrees", tool_call_id="call_abc") + result = _fix_messages([msg]) + out = result[0] + assert isinstance(out, HumanMessage) + assert "" in out.content + assert "42 degrees" in out.content + + def test_tool_message_with_list_content(self): + msg = ToolMessage( + content=[{"type": "text", "text": "result"}], + tool_call_id="call_xyz", + ) + result = _fix_messages([msg]) + assert isinstance(result[0], HumanMessage) + assert "result" in result[0].content + + # ── Mixed message list ──────────────────────────────────────────────────── + + def test_mixed_message_types_ordering_preserved(self): + msgs = [ + HumanMessage(content="q"), + AIMessage(content="a"), + ToolMessage(content="tool out", tool_call_id="c1"), + HumanMessage(content="follow up"), + ] + result = _fix_messages(msgs) + assert len(result) == 4 + assert isinstance(result[2], HumanMessage) + assert result[3].content == "follow up" + + # ── SystemMessage pass-through ──────────────────────────────────────────── + + def test_system_message_passed_through_unchanged(self): + msg = SystemMessage(content="You are helpful.") + result = _fix_messages([msg]) + assert result[0].content == "You are helpful." + + +# ═════════════════════════════════════════════════════════════════════════════ +# 2. _parse_xml_tool_call_to_dict +# ═════════════════════════════════════════════════════════════════════════════ + + +class TestParseXmlToolCalls: + def test_no_tool_call_returns_original(self): + content = "Just a normal reply." + clean, calls = _parse_xml_tool_call_to_dict(content) + assert clean == content + assert calls == [] + + def test_single_tool_call_parsed(self): + content = " pytest " + clean, calls = _parse_xml_tool_call_to_dict(content) + assert clean == "" + assert len(calls) == 1 + assert calls[0]["name"] == "search" + assert calls[0]["args"]["query"] == "pytest" + assert calls[0]["id"].startswith("call_") + + def test_multiple_tool_calls_parsed(self): + content = "12" + _, calls = _parse_xml_tool_call_to_dict(content) + assert len(calls) == 2 + assert calls[0]["name"] == "a" + assert calls[1]["name"] == "b" + + def test_text_before_tool_call_preserved(self): + content = "Here is the answer.\nv" + clean, calls = _parse_xml_tool_call_to_dict(content) + assert clean == "Here is the answer." + assert len(calls) == 1 + + def test_integer_param_deserialised(self): + content = "42" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["n"] == 42 + + def test_list_param_deserialised(self): + content = '["a","b"]' + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["lst"] == ["a", "b"] + + def test_dict_param_deserialised(self): + content = '{"k": 1}' + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["d"] == {"k": 1} + + def test_bool_param_deserialised(self): + content = "true" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["flag"] is True + + def test_malformed_param_stays_string(self): + content = "{broken json" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["bad"] == "{broken json" + + def test_non_string_input_returned_as_is(self): + result = _parse_xml_tool_call_to_dict(None) + assert result == (None, []) + + def test_unique_ids_generated(self): + block = "v" + _, c1 = _parse_xml_tool_call_to_dict(block) + _, c2 = _parse_xml_tool_call_to_dict(block) + assert c1[0]["id"] != c2[0]["id"] + + +# ═════════════════════════════════════════════════════════════════════════════ +# 3. MindIEChatModel._patch_result_with_tools +# ═════════════════════════════════════════════════════════════════════════════ + + +class TestPatchResult: + def _model(self): + with patch.object(MindIEChatModel, "__init__", return_value=None): + m = MindIEChatModel.__new__(MindIEChatModel) + return m + + def test_escaped_newlines_fixed(self): + model = self._model() + result = _make_chat_result("line1\\nline2") + patched = model._patch_result_with_tools(result) + assert patched.generations[0].message.content == "line1\nline2" + + def test_xml_tool_calls_extracted(self): + model = self._model() + content = "1+1" + result = _make_chat_result(content) + patched = model._patch_result_with_tools(result) + msg = patched.generations[0].message + assert msg.content == "" + assert len(msg.tool_calls) == 1 + assert msg.tool_calls[0]["name"] == "calc" + + def test_patch_result_appends_to_existing_tool_calls(self): + model = self._model() + existing = [{"name": "existing", "args": {}, "id": "e1"}] + content = "v" + result = _make_chat_result(content, tool_calls=existing) + patched = model._patch_result_with_tools(result) + msg = patched.generations[0].message + assert len(msg.tool_calls) == 2 + names = [tc["name"] for tc in msg.tool_calls] + assert "existing" in names + assert "new_tool" in names + + def test_no_tool_call_content_unchanged(self): + model = self._model() + result = _make_chat_result("plain reply") + patched = model._patch_result_with_tools(result) + assert patched.generations[0].message.content == "plain reply" + + def test_non_string_content_skipped(self): + model = self._model() + msg = AIMessage(content=[{"type": "text", "text": "hi"}]) + gen = ChatGeneration(message=msg) + result = ChatResult(generations=[gen]) + patched = model._patch_result_with_tools(result) + assert patched is not None + + +# ═════════════════════════════════════════════════════════════════════════════ +# 4. MindIEChatModel._generate (sync) +# ═════════════════════════════════════════════════════════════════════════════ + + +class TestGenerate: + def test_generate_calls_fix_messages_and_patch(self): + with patch("deerflow.models.mindie_provider.ChatOpenAI._generate") as mock_super_gen, patch.object(MindIEChatModel, "__init__", return_value=None): + mock_super_gen.return_value = _make_chat_result("hello") + model = MindIEChatModel.__new__(MindIEChatModel) + + msgs = [HumanMessage(content="ping")] + result = model._generate(msgs) + + assert mock_super_gen.called + called_msgs = mock_super_gen.call_args[0][0] + assert all(isinstance(m.content, str) for m in called_msgs) + assert result.generations[0].message.content == "hello" + + +# ═════════════════════════════════════════════════════════════════════════════ +# 5. MindIEChatModel._agenerate (async) +# ═════════════════════════════════════════════════════════════════════════════ + + +class TestAGenerate: + @pytest.mark.asyncio + async def test_agenerate_patches_result(self): + with patch("deerflow.models.mindie_provider.ChatOpenAI._agenerate", new_callable=AsyncMock) as mock_ag, patch.object(MindIEChatModel, "__init__", return_value=None): + mock_ag.return_value = _make_chat_result("world\\nfoo") + model = MindIEChatModel.__new__(MindIEChatModel) + + result = await model._agenerate([HumanMessage(content="hi")]) + assert result.generations[0].message.content == "world\nfoo" + + +# ═════════════════════════════════════════════════════════════════════════════ +# 6. MindIEChatModel._astream (async generator) +# ═════════════════════════════════════════════════════════════════════════════ + + +class TestAStream: + async def _collect(self, gen): + chunks = [] + async for chunk in gen: + chunks.append(chunk) + return chunks + + @pytest.mark.asyncio + async def test_no_tools_uses_real_stream(self): + from langchain_core.messages import AIMessageChunk + from langchain_core.outputs import ChatGenerationChunk + + async def fake_stream(*args, **kwargs): + for char in ["hel", "lo"]: + yield ChatGenerationChunk(message=AIMessageChunk(content=char)) + + with patch("deerflow.models.mindie_provider.ChatOpenAI._astream", side_effect=fake_stream), patch.object(MindIEChatModel, "__init__", return_value=None): + model = MindIEChatModel.__new__(MindIEChatModel) + chunks = await self._collect(model._astream([HumanMessage(content="hi")])) + + assert "".join(c.message.content for c in chunks) == "hello" + + @pytest.mark.asyncio + async def test_no_tools_fixes_escaped_newlines_in_stream(self): + from langchain_core.messages import AIMessageChunk + from langchain_core.outputs import ChatGenerationChunk + + async def fake_stream(*args, **kwargs): + yield ChatGenerationChunk(message=AIMessageChunk(content="a\\nb")) + + with patch("deerflow.models.mindie_provider.ChatOpenAI._astream", side_effect=fake_stream), patch.object(MindIEChatModel, "__init__", return_value=None): + model = MindIEChatModel.__new__(MindIEChatModel) + chunks = await self._collect(model._astream([HumanMessage(content="x")])) + + assert chunks[0].message.content == "a\nb" + + @pytest.mark.asyncio + async def test_with_tools_fake_streams_text_in_chunks(self): + with patch.object(MindIEChatModel, "_agenerate", new_callable=AsyncMock) as mock_ag, patch.object(MindIEChatModel, "__init__", return_value=None): + long_text = "A" * 50 + mock_ag.return_value = _make_chat_result(long_text) + model = MindIEChatModel.__new__(MindIEChatModel) + + chunks = await self._collect(model._astream([HumanMessage(content="q")], tools=[{"type": "function", "function": {"name": "dummy"}}])) + + full = "".join(c.message.content for c in chunks) + assert full == long_text + assert len(chunks) > 1 + + @pytest.mark.asyncio + async def test_with_tools_emits_tool_call_chunk(self): + + tool_calls = [{"name": "fn", "args": {}, "id": "c1"}] + with patch.object(MindIEChatModel, "_agenerate", new_callable=AsyncMock) as mock_ag, patch.object(MindIEChatModel, "__init__", return_value=None): + mock_ag.return_value = _make_chat_result("ok", tool_calls=tool_calls) + model = MindIEChatModel.__new__(MindIEChatModel) + + chunks = await self._collect(model._astream([HumanMessage(content="q")], tools=[{"type": "function", "function": {"name": "fn"}}])) + + tool_chunks = [c for c in chunks if getattr(c.message, "tool_calls", [])] + assert tool_chunks, "No chunk carried tool_calls" + assert tool_chunks[-1].message.tool_calls[0]["name"] == "fn" + + @pytest.mark.asyncio + async def test_with_tools_empty_text_still_emits_tool_chunk(self): + tool_calls = [{"name": "x", "args": {}, "id": "c2"}] + with patch.object(MindIEChatModel, "_agenerate", new_callable=AsyncMock) as mock_ag, patch.object(MindIEChatModel, "__init__", return_value=None): + mock_ag.return_value = _make_chat_result("", tool_calls=tool_calls) + model = MindIEChatModel.__new__(MindIEChatModel) + + chunks = await self._collect(model._astream([HumanMessage(content="q")], tools=[{"type": "function", "function": {"name": "x"}}])) + + assert any(getattr(c.message, "tool_calls", []) for c in chunks) diff --git a/backend/uv.lock b/backend/uv.lock index 716b7e07a..bd2630869 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -688,6 +688,7 @@ dependencies = [ dev = [ { name = "prompt-toolkit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -711,6 +712,7 @@ requires-dist = [ dev = [ { name = "prompt-toolkit", specifier = ">=3.0.0" }, { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "ruff", specifier = ">=0.14.11" }, ] @@ -3127,6 +3129,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" diff --git a/config.example.yaml b/config.example.yaml index 1c5bf4129..32a94105a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -326,6 +326,27 @@ models: # chat_template_kwargs: # enable_thinking: true + + # Example: Qwen3-Coder deployed on MindIE Engine + # - name: Qwen3_Coder_480B_MindIE + # display_name: Qwen3-Coder-480B (MindIE) + # use: deerflow.models.mindie_provider:MindIEChatModel + # model: Qwen3-Coder-480B-A35B-Instruct-Client + # base_url: http://localhost:8989/v1 + # api_key: $OPENAI_API_KEY + # temperature: 0 + # max_retries: 1 + # supports_thinking: false + # supports_vision: false + # supports_reasoning_effort: false + # # --- Advanced Network Settings --- + # # Due to MindIE's streaming limitations with tool calling, the provider + # # uses mock-streaming (awaiting full generation). Extended timeouts are required. + # read_timeout: 900.0 # 15 minutes to prevent drops during long document generation + # connect_timeout: 30.0 + # write_timeout: 60.0 + # pool_timeout: 30.0 + # ============================================================================ # Tool Groups Configuration # ============================================================================ From 950821cb9bb7fba773ba88e14cd8ade9f9151b8b Mon Sep 17 00:00:00 2001 From: orbisai0security Date: Sat, 25 Apr 2026 06:29:31 +0530 Subject: [PATCH 20/83] fix: use subprocess instead of os.system in local_backend.py (#2494) * fix: use subprocess instead of os.system in local_backend.py The sandbox backend and skill evaluation scripts use subprocess * fixing the failing test --------- Co-authored-by: Willem Jiang --- .../harness/deerflow/sandbox/local/local_sandbox.py | 6 +++--- backend/tests/test_local_sandbox_provider_mounts.py | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py index 2da0a678f..ae8c948b0 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py @@ -288,10 +288,10 @@ class LocalSandbox(Sandbox): timeout=600, ) else: + args = [shell, "-c", resolved_command] result = subprocess.run( - resolved_command, - executable=shell, - shell=True, + args, + shell=False, capture_output=True, text=True, timeout=600, diff --git a/backend/tests/test_local_sandbox_provider_mounts.py b/backend/tests/test_local_sandbox_provider_mounts.py index 18e180e3b..328b1d48d 100644 --- a/backend/tests/test_local_sandbox_provider_mounts.py +++ b/backend/tests/test_local_sandbox_provider_mounts.py @@ -255,7 +255,9 @@ class TestMultipleMounts: sandbox.execute_command("cat /mnt/data/test.txt") # Verify the command received the resolved local path - assert str(data_dir) in captured.get("command", "") + command = captured.get("command", []) + assert isinstance(command, list) and len(command) >= 3 + assert str(data_dir) in command[2] def test_reverse_resolve_path_does_not_match_partial_prefix(self, tmp_path): foo_dir = tmp_path / "foo" From f394c0d8c8de8821ac6a5becc73f5a9587a03e42 Mon Sep 17 00:00:00 2001 From: IECspace Date: Sat, 25 Apr 2026 09:18:13 +0800 Subject: [PATCH 21/83] feat(mcp): support custom tool interceptors via extensions_config.json (#2451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): support custom tool interceptors via extensions_config.json Add a generic extension point for registering custom MCP tool interceptors through `extensions_config.json`. This allows downstream projects to inject per-request header manipulation, auth context propagation, or other cross-cutting concerns without modifying DeerFlow source code. Interceptors are declared as Python callable paths in a new `mcpInterceptors` array field and loaded via the existing `resolve_variable` reflection mechanism: ```json { "mcpInterceptors": [ "my_package.mcp.auth:build_auth_interceptor" ] } ``` Each entry must resolve to a no-arg builder function that returns an async interceptor compatible with `MultiServerMCPClient`'s `tool_interceptors` interface. Co-Authored-By: Claude Opus 4.6 (1M context) * test(mcp): add unit tests for custom tool interceptors Cover all branches of the mcpInterceptors loading logic: - valid interceptor loaded and appended to tool_interceptors - multiple interceptors loaded in declaration order - builder returning None is skipped - resolve_variable ImportError logged and skipped - builder raising exception logged and skipped - absent mcpInterceptors field is safe (no-op) - custom interceptors coexist with OAuth interceptor Co-Authored-By: Claude Opus 4.6 (1M context) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(mcp): validate mcpInterceptors type and fix lint warnings Address review feedback: 1. Validate mcpInterceptors config value before iterating: - Accept a single string and normalize to [string] - Ignore None silently - Log warning and skip for non-list/non-string types 2. Fix ruff F841 lint errors in tests: - Rename _make_mock_env to _make_patches, embed mock_client - Remove unused `as mock_cls` bindings where not needed - Extract _get_interceptors() helper to reduce repetition 3. Add two new test cases for type validation: - test_mcp_interceptors_single_string_is_normalized - test_mcp_interceptors_invalid_type_logs_warning Co-Authored-By: Claude Opus 4.6 (1M context) * fix(mcp): validate interceptor return type and fix import mock path Address review feedback: 1. Validate builder return type with callable() check: - callable interceptor → append to tool_interceptors - None → silently skip (builder opted out) - non-callable → log warning with type name and skip 2. Fix test mock path: resolve_variable is a top-level import in tools.py, so mock deerflow.mcp.tools.resolve_variable instead of deerflow.reflection.resolve_variable to correctly intercept calls. 3. Add test_custom_interceptor_non_callable_return_logs_warning to cover the new non-callable validation branch. Co-Authored-By: Claude Opus 4.6 (1M context) * docs(mcp): add mcpInterceptors example and documentation - Add mcpInterceptors field to extensions_config.example.json - Add "Custom Tool Interceptors" section to MCP_SERVER.md with configuration format, example interceptor code, and edge case behavior notes Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: IECspace Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Willem Jiang Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/docs/MCP_SERVER.md | 35 +++ .../packages/harness/deerflow/mcp/tools.py | 22 ++ backend/tests/test_mcp_custom_interceptors.py | 274 ++++++++++++++++++ extensions_config.example.json | 3 + 4 files changed, 334 insertions(+) create mode 100644 backend/tests/test_mcp_custom_interceptors.py diff --git a/backend/docs/MCP_SERVER.md b/backend/docs/MCP_SERVER.md index efe2ea0c4..b7320f8cc 100644 --- a/backend/docs/MCP_SERVER.md +++ b/backend/docs/MCP_SERVER.md @@ -45,6 +45,41 @@ Example: } ``` +## Custom Tool Interceptors + +You can register custom interceptors that run before every MCP tool call. This is useful for injecting per-request headers (e.g., user auth tokens from the LangGraph execution context), logging, or metrics. + +Declare interceptors in `extensions_config.json` using the `mcpInterceptors` field: + +```json +{ + "mcpInterceptors": [ + "my_package.mcp.auth:build_auth_interceptor" + ], + "mcpServers": { ... } +} +``` + +Each entry is a Python import path in `module:variable` format (resolved via `resolve_variable`). The variable must be a **no-arg builder function** that returns an async interceptor compatible with `MultiServerMCPClient`’s `tool_interceptors` interface, or `None` to skip. + +Example interceptor that injects auth headers from LangGraph metadata: + +```python +def build_auth_interceptor(): + async def interceptor(request, handler): + from langgraph.config import get_config + metadata = get_config().get("metadata", {}) + headers = dict(request.headers or {}) + if token := metadata.get("auth_token"): + headers["X-Auth-Token"] = token + return await handler(request.override(headers=headers)) + return interceptor +``` + +- A single string value is accepted and normalized to a one-element list. +- Invalid paths or builder failures are logged as warnings without blocking other interceptors. +- The builder return value must be `callable`; non-callable values are skipped with a warning. + ## How It Works MCP servers expose tools that are automatically discovered and integrated into DeerFlow’s agent system at runtime. Once enabled, these tools become available to agents without additional code changes. diff --git a/backend/packages/harness/deerflow/mcp/tools.py b/backend/packages/harness/deerflow/mcp/tools.py index 718ac2ba3..bcd50c645 100644 --- a/backend/packages/harness/deerflow/mcp/tools.py +++ b/backend/packages/harness/deerflow/mcp/tools.py @@ -12,6 +12,7 @@ from langchain_core.tools import BaseTool from deerflow.config.extensions_config import ExtensionsConfig from deerflow.mcp.client import build_servers_config from deerflow.mcp.oauth import build_oauth_tool_interceptor, get_initial_oauth_headers +from deerflow.reflection import resolve_variable logger = logging.getLogger(__name__) @@ -95,6 +96,27 @@ async def get_mcp_tools() -> list[BaseTool]: if oauth_interceptor is not None: tool_interceptors.append(oauth_interceptor) + # Load custom interceptors declared in extensions_config.json + # Format: "mcpInterceptors": ["pkg.module:builder_func", ...] + raw_interceptor_paths = (extensions_config.model_extra or {}).get("mcpInterceptors") + if isinstance(raw_interceptor_paths, str): + raw_interceptor_paths = [raw_interceptor_paths] + elif not isinstance(raw_interceptor_paths, list): + if raw_interceptor_paths is not None: + logger.warning(f"mcpInterceptors must be a list of strings, got {type(raw_interceptor_paths).__name__}; skipping") + raw_interceptor_paths = [] + for interceptor_path in raw_interceptor_paths: + try: + builder = resolve_variable(interceptor_path) + interceptor = builder() + if callable(interceptor): + tool_interceptors.append(interceptor) + logger.info(f"Loaded MCP interceptor: {interceptor_path}") + elif interceptor is not None: + logger.warning(f"Builder {interceptor_path} returned non-callable {type(interceptor).__name__}; skipping") + except Exception as e: + logger.warning(f"Failed to load MCP interceptor {interceptor_path}: {e}", exc_info=True) + client = MultiServerMCPClient(servers_config, tool_interceptors=tool_interceptors, tool_name_prefix=True) # Get all tools from all servers diff --git a/backend/tests/test_mcp_custom_interceptors.py b/backend/tests/test_mcp_custom_interceptors.py new file mode 100644 index 000000000..08432de98 --- /dev/null +++ b/backend/tests/test_mcp_custom_interceptors.py @@ -0,0 +1,274 @@ +"""Tests for custom MCP tool interceptors loaded via extensions_config.json.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +from deerflow.mcp.tools import get_mcp_tools + + +def _make_patches(*, interceptor_paths=None): + """Set up mocks for get_mcp_tools() with optional custom interceptors. + + Returns a dict of patch context managers. + """ + mock_client = MagicMock() + mock_client.get_tools = AsyncMock(return_value=[]) + + extra = {} + if interceptor_paths is not None: + extra["mcpInterceptors"] = interceptor_paths + + return { + "client_cls": patch( + "langchain_mcp_adapters.client.MultiServerMCPClient", + return_value=mock_client, + ), + "from_file": patch( + "deerflow.config.extensions_config.ExtensionsConfig.from_file", + return_value=MagicMock( + model_extra=extra, + get_enabled_mcp_servers=MagicMock(return_value={}), + ), + ), + "build_servers": patch( + "deerflow.mcp.tools.build_servers_config", + return_value={"test-server": {}}, + ), + "oauth_headers": patch( + "deerflow.mcp.tools.get_initial_oauth_headers", + new_callable=AsyncMock, + return_value={}, + ), + "oauth_interceptor": patch( + "deerflow.mcp.tools.build_oauth_tool_interceptor", + return_value=None, + ), + } + + +def _get_interceptors(mock_cls): + """Extract the tool_interceptors list passed to MultiServerMCPClient.""" + kw = mock_cls.call_args + return kw.kwargs.get("tool_interceptors") or kw[1].get("tool_interceptors", []) + + +def test_custom_interceptor_loaded_and_appended(): + """A valid interceptor builder path is resolved, called, and appended to tool_interceptors.""" + + async def fake_interceptor(request, handler): + return await handler(request) + + def fake_builder(): + return fake_interceptor + + p = _make_patches(interceptor_paths=["my_package.auth:build_interceptor"]) + + with ( + p["client_cls"] as mock_cls, + p["from_file"], + p["build_servers"], + p["oauth_headers"], + p["oauth_interceptor"], + patch("deerflow.mcp.tools.resolve_variable", return_value=fake_builder), + ): + asyncio.run(get_mcp_tools()) + + interceptors = _get_interceptors(mock_cls) + assert len(interceptors) == 1 + assert interceptors[0] is fake_interceptor + + +def test_multiple_custom_interceptors(): + """Multiple interceptor paths are all loaded in order.""" + + async def interceptor_a(request, handler): + return await handler(request) + + async def interceptor_b(request, handler): + return await handler(request) + + builders = { + "pkg.a:build_a": lambda: interceptor_a, + "pkg.b:build_b": lambda: interceptor_b, + } + + p = _make_patches(interceptor_paths=["pkg.a:build_a", "pkg.b:build_b"]) + + with ( + p["client_cls"] as mock_cls, + p["from_file"], + p["build_servers"], + p["oauth_headers"], + p["oauth_interceptor"], + patch("deerflow.mcp.tools.resolve_variable", side_effect=lambda path: builders[path]), + ): + asyncio.run(get_mcp_tools()) + + interceptors = _get_interceptors(mock_cls) + assert len(interceptors) == 2 + assert interceptors[0] is interceptor_a + assert interceptors[1] is interceptor_b + + +def test_custom_interceptor_builder_returning_none_is_skipped(): + """If a builder returns None, it is not appended to the interceptor list.""" + p = _make_patches(interceptor_paths=["pkg.noop:build_noop"]) + + with ( + p["client_cls"] as mock_cls, + p["from_file"], + p["build_servers"], + p["oauth_headers"], + p["oauth_interceptor"], + patch("deerflow.mcp.tools.resolve_variable", return_value=lambda: None), + ): + asyncio.run(get_mcp_tools()) + + assert len(_get_interceptors(mock_cls)) == 0 + + +def test_custom_interceptor_resolve_error_logs_warning_and_continues(): + """A broken interceptor path logs a warning and does not block tool loading.""" + p = _make_patches(interceptor_paths=["broken.path:does_not_exist"]) + + with ( + p["client_cls"], + p["from_file"], + p["build_servers"], + p["oauth_headers"], + p["oauth_interceptor"], + patch("deerflow.mcp.tools.resolve_variable", side_effect=ImportError("no such module")), + patch("deerflow.mcp.tools.logger.warning") as mock_warn, + ): + tools = asyncio.run(get_mcp_tools()) + + assert tools == [] + mock_warn.assert_called_once() + assert "broken.path:does_not_exist" in mock_warn.call_args[0][0] + + +def test_custom_interceptor_builder_exception_logs_warning_and_continues(): + """If the builder function itself raises, the error is caught and logged.""" + + def exploding_builder(): + raise RuntimeError("builder exploded") + + p = _make_patches(interceptor_paths=["pkg.bad:exploding_builder"]) + + with ( + p["client_cls"], + p["from_file"], + p["build_servers"], + p["oauth_headers"], + p["oauth_interceptor"], + patch("deerflow.mcp.tools.resolve_variable", return_value=exploding_builder), + patch("deerflow.mcp.tools.logger.warning") as mock_warn, + ): + tools = asyncio.run(get_mcp_tools()) + + assert tools == [] + mock_warn.assert_called_once() + assert "pkg.bad:exploding_builder" in mock_warn.call_args[0][0] + + +def test_no_mcp_interceptors_field_is_safe(): + """When mcpInterceptors is absent from config, no interceptors are added.""" + p = _make_patches(interceptor_paths=None) + + with ( + p["client_cls"] as mock_cls, + p["from_file"], + p["build_servers"], + p["oauth_headers"], + p["oauth_interceptor"], + ): + asyncio.run(get_mcp_tools()) + + assert len(_get_interceptors(mock_cls)) == 0 + + +def test_custom_interceptor_coexists_with_oauth_interceptor(): + """Custom interceptors are appended after the OAuth interceptor.""" + + async def oauth_fn(request, handler): + return await handler(request) + + async def custom_fn(request, handler): + return await handler(request) + + p = _make_patches(interceptor_paths=["pkg.custom:build_custom"]) + + with ( + p["client_cls"] as mock_cls, + p["from_file"], + p["build_servers"], + p["oauth_headers"], + patch("deerflow.mcp.tools.build_oauth_tool_interceptor", return_value=oauth_fn), + patch("deerflow.mcp.tools.resolve_variable", return_value=lambda: custom_fn), + ): + asyncio.run(get_mcp_tools()) + + interceptors = _get_interceptors(mock_cls) + assert len(interceptors) == 2 + assert interceptors[0] is oauth_fn + assert interceptors[1] is custom_fn + + +def test_mcp_interceptors_single_string_is_normalized(): + """A single string value for mcpInterceptors is normalized to a list.""" + + async def fake_interceptor(request, handler): + return await handler(request) + + p = _make_patches(interceptor_paths="pkg.single:build_it") + + with ( + p["client_cls"] as mock_cls, + p["from_file"], + p["build_servers"], + p["oauth_headers"], + p["oauth_interceptor"], + patch("deerflow.mcp.tools.resolve_variable", return_value=lambda: fake_interceptor), + ): + asyncio.run(get_mcp_tools()) + + assert len(_get_interceptors(mock_cls)) == 1 + + +def test_mcp_interceptors_invalid_type_logs_warning(): + """A non-list, non-string value for mcpInterceptors logs a warning and is skipped.""" + p = _make_patches(interceptor_paths=42) + + with ( + p["client_cls"] as mock_cls, + p["from_file"], + p["build_servers"], + p["oauth_headers"], + p["oauth_interceptor"], + patch("deerflow.mcp.tools.logger.warning") as mock_warn, + ): + asyncio.run(get_mcp_tools()) + + assert len(_get_interceptors(mock_cls)) == 0 + mock_warn.assert_called_once() + assert "must be a list" in mock_warn.call_args[0][0] + + +def test_custom_interceptor_non_callable_return_logs_warning(): + """If a builder returns a non-callable value, it is skipped with a warning.""" + p = _make_patches(interceptor_paths=["pkg.bad:returns_string"]) + + with ( + p["client_cls"] as mock_cls, + p["from_file"], + p["build_servers"], + p["oauth_headers"], + p["oauth_interceptor"], + patch("deerflow.mcp.tools.resolve_variable", return_value=lambda: "not_a_callable"), + patch("deerflow.mcp.tools.logger.warning") as mock_warn, + ): + asyncio.run(get_mcp_tools()) + + assert len(_get_interceptors(mock_cls)) == 0 + mock_warn.assert_called_once() + assert "non-callable" in mock_warn.call_args[0][0] diff --git a/extensions_config.example.json b/extensions_config.example.json index dc0e224ea..118c5d6db 100644 --- a/extensions_config.example.json +++ b/extensions_config.example.json @@ -1,4 +1,7 @@ { + "mcpInterceptors": [ + "my_package.mcp.auth:build_auth_interceptor" + ], "mcpServers": { "filesystem": { "enabled": false, From 1f59e945af4a04824deda90cd41ca318670858c5 Mon Sep 17 00:00:00 2001 From: Octopus Date: Sat, 25 Apr 2026 19:40:06 +0800 Subject: [PATCH 22/83] fix: cap prompt caching breakpoints at 4 to prevent API 400 errors (#2449) * fix: cap prompt caching breakpoints at 4 to prevent API 400 errors (fixes #2448) The previous _apply_prompt_caching() attached cache_control to every text block in the system prompt, every content block in the last N messages, and the last tool definition. In multi-turn conversations with structured content blocks this easily exceeded the 4-breakpoint hard limit enforced by both the Anthropic API and AWS Bedrock, producing a 400 Bad Request (or a silent "No generations found in stream" when streaming). Fix: collect all candidate blocks in document order, then apply cache_control only to the last MAX_CACHE_BREAKPOINTS (4) of them. Later breakpoints cover a larger prefix and therefore yield better cache hit rates, making this the optimal placement strategy as well as the safe one. Adds 13 unit tests covering the budget cap, edge cases, and correct last-candidate placement. * docs: clarify _apply_prompt_caching docstring includes tool definitions Per Copilot review: the implementation also caches the last tool definition (see the candidates list at lines 202-205), so the docstring summary should explicitly mention tools alongside system and recent messages. * Fix the lint error * style: fix ruff format check for test_claude_provider_prompt_caching.py Add the missing blank line before the 'Edge cases' section comment so that ruff format --check passes in CI. --------- Co-authored-by: octo-patch Co-authored-by: Willem Jiang --- .../deerflow/models/claude_provider.py | 53 ++-- .../test_claude_provider_prompt_caching.py | 249 ++++++++++++++++++ 2 files changed, 281 insertions(+), 21 deletions(-) create mode 100644 backend/tests/test_claude_provider_prompt_caching.py diff --git a/backend/packages/harness/deerflow/models/claude_provider.py b/backend/packages/harness/deerflow/models/claude_provider.py index 2c0050313..35a15494d 100644 --- a/backend/packages/harness/deerflow/models/claude_provider.py +++ b/backend/packages/harness/deerflow/models/claude_provider.py @@ -190,23 +190,33 @@ class ClaudeChatModel(ChatAnthropic): ) def _apply_prompt_caching(self, payload: dict) -> None: - """Apply ephemeral cache_control to system and recent messages.""" - # Cache system messages + """Apply ephemeral cache_control to system, recent messages, and last tool definition. + + Uses a budget of MAX_CACHE_BREAKPOINTS (4) breakpoints — the hard limit + enforced by both the Anthropic API and AWS Bedrock. Breakpoints are + placed on the *last* eligible blocks because later breakpoints cover a + larger prefix and yield better cache hit rates. + """ + MAX_CACHE_BREAKPOINTS = 4 + + # Collect candidate blocks in document order: + # 1. system text blocks + # 2. content blocks of the last prompt_cache_size messages + # 3. the last tool definition + candidates: list[dict] = [] + + # 1. System blocks system = payload.get("system") if system and isinstance(system, list): for block in system: if isinstance(block, dict) and block.get("type") == "text": - block["cache_control"] = {"type": "ephemeral"} + candidates.append(block) elif system and isinstance(system, str): - payload["system"] = [ - { - "type": "text", - "text": system, - "cache_control": {"type": "ephemeral"}, - } - ] + new_block: dict = {"type": "text", "text": system} + payload["system"] = [new_block] + candidates.append(new_block) - # Cache recent messages + # 2. Recent message blocks messages = payload.get("messages", []) cache_start = max(0, len(messages) - self.prompt_cache_size) for i in range(cache_start, len(messages)): @@ -217,20 +227,21 @@ class ClaudeChatModel(ChatAnthropic): if isinstance(content, list): for block in content: if isinstance(block, dict): - block["cache_control"] = {"type": "ephemeral"} + candidates.append(block) elif isinstance(content, str) and content: - msg["content"] = [ - { - "type": "text", - "text": content, - "cache_control": {"type": "ephemeral"}, - } - ] + new_block = {"type": "text", "text": content} + msg["content"] = [new_block] + candidates.append(new_block) - # Cache the last tool definition + # 3. Last tool definition tools = payload.get("tools", []) if tools and isinstance(tools[-1], dict): - tools[-1]["cache_control"] = {"type": "ephemeral"} + candidates.append(tools[-1]) + + # Apply cache_control only to the last MAX_CACHE_BREAKPOINTS candidates + # to stay within the API limit. + for block in candidates[-MAX_CACHE_BREAKPOINTS:]: + block["cache_control"] = {"type": "ephemeral"} def _apply_thinking_budget(self, payload: dict) -> None: """Auto-allocate thinking budget (80% of max_tokens).""" diff --git a/backend/tests/test_claude_provider_prompt_caching.py b/backend/tests/test_claude_provider_prompt_caching.py new file mode 100644 index 000000000..e212b7329 --- /dev/null +++ b/backend/tests/test_claude_provider_prompt_caching.py @@ -0,0 +1,249 @@ +"""Tests for ClaudeChatModel._apply_prompt_caching. + +Validates that the function never places more than 4 cache_control breakpoints +(the hard limit enforced by the Anthropic API and AWS Bedrock) regardless of +how many system blocks, message content blocks, or tool definitions are present. +""" + +from unittest import mock + +import pytest + +from deerflow.models.claude_provider import ClaudeChatModel + + +def _make_model(prompt_cache_size: int = 3) -> ClaudeChatModel: + """Return a minimal ClaudeChatModel instance without network calls.""" + with mock.patch.object(ClaudeChatModel, "model_post_init"): + m = ClaudeChatModel( + model="claude-sonnet-4-6", + anthropic_api_key="sk-ant-fake", # type: ignore[call-arg] + prompt_cache_size=prompt_cache_size, + ) + m._is_oauth = False + m.enable_prompt_caching = True + return m + + +def _count_cache_control(payload: dict) -> int: + """Count the total number of cache_control markers in a payload.""" + count = 0 + + system = payload.get("system", []) + if isinstance(system, list): + for block in system: + if isinstance(block, dict) and "cache_control" in block: + count += 1 + + for msg in payload.get("messages", []): + if not isinstance(msg, dict): + continue + content = msg.get("content", []) + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and "cache_control" in block: + count += 1 + + for tool in payload.get("tools", []): + if isinstance(tool, dict) and "cache_control" in tool: + count += 1 + + return count + + +@pytest.fixture() +def model() -> ClaudeChatModel: + return _make_model() + + +# --------------------------------------------------------------------------- +# Basic correctness +# --------------------------------------------------------------------------- + + +def test_single_system_block_gets_cached(model): + payload: dict = {"system": [{"type": "text", "text": "sys"}]} + model._apply_prompt_caching(payload) + assert payload["system"][0].get("cache_control") == {"type": "ephemeral"} + + +def test_string_system_converted_and_cached(model): + payload: dict = {"system": "you are helpful"} + model._apply_prompt_caching(payload) + assert isinstance(payload["system"], list) + assert payload["system"][0].get("cache_control") == {"type": "ephemeral"} + + +def test_last_tool_gets_cached_when_budget_allows(model): + payload: dict = { + "tools": [{"name": "t1"}, {"name": "t2"}], + } + model._apply_prompt_caching(payload) + # With no system or messages the last tool should be cached. + assert payload["tools"][-1].get("cache_control") == {"type": "ephemeral"} + assert "cache_control" not in payload["tools"][0] + + +def test_recent_messages_get_cached(model): + """The last prompt_cache_size messages' content blocks should be cached.""" + payload: dict = { + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + ], + } + model._apply_prompt_caching(payload) + assert payload["messages"][0]["content"][0].get("cache_control") == {"type": "ephemeral"} + + +def test_string_message_content_converted_and_cached(model): + payload: dict = { + "messages": [ + {"role": "user", "content": "simple string"}, + ], + } + model._apply_prompt_caching(payload) + assert isinstance(payload["messages"][0]["content"], list) + assert payload["messages"][0]["content"][0].get("cache_control") == {"type": "ephemeral"} + + +# --------------------------------------------------------------------------- +# Budget enforcement (the core regression test for issue #2448) +# --------------------------------------------------------------------------- + + +def test_never_exceeds_4_breakpoints_with_large_system(model): + """Many system text blocks must not produce more than 4 breakpoints total.""" + payload: dict = { + "system": [{"type": "text", "text": f"sys {i}"} for i in range(6)], + "tools": [{"name": "t1"}], + } + model._apply_prompt_caching(payload) + assert _count_cache_control(payload) <= 4 + + +def test_never_exceeds_4_breakpoints_multi_turn_with_multi_block_messages(model): + """Multi-turn conversation where each message has multiple content blocks.""" + # 1 system block + 3 messages × 2 blocks + 1 tool = 8 candidates → must cap at 4 + payload: dict = { + "system": [{"type": "text", "text": "system prompt"}], + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "user text"}, + {"type": "tool_result", "tool_use_id": "x", "content": "result"}, + ], + }, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "assistant text"}, + {"type": "tool_use", "id": "y", "name": "bash", "input": {}}, + ], + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "follow up"}, + {"type": "text", "text": "second block"}, + ], + }, + ], + "tools": [{"name": "bash"}], + } + model._apply_prompt_caching(payload) + total = _count_cache_control(payload) + assert total <= 4, f"Expected ≤ 4 breakpoints, got {total}" + + +def test_never_exceeds_4_breakpoints_many_messages(model): + """Large number of messages with multiple blocks per message.""" + messages = [] + for i in range(10): + messages.append( + { + "role": "user", + "content": [ + {"type": "text", "text": f"msg {i} block a"}, + {"type": "text", "text": f"msg {i} block b"}, + ], + } + ) + payload: dict = { + "system": [{"type": "text", "text": "sys 1"}, {"type": "text", "text": "sys 2"}], + "messages": messages, + "tools": [{"name": "tool_a"}, {"name": "tool_b"}], + } + model._apply_prompt_caching(payload) + total = _count_cache_control(payload) + assert total <= 4, f"Expected ≤ 4 breakpoints, got {total}" + + +def test_exactly_4_breakpoints_when_4_or_more_candidates(model): + """When there are at least 4 candidates, exactly 4 breakpoints are placed.""" + payload: dict = { + "system": [{"type": "text", "text": f"sys {i}"} for i in range(3)], + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "user"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "asst"}]}, + {"role": "user", "content": [{"type": "text", "text": "follow"}]}, + ], + "tools": [{"name": "bash"}], + } + model._apply_prompt_caching(payload) + total = _count_cache_control(payload) + assert total == 4 + + +def test_breakpoints_placed_on_last_candidates(model): + """Breakpoints should be on the *last* candidates, not the first.""" + # 5 system blocks but budget = 4 → first system block should NOT be cached, + # last 4 (indices 1-4) should be. + payload: dict = { + "system": [{"type": "text", "text": f"sys {i}"} for i in range(5)], + } + model._apply_prompt_caching(payload) + # First block is NOT in the last-4 window + assert "cache_control" not in payload["system"][0] + # Last 4 blocks ARE cached + for i in range(1, 5): + assert payload["system"][i].get("cache_control") == {"type": "ephemeral"}, f"block {i} should be cached" + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +def test_no_candidates_is_a_no_op(model): + payload: dict = {} + model._apply_prompt_caching(payload) + assert _count_cache_control(payload) == 0 + + +def test_non_text_system_blocks_not_added_as_candidates(model): + """Image blocks in system should not receive cache_control.""" + payload: dict = { + "system": [ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc"}}, + {"type": "text", "text": "text block"}, + ], + } + model._apply_prompt_caching(payload) + assert "cache_control" not in payload["system"][0] + assert payload["system"][1].get("cache_control") == {"type": "ephemeral"} + + +def test_old_messages_outside_cache_window_not_cached(model): + """Messages older than prompt_cache_size should not be cached.""" + m = _make_model(prompt_cache_size=1) + payload: dict = { + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "old message"}]}, + {"role": "user", "content": [{"type": "text", "text": "recent message"}]}, + ], + } + m._apply_prompt_caching(payload) + # Only the last message should be within the cache window + assert "cache_control" not in payload["messages"][0]["content"][0] + assert payload["messages"][1]["content"][0].get("cache_control") == {"type": "ephemeral"} From 410f0c48b54d5b38f8baf834baf3956eba1f8679 Mon Sep 17 00:00:00 2001 From: ming1523 Date: Sat, 25 Apr 2026 19:40:52 +0800 Subject: [PATCH 23/83] fix(channels): accept single slack allowed user (#2481) * fix(channels): accept single slack allowed user * docs: address Slack allowed_users review notes * ci: rerun backend unit tests * docs: clarify Slack allowed_users config --------- Co-authored-by: Willem Jiang --- backend/app/channels/slack.py | 22 +++++++++- backend/tests/test_channels.py | 79 +++++++++++++++++++++++++++++++--- config.example.yaml | 2 +- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/backend/app/channels/slack.py b/backend/app/channels/slack.py index c9ad6a6ec..65cb36cf5 100644 --- a/backend/app/channels/slack.py +++ b/backend/app/channels/slack.py @@ -16,13 +16,31 @@ logger = logging.getLogger(__name__) _slack_md_converter = SlackMarkdownConverter() +def _normalize_allowed_users(allowed_users: Any) -> set[str]: + if allowed_users is None: + return set() + if isinstance(allowed_users, str): + values = [allowed_users] + elif isinstance(allowed_users, list | tuple | set): + values = allowed_users + else: + logger.warning( + "Slack allowed_users should be a list of Slack user IDs or a single Slack user ID string; treating %s as one string value", + type(allowed_users).__name__, + ) + values = [allowed_users] + return {str(user_id) for user_id in values if str(user_id)} + + class SlackChannel(Channel): """Slack IM channel using Socket Mode (WebSocket, no public IP). Configuration keys (in ``config.yaml`` under ``channels.slack``): - ``bot_token``: Slack Bot User OAuth Token (xoxb-...). - ``app_token``: Slack App-Level Token (xapp-...) for Socket Mode. - - ``allowed_users``: (optional) List of allowed Slack user IDs. Empty = allow all. + - ``allowed_users``: (optional) List of allowed Slack user IDs, or a + single Slack user ID string as shorthand. Empty = allow all. Other + scalar values are treated as a single string with a warning. """ def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None: @@ -30,7 +48,7 @@ class SlackChannel(Channel): self._socket_client = None self._web_client = None self._loop: asyncio.AbstractEventLoop | None = None - self._allowed_users: set[str] = {str(user_id) for user_id in config.get("allowed_users", [])} + self._allowed_users = _normalize_allowed_users(config.get("allowed_users", [])) async def start(self) -> None: if self._running: diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index 7fc412653..e6fb0213f 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -2046,6 +2046,11 @@ class TestSlackSendRetry: class TestSlackAllowedUsers: + @staticmethod + def _submit_coro(coro, loop): + coro.close() + return MagicMock() + def test_numeric_allowed_users_match_string_event_user_id(self): from app.channels.slack import SlackChannel @@ -2067,13 +2072,9 @@ class TestSlackAllowedUsers: "ts": "1710000000.000100", } - def submit_coro(coro, loop): - coro.close() - return MagicMock() - with patch( "app.channels.slack.asyncio.run_coroutine_threadsafe", - side_effect=submit_coro, + side_effect=self._submit_coro, ) as submit: channel._handle_message_event(event) @@ -2085,6 +2086,74 @@ class TestSlackAllowedUsers: assert inbound.chat_id == "C123" assert inbound.text == "hello from slack" + def test_string_allowed_users_match_event_user_id(self): + from app.channels.slack import SlackChannel + + bus = MessageBus() + bus.publish_inbound = AsyncMock() + channel = SlackChannel( + bus=bus, + config={"allowed_users": "U123456"}, + ) + channel._loop = MagicMock() + channel._loop.is_running.return_value = True + channel._add_reaction = MagicMock() + channel._send_running_reply = MagicMock() + + event = { + "user": "U123456", + "text": "hello from slack", + "channel": "C123", + "ts": "1710000000.000100", + } + + with patch( + "app.channels.slack.asyncio.run_coroutine_threadsafe", + side_effect=self._submit_coro, + ) as submit: + channel._handle_message_event(event) + + channel._add_reaction.assert_called_once_with("C123", "1710000000.000100", "eyes") + channel._send_running_reply.assert_called_once_with("C123", "1710000000.000100") + submit.assert_called_once() + inbound = bus.publish_inbound.call_args.args[0] + assert inbound.user_id == "U123456" + assert inbound.chat_id == "C123" + assert inbound.text == "hello from slack" + + def test_scalar_allowed_users_warns_and_matches_stringified_event_user_id(self, caplog): + from app.channels.slack import SlackChannel + + bus = MessageBus() + bus.publish_inbound = AsyncMock() + with caplog.at_level("WARNING"): + channel = SlackChannel( + bus=bus, + config={"allowed_users": 123456}, + ) + channel._loop = MagicMock() + channel._loop.is_running.return_value = True + channel._add_reaction = MagicMock() + channel._send_running_reply = MagicMock() + + event = { + "user": "123456", + "text": "hello from slack", + "channel": "C123", + "ts": "1710000000.000100", + } + + with patch( + "app.channels.slack.asyncio.run_coroutine_threadsafe", + side_effect=self._submit_coro, + ) as submit: + channel._handle_message_event(event) + + assert "Slack allowed_users should be a list" in caplog.text + submit.assert_called_once() + inbound = bus.publish_inbound.call_args.args[0] + assert inbound.user_id == "123456" + def test_raises_after_all_retries_exhausted(self): from app.channels.slack import SlackChannel diff --git a/config.example.yaml b/config.example.yaml index 32a94105a..b9f7a9632 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -867,7 +867,7 @@ checkpointer: # enabled: false # bot_token: $SLACK_BOT_TOKEN # xoxb-... # app_token: $SLACK_APP_TOKEN # xapp-... (Socket Mode) -# allowed_users: [] # empty = allow all +# allowed_users: [] # empty = allow all; can also be a single Slack user ID string, e.g. U123456, but list form is recommended # # telegram: # enabled: false From 8a044142cbf86ffa6bd445db7d129f9ac051d608 Mon Sep 17 00:00:00 2001 From: Willem Jiang Date: Sun, 26 Apr 2026 09:40:17 +0800 Subject: [PATCH 24/83] feat(dev): add pre-commit hooks for ruff, eslint, and prettier (#2525) * feat(dev): add pre-commit hooks for ruff, eslint, and prettier * fix: use local uv-based ruff hooks and uv run for pre-commit install Agent-Logs-Url: https://github.com/bytedance/deer-flow/sessions/a1e34cc5-0d4b-4400-9e6a-e687d964ff1e Co-authored-by: WillemJiang <219644+WillemJiang@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .pre-commit-config.yaml | 33 +++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- Makefile | 4 +++- README.md | 2 +- 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..c79d53b51 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,33 @@ +repos: + # Backend: ruff lint + format via uv (uses the same ruff version as backend deps) + - repo: local + hooks: + - id: ruff + name: ruff lint + entry: bash -c 'cd backend && uv run ruff check --fix "${@/#backend\//}"' -- + language: system + types_or: [python] + files: ^backend/ + - id: ruff-format + name: ruff format + entry: bash -c 'cd backend && uv run ruff format "${@/#backend\//}"' -- + language: system + types_or: [python] + files: ^backend/ + + # Frontend: eslint + prettier (must run from frontend/ for node_modules resolution) + - repo: local + hooks: + - id: frontend-eslint + name: eslint (frontend) + entry: bash -c 'cd frontend && npx eslint --fix "${@/#frontend\//}"' -- + language: system + types_or: [javascript, tsx, ts] + files: ^frontend/ + + - id: frontend-prettier + name: prettier (frontend) + entry: bash -c 'cd frontend && npx prettier --write "${@/#frontend\//}"' -- + language: system + files: ^frontend/ + types_or: [javascript, tsx, ts, json, css] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 241ca71af..b7cb2840b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -166,7 +166,7 @@ Required tools: 1. **Configure the application** (same as Docker setup above) -2. **Install dependencies**: +2. **Install dependencies** (this also sets up pre-commit hooks): ```bash make install ``` diff --git a/Makefile b/Makefile index b21d860ae..0d31b7c9f 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ help: @echo " make config - Generate local config files (aborts if config already exists)" @echo " make config-upgrade - Merge new fields from config.example.yaml into config.yaml" @echo " make check - Check if all required tools are installed" - @echo " make install - Install all dependencies (frontend + backend)" + @echo " make install - Install all dependencies (frontend + backend + pre-commit hooks)" @echo " make setup-sandbox - Pre-pull sandbox container image (recommended)" @echo " make dev - Start all services in development mode (with hot-reloading)" @echo " make dev-pro - Start in dev + Gateway mode (experimental, no LangGraph server)" @@ -73,6 +73,8 @@ install: @cd backend && uv sync @echo "Installing frontend dependencies..." @cd frontend && pnpm install + @echo "Installing pre-commit hooks..." + @$(BACKEND_UV_RUN) --with pre-commit pre-commit install @echo "✓ All dependencies installed" @echo "" @echo "==========================================" diff --git a/README.md b/README.md index e9ca2c174..59461ee99 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ On Windows, run the local development flow from Git Bash. Native `cmd.exe` and P 2. **Install dependencies**: ```bash - make install # Install backend + frontend dependencies + make install # Install backend + frontend dependencies + pre-commit hooks ``` 3. **(Optional) Pre-pull sandbox image**: From 9dc25987e05e71ae87db0da22a63b4290c5e9747 Mon Sep 17 00:00:00 2001 From: Willem Jiang Date: Sun, 26 Apr 2026 10:09:55 +0800 Subject: [PATCH 25/83] fix(channles):update the logger for the channel config (#2524) * fix(channles):update the logger for the channel config * fix(channels): normalize credential values and add tests for disabled-but-configured warning Agent-Logs-Url: https://github.com/bytedance/deer-flow/sessions/dfc0a566-aa59-49f9-a74d-610292fb0a63 Co-authored-by: WillemJiang <219644+WillemJiang@users.noreply.github.com> * fix the backend lint error --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- backend/app/channels/service.py | 21 +++++++++++- backend/tests/test_channels.py | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/backend/app/channels/service.py b/backend/app/channels/service.py index 8d17f7481..8042733c2 100644 --- a/backend/app/channels/service.py +++ b/backend/app/channels/service.py @@ -23,6 +23,16 @@ _CHANNEL_REGISTRY: dict[str, str] = { "wecom": "app.channels.wecom:WeComChannel", } +# Keys that indicate a user has configured credentials for a channel. +_CHANNEL_CREDENTIAL_KEYS: dict[str, list[str]] = { + "discord": ["bot_token"], + "feishu": ["app_id", "app_secret"], + "slack": ["bot_token", "app_token"], + "telegram": ["bot_token"], + "wecom": ["bot_id", "bot_secret"], + "wechat": ["bot_token"], +} + _CHANNELS_LANGGRAPH_URL_ENV = "DEER_FLOW_CHANNELS_LANGGRAPH_URL" _CHANNELS_GATEWAY_URL_ENV = "DEER_FLOW_CHANNELS_GATEWAY_URL" @@ -88,7 +98,16 @@ class ChannelService: if not isinstance(channel_config, dict): continue if not channel_config.get("enabled", False): - logger.info("Channel %s is disabled, skipping", name) + cred_keys = _CHANNEL_CREDENTIAL_KEYS.get(name, []) + has_creds = any(not isinstance(channel_config.get(k), bool) and channel_config.get(k) is not None and str(channel_config[k]).strip() for k in cred_keys) + if has_creds: + logger.warning( + "Channel '%s' has credentials configured but is disabled. Set enabled: true under channels.%s in config.yaml to activate it.", + name, + name, + ) + else: + logger.info("Channel %s is disabled, skipping", name) continue await self._start_channel(name, channel_config) diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index e6fb0213f..bdb4584e5 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -2011,6 +2011,65 @@ class TestChannelService: assert service.manager._langgraph_url == "http://custom-langgraph:2024" assert service.manager._gateway_url == "http://custom-gateway:8001" + def test_disabled_channel_with_string_creds_emits_warning(self, caplog): + """Warning is emitted when a channel has string credentials but enabled=false.""" + import logging + + from app.channels.service import ChannelService + + async def go(): + service = ChannelService( + channels_config={ + "wecom": {"enabled": False, "bot_id": "corp123", "bot_secret": "secret"}, + } + ) + with caplog.at_level(logging.WARNING, logger="app.channels.service"): + await service.start() + await service.stop() + + _run(go()) + assert any("wecom" in r.message and r.levelno == logging.WARNING for r in caplog.records) + + def test_disabled_channel_with_int_creds_emits_warning(self, caplog): + """Warning is emitted even when YAML-parsed integer credentials are present.""" + import logging + + from app.channels.service import ChannelService + + async def go(): + # Simulate YAML parsing a numeric token/ID as an int + service = ChannelService( + channels_config={ + "telegram": {"enabled": False, "bot_token": 123456789}, + } + ) + with caplog.at_level(logging.WARNING, logger="app.channels.service"): + await service.start() + await service.stop() + + _run(go()) + assert any("telegram" in r.message and r.levelno == logging.WARNING for r in caplog.records) + + def test_disabled_channel_without_creds_emits_info(self, caplog): + """Only an info log (no warning) is emitted when a channel is disabled with no credentials.""" + import logging + + from app.channels.service import ChannelService + + async def go(): + service = ChannelService( + channels_config={ + "telegram": {"enabled": False}, + } + ) + with caplog.at_level(logging.DEBUG, logger="app.channels.service"): + await service.start() + await service.stop() + + _run(go()) + warning_records = [r for r in caplog.records if "telegram" in r.message and r.levelno == logging.WARNING] + assert not warning_records + # --------------------------------------------------------------------------- # Slack send retry tests From d8ecaf46c977513c1b6f51954ffffea631966df8 Mon Sep 17 00:00:00 2001 From: rayhpeng Date: Tue, 7 Apr 2026 11:53:52 +0800 Subject: [PATCH 26/83] feat(persistence): add unified persistence layer with event store, token tracking, and feedback (#1930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(persistence): add SQLAlchemy 2.0 async ORM scaffold Introduce a unified database configuration (DatabaseConfig) that controls both the LangGraph checkpointer and the DeerFlow application persistence layer from a single `database:` config section. New modules: - deerflow.config.database_config — Pydantic config with memory/sqlite/postgres backends - deerflow.persistence — async engine lifecycle, DeclarativeBase with to_dict mixin, Alembic skeleton - deerflow.runtime.runs.store — RunStore ABC + MemoryRunStore implementation Gateway integration initializes/tears down the persistence engine in the existing langgraph_runtime() context manager. Legacy checkpointer config is preserved for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(persistence): add RunEventStore ABC + MemoryRunEventStore Phase 2-A prerequisite for event storage: adds the unified run event stream interface (RunEventStore) with an in-memory implementation, RunEventsConfig, gateway integration, and comprehensive tests (27 cases). Co-Authored-By: Claude Opus 4.6 (1M context) * feat(persistence): add ORM models, repositories, DB/JSONL event stores, RunJournal, and API endpoints Phase 2-B: run persistence + event storage + token tracking. - ORM models: RunRow (with token fields), ThreadMetaRow, RunEventRow - RunRepository implements RunStore ABC via SQLAlchemy ORM - ThreadMetaRepository with owner access control - DbRunEventStore with trace content truncation and cursor pagination - JsonlRunEventStore with per-run files and seq recovery from disk - RunJournal (BaseCallbackHandler) captures LLM/tool/lifecycle events, accumulates token usage by caller type, buffers and flushes to store - RunManager now accepts optional RunStore for persistent backing - Worker creates RunJournal, writes human_message, injects callbacks - Gateway deps use factory functions (RunRepository when DB available) - New endpoints: messages, run messages, run events, token-usage - ThreadCreateRequest gains assistant_id field - 92 tests pass (33 new), zero regressions Co-Authored-By: Claude Opus 4.6 (1M context) * feat(persistence): add user feedback + follow-up run association Phase 2-C: feedback and follow-up tracking. - FeedbackRow ORM model (rating +1/-1, optional message_id, comment) - FeedbackRepository with CRUD, list_by_run/thread, aggregate stats - Feedback API endpoints: create, list, stats, delete - follow_up_to_run_id in RunCreateRequest (explicit or auto-detected from latest successful run on the thread) - Worker writes follow_up_to_run_id into human_message event metadata - Gateway deps: feedback_repo factory + getter - 17 new tests (14 FeedbackRepository + 3 follow-up association) - 109 total tests pass, zero regressions Co-Authored-By: Claude Opus 4.6 (1M context) * test+config: comprehensive Phase 2 test coverage + deprecate checkpointer config - config.example.yaml: deprecate standalone checkpointer section, activate unified database:sqlite as default (drives both checkpointer + app data) - New: test_thread_meta_repo.py (14 tests) — full ThreadMetaRepository coverage including check_access owner logic, list_by_owner pagination - Extended test_run_repository.py (+4 tests) — completion preserves fields, list ordering desc, limit, owner_none returns all - Extended test_run_journal.py (+8 tests) — on_chain_error, track_tokens=false, middleware no ai_message, unknown caller tokens, convenience fields, tool_error, non-summarization custom event - Extended test_run_event_store.py (+7 tests) — DB batch seq continuity, make_run_event_store factory (memory/db/jsonl/fallback/unknown) - Extended test_phase2b_integration.py (+4 tests) — create_or_reject persists, follow-up metadata, summarization in history, full DB-backed lifecycle - Fixed DB integration test to use proper fake objects (not MagicMock) for JSON-serializable metadata - 157 total Phase 2 tests pass, zero regressions Co-Authored-By: Claude Opus 4.6 (1M context) * config: move default sqlite_dir to .deer-flow/data Keep SQLite databases alongside other DeerFlow-managed data (threads, memory) under the .deer-flow/ directory instead of a top-level ./data folder. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(persistence): remove UTFJSON, use engine-level json_serializer + datetime.now() - Replace custom UTFJSON type with standard sqlalchemy.JSON in all ORM models. Add json_serializer=json.dumps(ensure_ascii=False) to all create_async_engine calls so non-ASCII text (Chinese etc.) is stored as-is in both SQLite and Postgres. - Change ORM datetime defaults from datetime.now(UTC) to datetime.now(), remove UTC imports. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(gateway): simplify deps.py with getter factory + inline repos - Replace 6 identical getter functions with _require() factory. - Inline 3 _make_*_repo() factories into langgraph_runtime(), call get_session_factory() once instead of 3 times. - Add thread_meta upsert in start_run (services.py). Co-Authored-By: Claude Opus 4.6 (1M context) * feat(docker): add UV_EXTRAS build arg for optional dependencies Support installing optional dependency groups (e.g. postgres) at Docker build time via UV_EXTRAS build arg: UV_EXTRAS=postgres docker compose build Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(journal): fix flush, token tracking, and consolidate tests RunJournal fixes: - _flush_sync: retain events in buffer when no event loop instead of dropping them; worker's finally block flushes via async flush(). - on_llm_end: add tool_calls filter and caller=="lead_agent" guard for ai_message events; mark message IDs for dedup with record_llm_usage. - worker.py: persist completion data (tokens, message count) to RunStore in finally block. Model factory: - Auto-inject stream_usage=True for BaseChatOpenAI subclasses with custom api_base, so usage_metadata is populated in streaming responses. Test consolidation: - Delete test_phase2b_integration.py (redundant with existing tests). - Move DB-backed lifecycle test into test_run_journal.py. - Add tests for stream_usage injection in test_model_factory.py. - Clean up executor/task_tool dead journal references. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(events): widen content type to str|dict in all store backends Allow event content to be a dict (for structured OpenAI-format messages) in addition to plain strings. Dict values are JSON-serialized for the DB backend and deserialized on read; memory and JSONL backends handle dicts natively. Trace truncation now serializes dicts to JSON before measuring. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(events): use metadata flag instead of heuristic for dict content detection Co-Authored-By: Claude Opus 4.6 (1M context) * feat(converters): add LangChain-to-OpenAI message format converters Pure functions langchain_to_openai_message, langchain_to_openai_completion, langchain_messages_to_openai, and _infer_finish_reason for converting LangChain BaseMessage objects to OpenAI Chat Completions format, used by RunJournal for event storage. 15 unit tests added. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(converters): handle empty list content as null, clean up test Co-Authored-By: Claude Opus 4.6 (1M context) * feat(events): human_message content uses OpenAI user message format Co-Authored-By: Claude Sonnet 4.6 * feat(events): ai_message uses OpenAI format, add ai_tool_call message event - ai_message content now uses {"role": "assistant", "content": "..."} format - New ai_tool_call message event emitted when lead_agent LLM responds with tool_calls - ai_tool_call uses langchain_to_openai_message converter for consistent format - Both events include finish_reason in metadata ("stop" or "tool_calls") Co-Authored-By: Claude Opus 4.6 (1M context) * feat(events): add tool_result message event with OpenAI tool message format Cache tool_call_id from on_tool_start keyed by run_id as fallback for on_tool_end, then emit a tool_result message event (role=tool, tool_call_id, content) after each successful tool completion. Co-Authored-By: Claude Sonnet 4.6 * feat(events): summary content uses OpenAI system message format Co-Authored-By: Claude Opus 4.6 (1M context) * feat(events): replace llm_start/llm_end with llm_request/llm_response in OpenAI format Add on_chat_model_start to capture structured prompt messages as llm_request events. Replace llm_end trace events with llm_response using OpenAI Chat Completions format. Track llm_call_index to pair request/response events. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(events): add record_middleware method for middleware trace events Co-Authored-By: Claude Opus 4.6 (1M context) * test(events): add full run sequence integration test for OpenAI content format Co-Authored-By: Claude Sonnet 4.6 * feat(events): align message events with checkpoint format and add middleware tag injection - Message events (ai_message, ai_tool_call, tool_result, human_message) now use BaseMessage.model_dump() format, matching LangGraph checkpoint values.messages - on_tool_end extracts tool_call_id/name/status from ToolMessage objects - on_tool_error now emits tool_result message events with error status - record_middleware uses middleware:{tag} event_type and middleware category - Summarization custom events use middleware:summarize category - TitleMiddleware injects middleware:title tag via get_config() inheritance - SummarizationMiddleware model bound with middleware:summarize tag - Worker writes human_message using HumanMessage.model_dump() Co-Authored-By: Claude Opus 4.6 (1M context) * feat(threads): switch search endpoint to threads_meta table and sync title - POST /api/threads/search now queries threads_meta table directly, removing the two-phase Store + Checkpointer scan approach - Add ThreadMetaRepository.search() with metadata/status filters - Add ThreadMetaRepository.update_display_name() for title sync - Worker syncs checkpoint title to threads_meta.display_name on run completion - Map display_name to values.title in search response for API compatibility Co-Authored-By: Claude Opus 4.6 (1M context) * feat(threads): history endpoint reads messages from event store - POST /api/threads/{thread_id}/history now combines two data sources: checkpointer for checkpoint_id, metadata, title, thread_data; event store for messages (complete history, not truncated by summarization) - Strip internal LangGraph metadata keys from response - Remove full channel_values serialization in favor of selective fields Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove duplicate optional-dependencies header in pyproject.toml Co-Authored-By: Claude Opus 4.6 (1M context) * fix(middleware): pass tagged config to TitleMiddleware ainvoke call Without the config, the middleware:title tag was not injected, causing the LLM response to be recorded as a lead_agent ai_message in run_events. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve merge conflict in .env.example Keep both DATABASE_URL (from persistence-scaffold) and WECOM credentials (from main) after the merge. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(persistence): address review feedback on PR #1851 - Fix naive datetime.now() → datetime.now(UTC) in all ORM models - Fix seq race condition in DbRunEventStore.put() with FOR UPDATE and UNIQUE(thread_id, seq) constraint - Encapsulate _store access in RunManager.update_run_completion() - Deduplicate _store.put() logic in RunManager via _persist_to_store() - Add update_run_completion to RunStore ABC + MemoryRunStore - Wire follow_up_to_run_id through the full create path - Add error recovery to RunJournal._flush_sync() lost-event scenario - Add migration note for search_threads breaking change - Fix test_checkpointer_none_fix mock to set database=None Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update uv.lock Co-Authored-By: Claude Opus 4.6 (1M context) * fix(persistence): address 22 review comments from CodeQL, Copilot, and Code Quality Bug fixes: - Sanitize log params to prevent log injection (CodeQL) - Reset threads_meta.status to idle/error when run completes - Attach messages only to latest checkpoint in /history response - Write threads_meta on POST /threads so new threads appear in search Lint fixes: - Remove unused imports (journal.py, migrations/env.py, test_converters.py) - Convert lambda to named function (engine.py, Ruff E731) - Remove unused logger definitions in repos (Ruff F841) - Add logging to JSONL decode errors and empty except blocks - Separate assert side-effects in tests (CodeQL) - Remove unused local variables in tests (Ruff F841) - Fix max_trace_content truncation to use byte length, not char length Co-Authored-By: Claude Opus 4.6 (1M context) * style: apply ruff format to persistence and runtime files Co-Authored-By: Claude Opus 4.6 (1M context) * Potential fix for pull request finding 'Statement has no effect' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * refactor(runtime): introduce RunContext to reduce run_agent parameter bloat Extract checkpointer, store, event_store, run_events_config, thread_meta_repo, and follow_up_to_run_id into a frozen RunContext dataclass. Add get_run_context() in deps.py to build the base context from app.state singletons. start_run() uses dataclasses.replace() to enrich per-run fields before passing ctx to run_agent. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(gateway): move sanitize_log_param to app/gateway/utils.py Extract the log-injection sanitizer from routers/threads.py into a shared utils module and rename to sanitize_log_param (public API). Eliminates the reverse service → router import in services.py. Co-Authored-By: Claude Opus 4.6 (1M context) * perf: use SQL aggregation for feedback stats and thread token usage Replace Python-side counting in FeedbackRepository.aggregate_by_run with a single SELECT COUNT/SUM query. Add RunStore.aggregate_tokens_by_thread abstract method with SQL GROUP BY implementation in RunRepository and Python fallback in MemoryRunStore. Simplify the thread_token_usage endpoint to delegate to the new method, eliminating the limit=10000 truncation risk. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: annotate DbRunEventStore.put() as low-frequency path Add docstring clarifying that put() opens a per-call transaction with FOR UPDATE and should only be used for infrequent writes (currently just the initial human_message event). High-throughput callers should use put_batch() instead. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(threads): fall back to Store search when ThreadMetaRepository is unavailable When database.backend=memory (default) or no SQL session factory is configured, search_threads now queries the LangGraph Store instead of returning 503. Returns empty list if neither Store nor repo is available. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(persistence): introduce ThreadMetaStore ABC for backend-agnostic thread metadata Add ThreadMetaStore abstract base class with create/get/search/update/delete interface. ThreadMetaRepository (SQL) now inherits from it. New MemoryThreadMetaStore wraps LangGraph BaseStore for memory-mode deployments. deps.py now always provides a non-None thread_meta_repo, eliminating all `if thread_meta_repo is not None` guards in services.py, worker.py, and routers/threads.py. search_threads no longer needs a Store fallback branch. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(history): read messages from checkpointer instead of RunEventStore The /history endpoint now reads messages directly from the checkpointer's channel_values (the authoritative source) instead of querying RunEventStore.list_messages(). The RunEventStore API is preserved for other consumers. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(persistence): address new Copilot review comments - feedback.py: validate thread_id/run_id before deleting feedback - jsonl.py: add path traversal protection with ID validation - run_repo.py: parse `before` to datetime for PostgreSQL compat - thread_meta_repo.py: fix pagination when metadata filter is active - database_config.py: use resolve_path for sqlite_dir consistency Co-Authored-By: Claude Opus 4.6 (1M context) * Implement skill self-evolution and skill_manage flow (#1874) * chore: ignore .worktrees directory * Add skill_manage self-evolution flow * Fix CI regressions for skill_manage * Address PR review feedback for skill evolution * fix(skill-evolution): preserve history on delete * fix(skill-evolution): tighten scanner fallbacks * docs: add skill_manage e2e evidence screenshot * fix(skill-manage): avoid blocking fs ops in session runtime --------- Co-authored-by: Willem Jiang * fix(config): resolve sqlite_dir relative to CWD, not Paths.base_dir resolve_path() resolves relative to Paths.base_dir (.deer-flow), which double-nested the path to .deer-flow/.deer-flow/data/app.db. Use Path.resolve() (CWD-relative) instead. Co-Authored-By: Claude Opus 4.6 (1M context) * Feature/feishu receive file (#1608) * feat(feishu): add channel file materialization hook for inbound messages - Introduce Channel.receive_file(msg, thread_id) as a base method for file materialization; default is no-op. - Implement FeishuChannel.receive_file to download files/images from Feishu messages, save to sandbox, and inject virtual paths into msg.text. - Update ChannelManager to call receive_file for any channel if msg.files is present, enabling downstream model access to user-uploaded files. - No impact on Slack/Telegram or other channels (they inherit the default no-op). * style(backend): format code with ruff for lint compliance - Auto-formatted packages/harness/deerflow/agents/factory.py and tests/test_create_deerflow_agent.py using `ruff format` - Ensured both files conform to project linting standards - Fixes CI lint check failures caused by code style issues * fix(feishu): handle file write operation asynchronously to prevent blocking * fix(feishu): rename GetMessageResourceRequest to _GetMessageResourceRequest and remove redundant code * test(feishu): add tests for receive_file method and placeholder replacement * fix(manager): remove unnecessary type casting for channel retrieval * fix(feishu): update logging messages to reflect resource handling instead of image * fix(feishu): sanitize filename by replacing invalid characters in file uploads * fix(feishu): improve filename sanitization and reorder image key handling in message processing * fix(feishu): add thread lock to prevent filename conflicts during file downloads * fix(test): correct bad merge in test_feishu_parser.py * chore: run ruff and apply formatting cleanup fix(feishu): preserve rich-text attachment order and improve fallback filename handling * fix(docker): restore gateway env vars and fix langgraph empty arg issue (#1915) Two production docker-compose.yaml bugs prevent `make up` from working: 1. Gateway missing DEER_FLOW_CONFIG_PATH and DEER_FLOW_EXTENSIONS_CONFIG_PATH environment overrides. Added in fb2d99f (#1836) but accidentally reverted by ca2fb95 (#1847). Without them, gateway reads host paths from .env via env_file, causing FileNotFoundError inside the container. 2. Langgraph command fails when LANGGRAPH_ALLOW_BLOCKING is unset (default). Empty $${allow_blocking} inserts a bare space between flags, causing ' --no-reload' to be parsed as unexpected extra argument. Fix by building args string first and conditionally appending --allow-blocking. Co-authored-by: cooper * fix(frontend): resolve invalid HTML nesting and tabnabbing vulnerabilities (#1904) * fix(frontend): resolve invalid HTML nesting and tabnabbing vulnerabilities Fix ` + + +
+ +
+ +
+ + ← Back to home + +
+
+
+ ); +} diff --git a/frontend/src/app/(auth)/setup/page.tsx b/frontend/src/app/(auth)/setup/page.tsx new file mode 100644 index 000000000..e70d1efc6 --- /dev/null +++ b/frontend/src/app/(auth)/setup/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { getCsrfHeaders } from "@/core/api/fetcher"; +import { parseAuthError } from "@/core/auth/types"; + +export default function SetupPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSetup = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + if (newPassword.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + try { + const res = await fetch("/api/v1/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getCsrfHeaders(), + }, + credentials: "include", + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + new_email: email || undefined, + }), + }); + + if (!res.ok) { + const data = await res.json(); + const authError = parseAuthError(data); + setError(authError.message); + return; + } + + router.push("/workspace"); + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

DeerFlow

+

+ Complete admin account setup +

+

+ Set your real email and a new password. +

+
+
+ setEmail(e.target.value)} + required + /> + setCurrentPassword(e.target.value)} + required + /> + setNewPassword(e.target.value)} + required + minLength={8} + /> + setConfirmPassword(e.target.value)} + required + minLength={8} + /> + {error &&

{error}

} + +
+
+
+ ); +} diff --git a/frontend/src/app/api/auth/[...all]/route.ts b/frontend/src/app/api/auth/[...all]/route.ts deleted file mode 100644 index cde6018a8..000000000 --- a/frontend/src/app/api/auth/[...all]/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { toNextJsHandler } from "better-auth/next-js"; - -import { auth } from "@/server/better-auth"; - -export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index 4c1dd2036..fa19025a0 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -1,35 +1,58 @@ -import { cookies } from "next/headers"; -import { Toaster } from "sonner"; +import Link from "next/link"; +import { redirect } from "next/navigation"; -import { QueryClientProvider } from "@/components/query-client-provider"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; -import { CommandPalette } from "@/components/workspace/command-palette"; -import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; +import { AuthProvider } from "@/core/auth/AuthProvider"; +import { getServerSideUser } from "@/core/auth/server"; +import { assertNever } from "@/core/auth/types"; -function parseSidebarOpenCookie( - value: string | undefined, -): boolean | undefined { - if (value === "true") return true; - if (value === "false") return false; - return undefined; -} +import { WorkspaceContent } from "./workspace-content"; + +export const dynamic = "force-dynamic"; export default async function WorkspaceLayout({ children, }: Readonly<{ children: React.ReactNode }>) { - const cookieStore = await cookies(); - const initialSidebarOpen = parseSidebarOpenCookie( - cookieStore.get("sidebar_state")?.value, - ); + const result = await getServerSideUser(); - return ( - - - - {children} - - - - - ); + switch (result.tag) { + case "authenticated": + return ( + + {children} + + ); + case "needs_setup": + redirect("/setup"); + case "unauthenticated": + redirect("/login"); + case "gateway_unavailable": + return ( +
+

+ Service temporarily unavailable. +

+

+ The backend may be restarting. Please wait a moment and try again. +

+
+ + Retry + + + Logout & Reset + +
+
+ ); + case "config_error": + throw new Error(result.message); + default: + assertNever(result); + } } diff --git a/frontend/src/app/workspace/workspace-content.tsx b/frontend/src/app/workspace/workspace-content.tsx new file mode 100644 index 000000000..85c20b2ca --- /dev/null +++ b/frontend/src/app/workspace/workspace-content.tsx @@ -0,0 +1,35 @@ +import { cookies } from "next/headers"; +import { Toaster } from "sonner"; + +import { QueryClientProvider } from "@/components/query-client-provider"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { CommandPalette } from "@/components/workspace/command-palette"; +import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; + +function parseSidebarOpenCookie( + value: string | undefined, +): boolean | undefined { + if (value === "true") return true; + if (value === "false") return false; + return undefined; +} + +export async function WorkspaceContent({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const cookieStore = await cookies(); + const initialSidebarOpen = parseSidebarOpenCookie( + cookieStore.get("sidebar_state")?.value, + ); + + return ( + + + + {children} + + + + + ); +} diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index ddc682744..83bd75952 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -55,6 +55,7 @@ import { DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; @@ -395,16 +396,19 @@ export function InputBox({ setFollowupsLoading(true); setFollowups([]); - fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - messages: recent, - n: 3, - model_name: context.model_name ?? undefined, - }), - signal: controller.signal, - }) + fetchWithAuth( + `${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: recent, + n: 3, + model_name: context.model_name ?? undefined, + }), + signal: controller.signal, + }, + ) .then(async (res) => { if (!res.ok) { return { suggestions: [] as string[] }; diff --git a/frontend/src/components/workspace/settings/account-settings-page.tsx b/frontend/src/components/workspace/settings/account-settings-page.tsx new file mode 100644 index 000000000..6382b8859 --- /dev/null +++ b/frontend/src/components/workspace/settings/account-settings-page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { LogOutIcon } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { fetchWithAuth, getCsrfHeaders } from "@/core/api/fetcher"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { parseAuthError } from "@/core/auth/types"; + +import { SettingsSection } from "./settings-section"; + +export function AccountSettingsPage() { + const { user, logout } = useAuth(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setMessage(""); + + if (newPassword !== confirmPassword) { + setError("New passwords do not match"); + return; + } + if (newPassword.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + try { + const res = await fetchWithAuth("/api/v1/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getCsrfHeaders(), + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }); + + if (!res.ok) { + const data = await res.json(); + const authError = parseAuthError(data); + setError(authError.message); + return; + } + + setMessage("Password changed successfully"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+ Email + {user?.email ?? "—"} +
+
+ Role + + {user?.system_role ?? "—"} + +
+
+
+ + +
+ setCurrentPassword(e.target.value)} + required + /> + setNewPassword(e.target.value)} + required + minLength={8} + /> + setConfirmPassword(e.target.value)} + required + minLength={8} + /> + {error &&

{error}

} + {message &&

{message}

} + +
+
+ + + + +
+ ); +} diff --git a/frontend/src/components/workspace/settings/settings-dialog.tsx b/frontend/src/components/workspace/settings/settings-dialog.tsx index fadc25fa6..6e9fa5ddf 100644 --- a/frontend/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend/src/components/workspace/settings/settings-dialog.tsx @@ -6,6 +6,7 @@ import { BrainIcon, PaletteIcon, SparklesIcon, + UserIcon, WrenchIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -18,6 +19,7 @@ import { } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page"; +import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page"; import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page"; @@ -27,6 +29,7 @@ import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; type SettingsSection = + | "account" | "appearance" | "memory" | "tools" @@ -54,6 +57,11 @@ export function SettingsDialog(props: SettingsDialogProps) { const sections = useMemo( () => [ + { + id: "account", + label: t.settings.sections.account, + icon: UserIcon, + }, { id: "appearance", label: t.settings.sections.appearance, @@ -74,6 +82,7 @@ export function SettingsDialog(props: SettingsDialogProps) { { id: "about", label: t.settings.sections.about, icon: InfoIcon }, ], [ + t.settings.sections.account, t.settings.sections.appearance, t.settings.sections.memory, t.settings.sections.tools, @@ -122,8 +131,9 @@ export function SettingsDialog(props: SettingsDialogProps) { })} - -
+ +
+ {activeSection === "account" && } {activeSection === "appearance" && } {activeSection === "memory" && } {activeSection === "tools" && } diff --git a/frontend/src/core/agents/api.ts b/frontend/src/core/agents/api.ts index 927b5f20b..984ee8832 100644 --- a/frontend/src/core/agents/api.ts +++ b/frontend/src/core/agents/api.ts @@ -1,3 +1,4 @@ +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types"; @@ -28,7 +29,7 @@ export async function getAgent(name: string): Promise { } export async function createAgent(request: CreateAgentRequest): Promise { - const res = await fetch(`${getBackendBaseURL()}/api/agents`, { + const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), @@ -44,7 +45,7 @@ export async function updateAgent( name: string, request: UpdateAgentRequest, ): Promise { - const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, { + const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents/${name}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), @@ -57,7 +58,7 @@ export async function updateAgent( } export async function deleteAgent(name: string): Promise { - const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, { + const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents/${name}`, { method: "DELETE", }); if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`); diff --git a/frontend/src/core/api/api-client.ts b/frontend/src/core/api/api-client.ts index b86251513..5e71730e7 100644 --- a/frontend/src/core/api/api-client.ts +++ b/frontend/src/core/api/api-client.ts @@ -4,11 +4,37 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client"; import { getLangGraphBaseURL } from "../config"; +import { isStateChangingMethod, readCsrfCookie } from "./fetcher"; import { sanitizeRunStreamOptions } from "./stream-mode"; +/** + * SDK ``onRequest`` hook that mints the ``X-CSRF-Token`` header from the + * live ``csrf_token`` cookie just before each outbound fetch. + * + * Reading the cookie per-request (rather than baking it into the SDK's + * ``defaultHeaders`` at construction) handles login / logout / password + * change cookie rotation transparently. Both the ``/langgraph-compat/*`` + * SDK path and the direct REST endpoints in ``fetcher.ts:fetchWithAuth`` + * share :func:`readCsrfCookie` and :const:`STATE_CHANGING_METHODS` so + * the contract stays in lockstep. + */ +function injectCsrfHeader(_url: URL, init: RequestInit): RequestInit { + if (!isStateChangingMethod(init.method ?? "GET")) { + return init; + } + const token = readCsrfCookie(); + if (!token) return init; + const headers = new Headers(init.headers); + if (!headers.has("X-CSRF-Token")) { + headers.set("X-CSRF-Token", token); + } + return { ...init, headers }; +} + function createCompatibleClient(isMock?: boolean): LangGraphClient { const client = new LangGraphClient({ apiUrl: getLangGraphBaseURL(isMock), + onRequest: injectCsrfHeader, }); const originalRunStream = client.runs.stream.bind(client.runs); diff --git a/frontend/src/core/api/fetcher.ts b/frontend/src/core/api/fetcher.ts new file mode 100644 index 000000000..d5f30404c --- /dev/null +++ b/frontend/src/core/api/fetcher.ts @@ -0,0 +1,104 @@ +import { buildLoginUrl } from "@/core/auth/types"; + +/** HTTP methods that the gateway's CSRFMiddleware checks. */ +export type StateChangingMethod = "POST" | "PUT" | "DELETE" | "PATCH"; + +export const STATE_CHANGING_METHODS: ReadonlySet = new Set( + ["POST", "PUT", "DELETE", "PATCH"], +); + +/** Mirror of the gateway's ``should_check_csrf`` decision. */ +export function isStateChangingMethod(method: string): boolean { + return (STATE_CHANGING_METHODS as ReadonlySet).has( + method.toUpperCase(), + ); +} + +const CSRF_COOKIE_PREFIX = "csrf_token="; + +/** + * Read the ``csrf_token`` cookie set by the gateway at login. + * + * SSR-safe: returns ``null`` when ``document`` is undefined so the same + * helper can be imported from server components without a guard. + * + * Uses `String.split` instead of a regex to side-step ESLint's + * `prefer-regexp-exec` rule and the cookie value's reliable `; ` + * separator (set by the gateway, not the browser, so format is stable). + */ +export function readCsrfCookie(): string | null { + if (typeof document === "undefined") return null; + for (const pair of document.cookie.split("; ")) { + if (pair.startsWith(CSRF_COOKIE_PREFIX)) { + return decodeURIComponent(pair.slice(CSRF_COOKIE_PREFIX.length)); + } + } + return null; +} + +/** + * Fetch with credentials and automatic CSRF protection. + * + * Two centralized contracts every API call needs: + * + * 1. ``credentials: "include"`` so the HttpOnly access_token cookie + * accompanies cross-origin SSR-routed requests. + * 2. ``X-CSRF-Token`` header on state-changing methods (POST/PUT/ + * DELETE/PATCH), echoed from the ``csrf_token`` cookie. The gateway's + * CSRFMiddleware enforces Double Submit Cookie comparison and returns + * 403 if the header is missing — silently breaking every call site + * that uses raw ``fetch()`` instead of this wrapper. + * + * Auto-redirects to ``/login`` on 401. Caller-supplied headers are + * preserved; the helper only ADDS the CSRF header when it isn't already + * present, so explicit overrides win. + */ +export async function fetchWithAuth( + input: RequestInfo | string, + init?: RequestInit, +): Promise { + const url = typeof input === "string" ? input : input.url; + + // Inject CSRF for state-changing methods. GET/HEAD/OPTIONS/TRACE skip + // it to mirror the gateway's ``should_check_csrf`` logic exactly. + let headers = init?.headers; + if (isStateChangingMethod(init?.method ?? "GET")) { + const token = readCsrfCookie(); + if (token) { + // Fresh Headers instance so we don't mutate caller-supplied objects. + const merged = new Headers(headers); + if (!merged.has("X-CSRF-Token")) { + merged.set("X-CSRF-Token", token); + } + headers = merged; + } + } + + const res = await fetch(url, { + ...init, + headers, + credentials: "include", + }); + + if (res.status === 401) { + window.location.href = buildLoginUrl(window.location.pathname); + throw new Error("Unauthorized"); + } + + return res; +} + +/** + * Build headers for CSRF-protected requests. + * + * **Prefer :func:`fetchWithAuth`** for new code — it injects the header + * automatically on state-changing methods. This helper exists for legacy + * call sites that need to compose headers manually (e.g. inside + * `next/server` route handlers that build their own ``Headers`` object). + * + * Per RFC-001: Double Submit Cookie pattern. + */ +export function getCsrfHeaders(): HeadersInit { + const token = readCsrfCookie(); + return token ? { "X-CSRF-Token": token } : {}; +} diff --git a/frontend/src/core/auth/AuthProvider.tsx b/frontend/src/core/auth/AuthProvider.tsx new file mode 100644 index 000000000..652cc49b8 --- /dev/null +++ b/frontend/src/core/auth/AuthProvider.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useRouter, usePathname } from "next/navigation"; +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + type ReactNode, +} from "react"; + +import { type User, buildLoginUrl } from "./types"; + +// Re-export for consumers +export type { User }; + +/** + * Authentication context provided to consuming components + */ +interface AuthContextType { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + logout: () => Promise; + refreshUser: () => Promise; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; + initialUser: User | null; +} + +/** + * AuthProvider - Unified authentication context for the application + * + * Per RFC-001: + * - Only holds display information (user), never JWT or tokens + * - initialUser comes from server-side guard, avoiding client flicker + * - Provides logout and refresh capabilities + */ +export function AuthProvider({ children, initialUser }: AuthProviderProps) { + const [user, setUser] = useState(initialUser); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + + const isAuthenticated = user !== null; + + /** + * Fetch current user from FastAPI + * Used when initialUser might be stale (e.g., after tab was inactive) + */ + const refreshUser = useCallback(async () => { + try { + setIsLoading(true); + const res = await fetch("/api/v1/auth/me", { + credentials: "include", + }); + + if (res.ok) { + const data = await res.json(); + setUser(data); + } else if (res.status === 401) { + // Session expired or invalid + setUser(null); + // Redirect to login if on a protected route + if (pathname?.startsWith("/workspace")) { + router.push(buildLoginUrl(pathname)); + } + } + } catch (err) { + console.error("Failed to refresh user:", err); + setUser(null); + } finally { + setIsLoading(false); + } + }, [pathname, router]); + + /** + * Logout - call FastAPI logout endpoint and clear local state + * Per RFC-001: Immediately clear local state, don't wait for server confirmation + */ + const logout = useCallback(async () => { + // Immediately clear local state to prevent UI flicker + setUser(null); + + try { + await fetch("/api/v1/auth/logout", { + method: "POST", + credentials: "include", + }); + } catch (err) { + console.error("Logout request failed:", err); + // Still redirect even if logout request fails + } + + // Redirect to home page + router.push("/"); + }, [router]); + + /** + * Handle visibility change - refresh user when tab becomes visible again. + * Throttled to at most once per 60 s to avoid spamming the backend on rapid tab switches. + */ + const lastCheckRef = React.useRef(0); + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState !== "visible" || user === null) return; + const now = Date.now(); + if (now - lastCheckRef.current < 60_000) return; + lastCheckRef.current = now; + void refreshUser(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [user, refreshUser]); + + const value: AuthContextType = { + user, + isAuthenticated, + isLoading, + logout, + refreshUser, + }; + + return {children}; +} + +/** + * Hook to access authentication context + * Throws if used outside AuthProvider - this is intentional for proper usage + */ +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + +/** + * Hook to require authentication - redirects to login if not authenticated + * Useful for client-side checks in addition to server-side guards + */ +export function useRequireAuth(): AuthContextType { + const auth = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + // Only redirect if we're sure user is not authenticated (not just loading) + if (!auth.isLoading && !auth.isAuthenticated) { + router.push(buildLoginUrl(pathname || "/workspace")); + } + }, [auth.isAuthenticated, auth.isLoading, router, pathname]); + + return auth; +} diff --git a/frontend/src/core/auth/gateway-config.ts b/frontend/src/core/auth/gateway-config.ts new file mode 100644 index 000000000..61c6ae850 --- /dev/null +++ b/frontend/src/core/auth/gateway-config.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +const gatewayConfigSchema = z.object({ + internalGatewayUrl: z.string().url(), + trustedOrigins: z.array(z.string()).min(1), +}); + +export type GatewayConfig = z.infer; + +let _cached: GatewayConfig | null = null; + +export function getGatewayConfig(): GatewayConfig { + if (_cached) return _cached; + + const isDev = process.env.NODE_ENV === "development"; + + const rawUrl = process.env.DEER_FLOW_INTERNAL_GATEWAY_BASE_URL?.trim(); + const internalGatewayUrl = + rawUrl?.replace(/\/+$/, "") ?? + (isDev ? "http://localhost:8001" : undefined); + + const rawOrigins = process.env.DEER_FLOW_TRUSTED_ORIGINS?.trim(); + const trustedOrigins = rawOrigins + ? rawOrigins + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : isDev + ? ["http://localhost:3000"] + : undefined; + + _cached = gatewayConfigSchema.parse({ internalGatewayUrl, trustedOrigins }); + return _cached; +} diff --git a/frontend/src/core/auth/proxy-policy.ts b/frontend/src/core/auth/proxy-policy.ts new file mode 100644 index 000000000..9e6f1f424 --- /dev/null +++ b/frontend/src/core/auth/proxy-policy.ts @@ -0,0 +1,55 @@ +export interface ProxyPolicy { + /** Allowed upstream path prefixes */ + readonly allowedPaths: readonly string[]; + /** Request headers to strip before forwarding */ + readonly strippedRequestHeaders: ReadonlySet; + /** Response headers to strip before returning */ + readonly strippedResponseHeaders: ReadonlySet; + /** Credential mode: which cookie to forward */ + readonly credential: { readonly type: "cookie"; readonly name: string }; + /** Timeout in ms */ + readonly timeoutMs: number; + /** CSRF: required for non-GET/HEAD */ + readonly csrf: boolean; +} + +export const LANGGRAPH_COMPAT_POLICY: ProxyPolicy = { + allowedPaths: [ + "threads", + "runs", + "assistants", + "store", + "models", + "mcp", + "skills", + "memory", + ], + strippedRequestHeaders: new Set([ + "host", + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "authorization", + "x-api-key", + "origin", + "referer", + "proxy-authorization", + "proxy-authenticate", + ]), + strippedResponseHeaders: new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "content-length", + "set-cookie", + ]), + credential: { type: "cookie", name: "access_token" }, + timeoutMs: 120_000, + csrf: true, +}; diff --git a/frontend/src/core/auth/server.ts b/frontend/src/core/auth/server.ts new file mode 100644 index 000000000..4229143aa --- /dev/null +++ b/frontend/src/core/auth/server.ts @@ -0,0 +1,57 @@ +import { cookies } from "next/headers"; + +import { getGatewayConfig } from "./gateway-config"; +import { type AuthResult, userSchema } from "./types"; + +const SSR_AUTH_TIMEOUT_MS = 5_000; + +/** + * Fetch the authenticated user from the gateway using the request's cookies. + * Returns a tagged AuthResult — callers use exhaustive switch, no try/catch. + */ +export async function getServerSideUser(): Promise { + const cookieStore = await cookies(); + const sessionCookie = cookieStore.get("access_token"); + + let internalGatewayUrl: string; + try { + internalGatewayUrl = getGatewayConfig().internalGatewayUrl; + } catch (err) { + return { tag: "config_error", message: String(err) }; + } + + if (!sessionCookie) return { tag: "unauthenticated" }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS); + + try { + const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, { + headers: { Cookie: `access_token=${sessionCookie.value}` }, + cache: "no-store", + signal: controller.signal, + }); + clearTimeout(timeout); // Clear immediately — covers all response branches + + if (res.ok) { + const parsed = userSchema.safeParse(await res.json()); + if (!parsed.success) { + console.error("[SSR auth] Malformed /auth/me response:", parsed.error); + return { tag: "gateway_unavailable" }; + } + if (parsed.data.needs_setup) { + return { tag: "needs_setup", user: parsed.data }; + } + return { tag: "authenticated", user: parsed.data }; + } + if (res.status === 401 || res.status === 403) { + return { tag: "unauthenticated" }; + } + console.error(`[SSR auth] /api/v1/auth/me responded ${res.status}`); + return { tag: "gateway_unavailable" }; + } catch (err) { + clearTimeout(timeout); + console.error("[SSR auth] Failed to reach gateway:", err); + return { tag: "gateway_unavailable" }; + } +} diff --git a/frontend/src/core/auth/types.ts b/frontend/src/core/auth/types.ts new file mode 100644 index 000000000..4cf42583e --- /dev/null +++ b/frontend/src/core/auth/types.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +// ── User schema (single source of truth) ────────────────────────── + +export const userSchema = z.object({ + id: z.string(), + email: z.string().email(), + system_role: z.enum(["admin", "user"]), + needs_setup: z.boolean().optional().default(false), +}); + +export type User = z.infer; + +// ── SSR auth result (tagged union) ──────────────────────────────── + +export type AuthResult = + | { tag: "authenticated"; user: User } + | { tag: "needs_setup"; user: User } + | { tag: "unauthenticated" } + | { tag: "gateway_unavailable" } + | { tag: "config_error"; message: string }; + +export function assertNever(x: never): never { + throw new Error(`Unexpected auth result: ${JSON.stringify(x)}`); +} + +export function buildLoginUrl(returnPath: string): string { + return `/login?next=${encodeURIComponent(returnPath)}`; +} + +// ── Backend error response parsing ──────────────────────────────── + +const AUTH_ERROR_CODES = [ + "invalid_credentials", + "token_expired", + "token_invalid", + "user_not_found", + "email_already_exists", + "provider_not_found", + "not_authenticated", +] as const; + +export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[number]; + +export interface AuthErrorResponse { + code: AuthErrorCode; + message: string; +} + +const authErrorSchema = z.object({ + code: z.enum(AUTH_ERROR_CODES), + message: z.string(), +}); + +export function parseAuthError(data: unknown): AuthErrorResponse { + // Try top-level {code, message} first + const parsed = authErrorSchema.safeParse(data); + if (parsed.success) return parsed.data; + + // Unwrap FastAPI's {detail: {code, message}} envelope + if (typeof data === "object" && data !== null && "detail" in data) { + const detail = (data as Record).detail; + const nested = authErrorSchema.safeParse(detail); + if (nested.success) return nested.data; + // Legacy string-detail responses + if (typeof detail === "string") { + return { code: "invalid_credentials", message: detail }; + } + } + + return { code: "invalid_credentials", message: "Authentication failed" }; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index de94e0c98..7de0d78ec 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -236,6 +236,7 @@ export const enUS: Translations = { reportIssue: "Report a issue", contactUs: "Contact us", about: "About DeerFlow", + logout: "Log out", }, // Conversation @@ -324,6 +325,7 @@ export const enUS: Translations = { title: "Settings", description: "Adjust how DeerFlow looks and behaves for you.", sections: { + account: "Account", appearance: "Appearance", memory: "Memory", tools: "Tools", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index a8d99e4c7..e42c681f2 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -168,6 +168,7 @@ export interface Translations { reportIssue: string; contactUs: string; about: string; + logout: string; }; // Conversation @@ -253,6 +254,7 @@ export interface Translations { title: string; description: string; sections: { + account: string; appearance: string; memory: string; tools: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 600cb8f07..b6416c288 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -224,6 +224,7 @@ export const zhCN: Translations = { reportIssue: "报告问题", contactUs: "联系我们", about: "关于 DeerFlow", + logout: "退出登录", }, // Conversation @@ -309,6 +310,7 @@ export const zhCN: Translations = { title: "设置", description: "根据你的偏好调整 DeerFlow 的界面和行为。", sections: { + account: "账号", appearance: "外观", memory: "记忆", tools: "工具", diff --git a/frontend/src/core/mcp/api.ts b/frontend/src/core/mcp/api.ts index 003303238..61e681d34 100644 --- a/frontend/src/core/mcp/api.ts +++ b/frontend/src/core/mcp/api.ts @@ -1,3 +1,4 @@ +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { MCPConfig } from "./types"; @@ -8,12 +9,15 @@ export async function loadMCPConfig() { } export async function updateMCPConfig(config: MCPConfig) { - const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`, { - method: "PUT", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/mcp/config`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(config), }, - body: JSON.stringify(config), - }); + ); return response.json(); } diff --git a/frontend/src/core/memory/api.ts b/frontend/src/core/memory/api.ts index 5fcf8e4c0..073328808 100644 --- a/frontend/src/core/memory/api.ts +++ b/frontend/src/core/memory/api.ts @@ -1,3 +1,4 @@ +import { fetchWithAuth } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; import type { @@ -85,14 +86,14 @@ export async function loadMemory(): Promise { } export async function clearMemory(): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/memory`, { + const response = await fetchWithAuth(`${getBackendBaseURL()}/api/memory`, { method: "DELETE", }); return readMemoryResponse(response, "Failed to clear memory"); } export async function deleteMemoryFact(factId: string): Promise { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`, { method: "DELETE", @@ -107,26 +108,32 @@ export async function exportMemory(): Promise { } export async function importMemory(memory: UserMemory): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/memory/import`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/memory/import`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(memory), }, - body: JSON.stringify(memory), - }); + ); return readMemoryResponse(response, "Failed to import memory"); } export async function createMemoryFact( input: MemoryFactInput, ): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/memory/facts`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/memory/facts`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(input), }, - body: JSON.stringify(input), - }); + ); return readMemoryResponse(response, "Failed to create memory fact"); } @@ -134,7 +141,7 @@ export async function updateMemoryFact( factId: string, input: MemoryFactPatchInput, ): Promise { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`, { method: "PATCH", diff --git a/frontend/src/core/skills/api.ts b/frontend/src/core/skills/api.ts index b6a358f03..03a713d92 100644 --- a/frontend/src/core/skills/api.ts +++ b/frontend/src/core/skills/api.ts @@ -1,3 +1,4 @@ +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { Skill } from "./type"; @@ -9,7 +10,7 @@ export async function loadSkills() { } export async function enableSkill(skillName: string, enabled: boolean) { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/skills/${skillName}`, { method: "PUT", @@ -38,13 +39,16 @@ export interface InstallSkillResponse { export async function installSkill( request: InstallSkillRequest, ): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/skills/install`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/skills/install`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), }, - body: JSON.stringify(request), - }); + ); if (!response.ok) { // Handle HTTP error responses (4xx, 5xx) diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 9292ac12b..33424aeee 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -8,6 +8,7 @@ import { toast } from "sonner"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { getAPIClient } from "../api"; +import { fetchWithAuth } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; import { useI18n } from "../i18n/hooks"; import type { FileInMessage } from "../messages/utils"; @@ -604,7 +605,7 @@ export function useDeleteThread() { mutationFn: async ({ threadId }: { threadId: string }) => { await apiClient.threads.delete(threadId); - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`, { method: "DELETE", diff --git a/frontend/src/core/uploads/api.ts b/frontend/src/core/uploads/api.ts index 23d463c2d..a00a259cb 100644 --- a/frontend/src/core/uploads/api.ts +++ b/frontend/src/core/uploads/api.ts @@ -2,6 +2,7 @@ * API functions for file uploads */ +import { fetchWithAuth } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; export interface UploadedFileInfo { @@ -50,7 +51,7 @@ export async function uploadFiles( formData.append("files", file); }); - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${threadId}/uploads`, { method: "POST", @@ -91,7 +92,7 @@ export async function deleteUploadedFile( threadId: string, filename: string, ): Promise<{ success: boolean; message: string }> { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${threadId}/uploads/${filename}`, { method: "DELETE", diff --git a/frontend/src/env.js b/frontend/src/env.js index f00fa7a6c..ea90cac5d 100644 --- a/frontend/src/env.js +++ b/frontend/src/env.js @@ -7,12 +7,6 @@ export const env = createEnv({ * isn't built with invalid env vars. */ server: { - BETTER_AUTH_SECRET: - process.env.NODE_ENV === "production" - ? z.string() - : z.string().optional(), - BETTER_AUTH_GITHUB_CLIENT_ID: z.string().optional(), - BETTER_AUTH_GITHUB_CLIENT_SECRET: z.string().optional(), GITHUB_OAUTH_TOKEN: z.string().optional(), NODE_ENV: z .enum(["development", "test", "production"]) @@ -35,10 +29,6 @@ export const env = createEnv({ * middlewares) or client-side so we need to destruct manually. */ runtimeEnv: { - BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, - BETTER_AUTH_GITHUB_CLIENT_ID: process.env.BETTER_AUTH_GITHUB_CLIENT_ID, - BETTER_AUTH_GITHUB_CLIENT_SECRET: - process.env.BETTER_AUTH_GITHUB_CLIENT_SECRET, NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_BACKEND_BASE_URL: process.env.NEXT_PUBLIC_BACKEND_BASE_URL, diff --git a/frontend/src/server/better-auth/client.ts b/frontend/src/server/better-auth/client.ts deleted file mode 100644 index 493f84993..000000000 --- a/frontend/src/server/better-auth/client.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAuthClient } from "better-auth/react"; - -export const authClient = createAuthClient(); - -export type Session = typeof authClient.$Infer.Session; diff --git a/frontend/src/server/better-auth/config.ts b/frontend/src/server/better-auth/config.ts deleted file mode 100644 index abf50faca..000000000 --- a/frontend/src/server/better-auth/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { betterAuth } from "better-auth"; - -export const auth = betterAuth({ - emailAndPassword: { - enabled: true, - }, -}); - -export type Session = typeof auth.$Infer.Session; diff --git a/frontend/src/server/better-auth/index.ts b/frontend/src/server/better-auth/index.ts deleted file mode 100644 index d705e873e..000000000 --- a/frontend/src/server/better-auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { auth } from "./config"; diff --git a/frontend/src/server/better-auth/server.ts b/frontend/src/server/better-auth/server.ts deleted file mode 100644 index 064cd349c..000000000 --- a/frontend/src/server/better-auth/server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { headers } from "next/headers"; -import { cache } from "react"; - -import { auth } from "."; - -export const getSession = cache(async () => - auth.api.getSession({ headers: await headers() }), -); From 848ace98cb0ca54735f6003b711d0bbb21eecab8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:39:12 +0800 Subject: [PATCH 28/83] feat: replace auto-admin creation with secure interactive first-boot setup (#2063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(persistence): add unified persistence layer with event store, token tracking, and feedback (#1930) * feat(persistence): add SQLAlchemy 2.0 async ORM scaffold Introduce a unified database configuration (DatabaseConfig) that controls both the LangGraph checkpointer and the DeerFlow application persistence layer from a single `database:` config section. New modules: - deerflow.config.database_config — Pydantic config with memory/sqlite/postgres backends - deerflow.persistence — async engine lifecycle, DeclarativeBase with to_dict mixin, Alembic skeleton - deerflow.runtime.runs.store — RunStore ABC + MemoryRunStore implementation Gateway integration initializes/tears down the persistence engine in the existing langgraph_runtime() context manager. Legacy checkpointer config is preserved for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(persistence): add RunEventStore ABC + MemoryRunEventStore Phase 2-A prerequisite for event storage: adds the unified run event stream interface (RunEventStore) with an in-memory implementation, RunEventsConfig, gateway integration, and comprehensive tests (27 cases). Co-Authored-By: Claude Opus 4.6 (1M context) * feat(persistence): add ORM models, repositories, DB/JSONL event stores, RunJournal, and API endpoints Phase 2-B: run persistence + event storage + token tracking. - ORM models: RunRow (with token fields), ThreadMetaRow, RunEventRow - RunRepository implements RunStore ABC via SQLAlchemy ORM - ThreadMetaRepository with owner access control - DbRunEventStore with trace content truncation and cursor pagination - JsonlRunEventStore with per-run files and seq recovery from disk - RunJournal (BaseCallbackHandler) captures LLM/tool/lifecycle events, accumulates token usage by caller type, buffers and flushes to store - RunManager now accepts optional RunStore for persistent backing - Worker creates RunJournal, writes human_message, injects callbacks - Gateway deps use factory functions (RunRepository when DB available) - New endpoints: messages, run messages, run events, token-usage - ThreadCreateRequest gains assistant_id field - 92 tests pass (33 new), zero regressions Co-Authored-By: Claude Opus 4.6 (1M context) * feat(persistence): add user feedback + follow-up run association Phase 2-C: feedback and follow-up tracking. - FeedbackRow ORM model (rating +1/-1, optional message_id, comment) - FeedbackRepository with CRUD, list_by_run/thread, aggregate stats - Feedback API endpoints: create, list, stats, delete - follow_up_to_run_id in RunCreateRequest (explicit or auto-detected from latest successful run on the thread) - Worker writes follow_up_to_run_id into human_message event metadata - Gateway deps: feedback_repo factory + getter - 17 new tests (14 FeedbackRepository + 3 follow-up association) - 109 total tests pass, zero regressions Co-Authored-By: Claude Opus 4.6 (1M context) * test+config: comprehensive Phase 2 test coverage + deprecate checkpointer config - config.example.yaml: deprecate standalone checkpointer section, activate unified database:sqlite as default (drives both checkpointer + app data) - New: test_thread_meta_repo.py (14 tests) — full ThreadMetaRepository coverage including check_access owner logic, list_by_owner pagination - Extended test_run_repository.py (+4 tests) — completion preserves fields, list ordering desc, limit, owner_none returns all - Extended test_run_journal.py (+8 tests) — on_chain_error, track_tokens=false, middleware no ai_message, unknown caller tokens, convenience fields, tool_error, non-summarization custom event - Extended test_run_event_store.py (+7 tests) — DB batch seq continuity, make_run_event_store factory (memory/db/jsonl/fallback/unknown) - Extended test_phase2b_integration.py (+4 tests) — create_or_reject persists, follow-up metadata, summarization in history, full DB-backed lifecycle - Fixed DB integration test to use proper fake objects (not MagicMock) for JSON-serializable metadata - 157 total Phase 2 tests pass, zero regressions Co-Authored-By: Claude Opus 4.6 (1M context) * config: move default sqlite_dir to .deer-flow/data Keep SQLite databases alongside other DeerFlow-managed data (threads, memory) under the .deer-flow/ directory instead of a top-level ./data folder. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(persistence): remove UTFJSON, use engine-level json_serializer + datetime.now() - Replace custom UTFJSON type with standard sqlalchemy.JSON in all ORM models. Add json_serializer=json.dumps(ensure_ascii=False) to all create_async_engine calls so non-ASCII text (Chinese etc.) is stored as-is in both SQLite and Postgres. - Change ORM datetime defaults from datetime.now(UTC) to datetime.now(), remove UTC imports. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(gateway): simplify deps.py with getter factory + inline repos - Replace 6 identical getter functions with _require() factory. - Inline 3 _make_*_repo() factories into langgraph_runtime(), call get_session_factory() once instead of 3 times. - Add thread_meta upsert in start_run (services.py). Co-Authored-By: Claude Opus 4.6 (1M context) * feat(docker): add UV_EXTRAS build arg for optional dependencies Support installing optional dependency groups (e.g. postgres) at Docker build time via UV_EXTRAS build arg: UV_EXTRAS=postgres docker compose build Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(journal): fix flush, token tracking, and consolidate tests RunJournal fixes: - _flush_sync: retain events in buffer when no event loop instead of dropping them; worker's finally block flushes via async flush(). - on_llm_end: add tool_calls filter and caller=="lead_agent" guard for ai_message events; mark message IDs for dedup with record_llm_usage. - worker.py: persist completion data (tokens, message count) to RunStore in finally block. Model factory: - Auto-inject stream_usage=True for BaseChatOpenAI subclasses with custom api_base, so usage_metadata is populated in streaming responses. Test consolidation: - Delete test_phase2b_integration.py (redundant with existing tests). - Move DB-backed lifecycle test into test_run_journal.py. - Add tests for stream_usage injection in test_model_factory.py. - Clean up executor/task_tool dead journal references. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(events): widen content type to str|dict in all store backends Allow event content to be a dict (for structured OpenAI-format messages) in addition to plain strings. Dict values are JSON-serialized for the DB backend and deserialized on read; memory and JSONL backends handle dicts natively. Trace truncation now serializes dicts to JSON before measuring. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(events): use metadata flag instead of heuristic for dict content detection Co-Authored-By: Claude Opus 4.6 (1M context) * feat(converters): add LangChain-to-OpenAI message format converters Pure functions langchain_to_openai_message, langchain_to_openai_completion, langchain_messages_to_openai, and _infer_finish_reason for converting LangChain BaseMessage objects to OpenAI Chat Completions format, used by RunJournal for event storage. 15 unit tests added. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(converters): handle empty list content as null, clean up test Co-Authored-By: Claude Opus 4.6 (1M context) * feat(events): human_message content uses OpenAI user message format Co-Authored-By: Claude Sonnet 4.6 * feat(events): ai_message uses OpenAI format, add ai_tool_call message event - ai_message content now uses {"role": "assistant", "content": "..."} format - New ai_tool_call message event emitted when lead_agent LLM responds with tool_calls - ai_tool_call uses langchain_to_openai_message converter for consistent format - Both events include finish_reason in metadata ("stop" or "tool_calls") Co-Authored-By: Claude Opus 4.6 (1M context) * feat(events): add tool_result message event with OpenAI tool message format Cache tool_call_id from on_tool_start keyed by run_id as fallback for on_tool_end, then emit a tool_result message event (role=tool, tool_call_id, content) after each successful tool completion. Co-Authored-By: Claude Sonnet 4.6 * feat(events): summary content uses OpenAI system message format Co-Authored-By: Claude Opus 4.6 (1M context) * feat(events): replace llm_start/llm_end with llm_request/llm_response in OpenAI format Add on_chat_model_start to capture structured prompt messages as llm_request events. Replace llm_end trace events with llm_response using OpenAI Chat Completions format. Track llm_call_index to pair request/response events. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(events): add record_middleware method for middleware trace events Co-Authored-By: Claude Opus 4.6 (1M context) * test(events): add full run sequence integration test for OpenAI content format Co-Authored-By: Claude Sonnet 4.6 * feat(events): align message events with checkpoint format and add middleware tag injection - Message events (ai_message, ai_tool_call, tool_result, human_message) now use BaseMessage.model_dump() format, matching LangGraph checkpoint values.messages - on_tool_end extracts tool_call_id/name/status from ToolMessage objects - on_tool_error now emits tool_result message events with error status - record_middleware uses middleware:{tag} event_type and middleware category - Summarization custom events use middleware:summarize category - TitleMiddleware injects middleware:title tag via get_config() inheritance - SummarizationMiddleware model bound with middleware:summarize tag - Worker writes human_message using HumanMessage.model_dump() Co-Authored-By: Claude Opus 4.6 (1M context) * feat(threads): switch search endpoint to threads_meta table and sync title - POST /api/threads/search now queries threads_meta table directly, removing the two-phase Store + Checkpointer scan approach - Add ThreadMetaRepository.search() with metadata/status filters - Add ThreadMetaRepository.update_display_name() for title sync - Worker syncs checkpoint title to threads_meta.display_name on run completion - Map display_name to values.title in search response for API compatibility Co-Authored-By: Claude Opus 4.6 (1M context) * feat(threads): history endpoint reads messages from event store - POST /api/threads/{thread_id}/history now combines two data sources: checkpointer for checkpoint_id, metadata, title, thread_data; event store for messages (complete history, not truncated by summarization) - Strip internal LangGraph metadata keys from response - Remove full channel_values serialization in favor of selective fields Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove duplicate optional-dependencies header in pyproject.toml Co-Authored-By: Claude Opus 4.6 (1M context) * fix(middleware): pass tagged config to TitleMiddleware ainvoke call Without the config, the middleware:title tag was not injected, causing the LLM response to be recorded as a lead_agent ai_message in run_events. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve merge conflict in .env.example Keep both DATABASE_URL (from persistence-scaffold) and WECOM credentials (from main) after the merge. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(persistence): address review feedback on PR #1851 - Fix naive datetime.now() → datetime.now(UTC) in all ORM models - Fix seq race condition in DbRunEventStore.put() with FOR UPDATE and UNIQUE(thread_id, seq) constraint - Encapsulate _store access in RunManager.update_run_completion() - Deduplicate _store.put() logic in RunManager via _persist_to_store() - Add update_run_completion to RunStore ABC + MemoryRunStore - Wire follow_up_to_run_id through the full create path - Add error recovery to RunJournal._flush_sync() lost-event scenario - Add migration note for search_threads breaking change - Fix test_checkpointer_none_fix mock to set database=None Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update uv.lock Co-Authored-By: Claude Opus 4.6 (1M context) * fix(persistence): address 22 review comments from CodeQL, Copilot, and Code Quality Bug fixes: - Sanitize log params to prevent log injection (CodeQL) - Reset threads_meta.status to idle/error when run completes - Attach messages only to latest checkpoint in /history response - Write threads_meta on POST /threads so new threads appear in search Lint fixes: - Remove unused imports (journal.py, migrations/env.py, test_converters.py) - Convert lambda to named function (engine.py, Ruff E731) - Remove unused logger definitions in repos (Ruff F841) - Add logging to JSONL decode errors and empty except blocks - Separate assert side-effects in tests (CodeQL) - Remove unused local variables in tests (Ruff F841) - Fix max_trace_content truncation to use byte length, not char length Co-Authored-By: Claude Opus 4.6 (1M context) * style: apply ruff format to persistence and runtime files Co-Authored-By: Claude Opus 4.6 (1M context) * Potential fix for pull request finding 'Statement has no effect' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * refactor(runtime): introduce RunContext to reduce run_agent parameter bloat Extract checkpointer, store, event_store, run_events_config, thread_meta_repo, and follow_up_to_run_id into a frozen RunContext dataclass. Add get_run_context() in deps.py to build the base context from app.state singletons. start_run() uses dataclasses.replace() to enrich per-run fields before passing ctx to run_agent. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(gateway): move sanitize_log_param to app/gateway/utils.py Extract the log-injection sanitizer from routers/threads.py into a shared utils module and rename to sanitize_log_param (public API). Eliminates the reverse service → router import in services.py. Co-Authored-By: Claude Opus 4.6 (1M context) * perf: use SQL aggregation for feedback stats and thread token usage Replace Python-side counting in FeedbackRepository.aggregate_by_run with a single SELECT COUNT/SUM query. Add RunStore.aggregate_tokens_by_thread abstract method with SQL GROUP BY implementation in RunRepository and Python fallback in MemoryRunStore. Simplify the thread_token_usage endpoint to delegate to the new method, eliminating the limit=10000 truncation risk. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: annotate DbRunEventStore.put() as low-frequency path Add docstring clarifying that put() opens a per-call transaction with FOR UPDATE and should only be used for infrequent writes (currently just the initial human_message event). High-throughput callers should use put_batch() instead. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(threads): fall back to Store search when ThreadMetaRepository is unavailable When database.backend=memory (default) or no SQL session factory is configured, search_threads now queries the LangGraph Store instead of returning 503. Returns empty list if neither Store nor repo is available. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(persistence): introduce ThreadMetaStore ABC for backend-agnostic thread metadata Add ThreadMetaStore abstract base class with create/get/search/update/delete interface. ThreadMetaRepository (SQL) now inherits from it. New MemoryThreadMetaStore wraps LangGraph BaseStore for memory-mode deployments. deps.py now always provides a non-None thread_meta_repo, eliminating all `if thread_meta_repo is not None` guards in services.py, worker.py, and routers/threads.py. search_threads no longer needs a Store fallback branch. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(history): read messages from checkpointer instead of RunEventStore The /history endpoint now reads messages directly from the checkpointer's channel_values (the authoritative source) instead of querying RunEventStore.list_messages(). The RunEventStore API is preserved for other consumers. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(persistence): address new Copilot review comments - feedback.py: validate thread_id/run_id before deleting feedback - jsonl.py: add path traversal protection with ID validation - run_repo.py: parse `before` to datetime for PostgreSQL compat - thread_meta_repo.py: fix pagination when metadata filter is active - database_config.py: use resolve_path for sqlite_dir consistency Co-Authored-By: Claude Opus 4.6 (1M context) * Implement skill self-evolution and skill_manage flow (#1874) * chore: ignore .worktrees directory * Add skill_manage self-evolution flow * Fix CI regressions for skill_manage * Address PR review feedback for skill evolution * fix(skill-evolution): preserve history on delete * fix(skill-evolution): tighten scanner fallbacks * docs: add skill_manage e2e evidence screenshot * fix(skill-manage): avoid blocking fs ops in session runtime --------- Co-authored-by: Willem Jiang * fix(config): resolve sqlite_dir relative to CWD, not Paths.base_dir resolve_path() resolves relative to Paths.base_dir (.deer-flow), which double-nested the path to .deer-flow/.deer-flow/data/app.db. Use Path.resolve() (CWD-relative) instead. Co-Authored-By: Claude Opus 4.6 (1M context) * Feature/feishu receive file (#1608) * feat(feishu): add channel file materialization hook for inbound messages - Introduce Channel.receive_file(msg, thread_id) as a base method for file materialization; default is no-op. - Implement FeishuChannel.receive_file to download files/images from Feishu messages, save to sandbox, and inject virtual paths into msg.text. - Update ChannelManager to call receive_file for any channel if msg.files is present, enabling downstream model access to user-uploaded files. - No impact on Slack/Telegram or other channels (they inherit the default no-op). * style(backend): format code with ruff for lint compliance - Auto-formatted packages/harness/deerflow/agents/factory.py and tests/test_create_deerflow_agent.py using `ruff format` - Ensured both files conform to project linting standards - Fixes CI lint check failures caused by code style issues * fix(feishu): handle file write operation asynchronously to prevent blocking * fix(feishu): rename GetMessageResourceRequest to _GetMessageResourceRequest and remove redundant code * test(feishu): add tests for receive_file method and placeholder replacement * fix(manager): remove unnecessary type casting for channel retrieval * fix(feishu): update logging messages to reflect resource handling instead of image * fix(feishu): sanitize filename by replacing invalid characters in file uploads * fix(feishu): improve filename sanitization and reorder image key handling in message processing * fix(feishu): add thread lock to prevent filename conflicts during file downloads * fix(test): correct bad merge in test_feishu_parser.py * chore: run ruff and apply formatting cleanup fix(feishu): preserve rich-text attachment order and improve fallback filename handling * fix(docker): restore gateway env vars and fix langgraph empty arg issue (#1915) Two production docker-compose.yaml bugs prevent `make up` from working: 1. Gateway missing DEER_FLOW_CONFIG_PATH and DEER_FLOW_EXTENSIONS_CONFIG_PATH environment overrides. Added in fb2d99f (#1836) but accidentally reverted by ca2fb95 (#1847). Without them, gateway reads host paths from .env via env_file, causing FileNotFoundError inside the container. 2. Langgraph command fails when LANGGRAPH_ALLOW_BLOCKING is unset (default). Empty $${allow_blocking} inserts a bare space between flags, causing ' --no-reload' to be parsed as unexpected extra argument. Fix by building args string first and conditionally appending --allow-blocking. Co-authored-by: cooper * fix(frontend): resolve invalid HTML nesting and tabnabbing vulnerabilities (#1904) * fix(frontend): resolve invalid HTML nesting and tabnabbing vulnerabilities Fix ` + +
+
+ ); + } + + // ── Change-password form (needs_setup after login) ───────────────── return ( -
-
+
+ +

DeerFlow

@@ -73,7 +266,7 @@ export default function SetupPage() { Set your real email and a new password.

-
+ setCurrentPassword(e.target.value)} required @@ -106,7 +299,7 @@ export default function SetupPage() { /> {error &&

{error}

}
diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index fa19025a0..c2d567339 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -23,6 +23,8 @@ export default async function WorkspaceLayout({ ); case "needs_setup": redirect("/setup"); + case "system_setup_required": + redirect("/setup"); case "unauthenticated": redirect("/login"); case "gateway_unavailable": diff --git a/frontend/src/components/workspace/settings/account-settings-page.tsx b/frontend/src/components/workspace/settings/account-settings-page.tsx index 6382b8859..c00d6961e 100644 --- a/frontend/src/components/workspace/settings/account-settings-page.tsx +++ b/frontend/src/components/workspace/settings/account-settings-page.tsx @@ -69,12 +69,10 @@ export function AccountSettingsPage() { return (
-
-
+
+
Email {user?.email ?? "—"} -
-
Role {user?.system_role ?? "—"} @@ -83,7 +81,10 @@ export function AccountSettingsPage() {
- +
- + + +
+ ); +} + export function MessageListItem({ className, + threadId, message, isLoading, - threadId, tokenUsageEnabled = false, + feedback, + runId, }: { className?: string; message: Message; isLoading?: boolean; threadId: string; tokenUsageEnabled?: boolean; + feedback?: FeedbackData | null; + runId?: string; }) { const isHuman = message.type === "human"; return ( @@ -70,7 +153,7 @@ export function MessageListItem({
@@ -81,6 +164,13 @@ export function MessageListItem({ "" } /> + {feedback !== undefined && runId && threadId && ( + + )}
)} diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index d1d02c6d0..238046ae6 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -19,6 +19,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; +import { useThreadMessageEnrichment } from "@/core/threads/hooks"; import { cn } from "@/lib/utils"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; @@ -51,6 +52,8 @@ export function MessageList({ const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const updateSubtask = useUpdateSubtask(); const messages = thread.messages; + const { data: enrichment } = useThreadMessageEnrichment(threadId); + if (thread.isThreadLoading && messages.length === 0) { return ; } @@ -62,13 +65,16 @@ export function MessageList({ {groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { return group.messages.map((msg) => { + const entry = msg.id ? enrichment?.get(msg.id) : undefined; return ( ); }); @@ -183,7 +189,7 @@ export function MessageList({ results.push(
{t.subtasks.executing(tasks.size)}
, diff --git a/frontend/src/core/api/feedback.ts b/frontend/src/core/api/feedback.ts new file mode 100644 index 000000000..5af3f02c8 --- /dev/null +++ b/frontend/src/core/api/feedback.ts @@ -0,0 +1,42 @@ +import { getBackendBaseURL } from "../config"; + +import { fetchWithAuth } from "./fetcher"; + +export interface FeedbackData { + feedback_id: string; + rating: number; + comment: string | null; +} + +export async function upsertFeedback( + threadId: string, + runId: string, + rating: number, + comment?: string, +): Promise { + const res = await fetchWithAuth( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rating, comment: comment ?? null }), + }, + ); + if (!res.ok) { + throw new Error(`Failed to submit feedback: ${res.status}`); + } + return res.json(); +} + +export async function deleteFeedback( + threadId: string, + runId: string, +): Promise { + const res = await fetchWithAuth( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`, + { method: "DELETE" }, + ); + if (!res.ok && res.status !== 404) { + throw new Error(`Failed to delete feedback: ${res.status}`); + } +} diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 33424aeee..3b0aafda2 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -8,6 +8,7 @@ import { toast } from "sonner"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { getAPIClient } from "../api"; +import type { FeedbackData } from "../api/feedback"; import { fetchWithAuth } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; import { useI18n } from "../i18n/hooks"; @@ -294,6 +295,9 @@ export function useThreadStream({ onFinish(state) { listeners.current.onFinish?.(state.values); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); + void queryClient.invalidateQueries({ + queryKey: ["thread-message-enrichment"], + }); }, }); @@ -678,3 +682,65 @@ export function useRenameThread() { }, }); } + +/** Per-message enrichment data attached by the backend ``/history`` helper. */ +export interface MessageEnrichment { + run_id: string; + /** ``undefined`` = not feedback-eligible; ``null`` = eligible but unrated. */ + feedback?: FeedbackData | null; +} + +/** + * Fetch ``/history`` once and index feedback + run_id by message id. + * + * Replaces the old ``useThreadFeedback`` hook which keyed by AI-message + * ordinal position — an inherently fragile mapping that broke whenever + * ``ai_tool_call`` messages were interleaved with ``ai_message`` messages. + * Keying by ``message.id`` is stable regardless of run count, tool-call + * chains, or summarization. + * + * The ``/history`` response is refreshed on every stream completion via + * ``invalidateQueries(["thread-message-enrichment"])`` in ``onFinish``. + */ +export function useThreadMessageEnrichment( + threadId: string | null | undefined, +) { + return useQuery({ + queryKey: ["thread-message-enrichment", threadId], + queryFn: async (): Promise> => { + const empty = new Map(); + if (!threadId) return empty; + const res = await fetchWithAuth( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/history`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ limit: 1 }), + }, + ); + if (!res.ok) return empty; + const entries = (await res.json()) as Array<{ + values?: { + messages?: Array<{ + id?: string; + run_id?: string; + feedback?: FeedbackData | null; + }>; + }; + }>; + const messages = entries[0]?.values?.messages ?? []; + const map = new Map(); + for (const m of messages) { + if (!m.id || !m.run_id) continue; + const entry: MessageEnrichment = { run_id: m.run_id }; + // Preserve presence: "feedback" key absent → ineligible; present with + // null → eligible but unrated; present with object → rated. + if ("feedback" in m) entry.feedback = m.feedback; + map.set(m.id, entry); + } + return map; + }, + enabled: !!threadId, + staleTime: 30_000, + }); +} From 44d9953e2e2f4f2993660ecb191be86ed89e608a Mon Sep 17 00:00:00 2001 From: JeffJiang Date: Sun, 12 Apr 2026 11:16:08 +0800 Subject: [PATCH 34/83] feat: Add metadata and descriptions to various documentation pages in Chinese - Added titles and descriptions to workspace usage, configuration, customization, design principles, installation, integration guide, lead agent, MCP integration, memory system, middleware, quick start, sandbox, skills, subagents, and tools documentation. - Removed outdated API/Gateway reference and concepts glossary pages. - Updated configuration reference to reflect current structure and removed unnecessary sections. - Introduced new model provider documentation for Ark and updated the index page for model providers. - Enhanced tutorials with titles and descriptions for better clarity and navigation. --- deer-flow.code-workspace | 4 +- frontend/src/app/[lang]/docs/layout.tsx | 4 +- frontend/src/components/landing/footer.tsx | 15 +- frontend/src/content/en/_meta.ts | 9 + .../en/application/agents-and-threads.mdx | 5 + .../content/en/application/configuration.mdx | 5 + .../en/application/deployment-guide.mdx | 56 +++--- frontend/src/content/en/application/index.mdx | 5 + .../operations-and-troubleshooting.mdx | 5 + .../content/en/application/quick-start.mdx | 5 + .../en/application/workspace-usage.mdx | 5 + .../src/content/en/harness/configuration.mdx | 5 + .../src/content/en/harness/customization.mdx | 5 + .../content/en/harness/design-principles.mdx | 5 + frontend/src/content/en/harness/index.mdx | 5 + .../content/en/harness/integration-guide.mdx | 5 + .../src/content/en/harness/lead-agent.mdx | 5 + frontend/src/content/en/harness/mcp.mdx | 5 + frontend/src/content/en/harness/memory.mdx | 5 + .../src/content/en/harness/middlewares.mdx | 5 + .../src/content/en/harness/quick-start.mdx | 168 +++++++----------- frontend/src/content/en/harness/sandbox.mdx | 5 + frontend/src/content/en/harness/skills.mdx | 5 + frontend/src/content/en/harness/subagents.mdx | 5 + frontend/src/content/en/harness/tools.mdx | 5 + .../content/en/introduction/core-concepts.mdx | 5 + .../en/introduction/harness-vs-app.mdx | 5 + .../content/en/introduction/why-deerflow.mdx | 5 + frontend/src/content/en/reference/_meta.ts | 16 +- .../en/reference/api-gateway-reference.mdx | 69 ------- .../en/reference/concepts-glossary.mdx | 67 ------- .../en/reference/configuration-reference.mdx | 123 ------------- .../en/reference/model-providers/_meta.ts | 9 + .../en/reference/model-providers/ark.mdx | 8 + .../en/reference/model-providers/index.mdx | 7 + .../en/reference/runtime-flags-and-modes.mdx | 36 ---- .../src/content/en/reference/source-map.mdx | 88 --------- .../tutorials/create-your-first-harness.mdx | 5 + .../en/tutorials/deploy-your-own-deerflow.mdx | 5 + .../en/tutorials/first-conversation.mdx | 5 + .../en/tutorials/use-tools-and-skills.mdx | 5 + .../content/en/tutorials/work-with-memory.mdx | 5 + frontend/src/content/zh/_meta.ts | 9 + .../zh/application/agents-and-threads.mdx | 5 + .../content/zh/application/configuration.mdx | 5 + .../zh/application/deployment-guide.mdx | 5 + frontend/src/content/zh/application/index.mdx | 5 + .../operations-and-troubleshooting.mdx | 5 + .../content/zh/application/quick-start.mdx | 5 + .../zh/application/workspace-usage.mdx | 5 + .../src/content/zh/harness/configuration.mdx | 5 + .../src/content/zh/harness/customization.mdx | 5 + .../content/zh/harness/design-principles.mdx | 5 + frontend/src/content/zh/harness/index.mdx | 5 + .../content/zh/harness/integration-guide.mdx | 5 + .../src/content/zh/harness/lead-agent.mdx | 5 + frontend/src/content/zh/harness/mcp.mdx | 10 +- frontend/src/content/zh/harness/memory.mdx | 5 + .../src/content/zh/harness/middlewares.mdx | 5 + .../src/content/zh/harness/quick-start.mdx | 166 +++++++---------- frontend/src/content/zh/harness/sandbox.mdx | 5 + frontend/src/content/zh/harness/skills.mdx | 5 + frontend/src/content/zh/harness/subagents.mdx | 5 + frontend/src/content/zh/harness/tools.mdx | 5 + .../content/zh/introduction/core-concepts.mdx | 5 + .../zh/introduction/harness-vs-app.mdx | 5 + .../content/zh/introduction/why-deerflow.mdx | 5 + frontend/src/content/zh/reference/_meta.ts | 16 +- .../zh/reference/api-gateway-reference.mdx | 68 ------- .../zh/reference/concepts-glossary.mdx | 67 ------- .../zh/reference/configuration-reference.mdx | 122 ------------- .../zh/reference/model-providers/_meta.ts | 9 + .../zh/reference/model-providers/ark.mdx | 8 + .../zh/reference/model-providers/index.mdx | 7 + .../zh/reference/runtime-flags-and-modes.mdx | 36 ---- .../src/content/zh/reference/source-map.mdx | 88 --------- .../tutorials/create-your-first-harness.mdx | 5 + .../zh/tutorials/deploy-your-own-deerflow.mdx | 5 + .../zh/tutorials/first-conversation.mdx | 5 + .../zh/tutorials/use-tools-and-skills.mdx | 5 + .../content/zh/tutorials/work-with-memory.mdx | 5 + 81 files changed, 528 insertions(+), 1027 deletions(-) delete mode 100644 frontend/src/content/en/reference/api-gateway-reference.mdx delete mode 100644 frontend/src/content/en/reference/concepts-glossary.mdx delete mode 100644 frontend/src/content/en/reference/configuration-reference.mdx create mode 100644 frontend/src/content/en/reference/model-providers/_meta.ts create mode 100644 frontend/src/content/en/reference/model-providers/ark.mdx create mode 100644 frontend/src/content/en/reference/model-providers/index.mdx delete mode 100644 frontend/src/content/en/reference/runtime-flags-and-modes.mdx delete mode 100644 frontend/src/content/en/reference/source-map.mdx delete mode 100644 frontend/src/content/zh/reference/api-gateway-reference.mdx delete mode 100644 frontend/src/content/zh/reference/concepts-glossary.mdx delete mode 100644 frontend/src/content/zh/reference/configuration-reference.mdx create mode 100644 frontend/src/content/zh/reference/model-providers/_meta.ts create mode 100644 frontend/src/content/zh/reference/model-providers/ark.mdx create mode 100644 frontend/src/content/zh/reference/model-providers/index.mdx delete mode 100644 frontend/src/content/zh/reference/runtime-flags-and-modes.mdx delete mode 100644 frontend/src/content/zh/reference/source-map.mdx diff --git a/deer-flow.code-workspace b/deer-flow.code-workspace index ef2863302..a4f4cb240 100644 --- a/deer-flow.code-workspace +++ b/deer-flow.code-workspace @@ -5,7 +5,7 @@ } ], "settings": { - "typescript.tsdk": "frontend/node_modules/typescript/lib", + "js/ts.tsdk.path": "frontend/node_modules/typescript/lib", "python-envs.pythonProjects": [ { "path": "backend", @@ -44,4 +44,4 @@ } ] } -} +} \ No newline at end of file diff --git a/frontend/src/app/[lang]/docs/layout.tsx b/frontend/src/app/[lang]/docs/layout.tsx index f63d6ae7b..895da1da8 100644 --- a/frontend/src/app/[lang]/docs/layout.tsx +++ b/frontend/src/app/[lang]/docs/layout.tsx @@ -34,14 +34,14 @@ export default async function DocLayout({ children, params }) { } pageMap={pageMap} docsRepositoryBase="https://github.com/bytedance/deerflow/tree/main/frontend/src/content" - footer={
@@ -144,7 +158,7 @@ export default function ChatPage() { />
- {mounted ? ( + {mountedRef.current ? (