From c6b0423558cafb603b01148bbc999a0b883b315e Mon Sep 17 00:00:00 2001 From: yangzheli <43645580+yangzheli@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:21:08 +0800 Subject: [PATCH 01/55] feat(frontend): add Playwright E2E tests with CI workflow (#2279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(frontend): add Playwright E2E tests with CI workflow Add end-to-end testing infrastructure using Playwright (Chromium only). 14 tests across 5 spec files cover landing page, chat workspace, thread history, sidebar navigation, and agent chat — all with mocked LangGraph/Backend APIs via network interception (zero backend dependency). New files: - playwright.config.ts — Chromium, 30s timeout, auto-start Next.js - tests/e2e/utils/mock-api.ts — shared API mocks & SSE stream helpers - tests/e2e/{landing,chat,thread-history,sidebar,agent-chat}.spec.ts - .github/workflows/e2e-tests.yml — push main + PR trigger, paths filter Updated: package.json, Makefile, .gitignore, CONTRIBUTING.md, frontend/CLAUDE.md, frontend/AGENTS.md, frontend/README.md Co-Authored-By: Claude Opus 4.6 * fix: apply Copilot suggestions --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Willem Jiang --- .github/workflows/e2e-tests.yml | 63 ++++++ .gitignore | 2 + CONTRIBUTING.md | 7 +- frontend/AGENTS.md | 3 +- frontend/CLAUDE.md | 3 + frontend/Makefile | 3 + frontend/README.md | 8 + frontend/package.json | 2 + frontend/playwright.config.ts | 33 +++ frontend/pnpm-lock.yaml | 63 +++++- frontend/tests/e2e/agent-chat.spec.ts | 46 ++++ frontend/tests/e2e/chat.spec.ts | 51 +++++ frontend/tests/e2e/landing.spec.ts | 32 +++ frontend/tests/e2e/sidebar.spec.ts | 32 +++ frontend/tests/e2e/thread-history.spec.ts | 76 +++++++ frontend/tests/e2e/utils/mock-api.ts | 261 ++++++++++++++++++++++ 16 files changed, 671 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/e2e-tests.yml create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/tests/e2e/agent-chat.spec.ts create mode 100644 frontend/tests/e2e/chat.spec.ts create mode 100644 frontend/tests/e2e/landing.spec.ts create mode 100644 frontend/tests/e2e/sidebar.spec.ts create mode 100644 frontend/tests/e2e/thread-history.spec.ts create mode 100644 frontend/tests/e2e/utils/mock-api.ts diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000000..8f5379ed1d --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,63 @@ +name: E2E Tests + +on: + push: + branches: [ 'main' ] + paths: + - 'frontend/**' + - '.github/workflows/e2e-tests.yml' + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'frontend/**' + - '.github/workflows/e2e-tests.yml' + +concurrency: + group: e2e-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + e2e-tests: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Enable Corepack + run: corepack enable + + - name: Use pinned pnpm version + run: corepack prepare pnpm@10.26.2 --activate + + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + working-directory: frontend + run: npx playwright install chromium --with-deps + + - name: Run E2E tests + working-directory: frontend + run: pnpm exec playwright test + env: + SKIP_ENV_VALIDATION: '1' + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index ed37fecbee..4e46d2e712 100644 --- a/.gitignore +++ b/.gitignore @@ -55,5 +55,7 @@ web/ backend/Dockerfile.langgraph config.yaml.bak .playwright-mcp +/frontend/test-results/ +/frontend/playwright-report/ .gstack/ .worktrees diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca7b05a333..241ca71af6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -300,9 +300,13 @@ Nginx (port 2026) ← Unified entry point cd backend make test -# Frontend tests +# Frontend unit tests cd frontend make test + +# Frontend E2E tests (requires Chromium; builds and auto-starts the Next.js production server) +cd frontend +make test-e2e ``` ### PR Regression Checks @@ -311,6 +315,7 @@ Every pull request triggers the following CI workflows: - **Backend unit tests** — [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml) - **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml) +- **Frontend E2E tests** — [.github/workflows/e2e-tests.yml](.github/workflows/e2e-tests.yml) (triggered only when `frontend/` files change) ## Code Style diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index f2bdba8f3d..036927a2b8 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -37,6 +37,7 @@ DeerFlow is built on a sophisticated agent-based architecture using the [LangGra ``` tests/ +├── e2e/ # E2E tests (Playwright, Chromium, mocked backend) └── unit/ # Unit tests (mirrors src/ layout, powered by Vitest) src/ ├── app/ # Next.js App Router pages @@ -98,7 +99,7 @@ When adding new agent features: 1. Follow the established project structure 2. Add comprehensive TypeScript types 3. Implement proper error handling -4. Write unit tests under `tests/unit/` (run with `pnpm test`) +4. Write unit tests under `tests/unit/` (run with `pnpm test`) and E2E tests under `tests/e2e/` (run with `pnpm test:e2e`) 5. Update this documentation 6. Follow the code style guide (ESLint + Prettier) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index a6dbfcf03a..1f66bf98ea 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -18,11 +18,14 @@ DeerFlow Frontend is a Next.js 16 web interface for an AI agent system. It commu | `pnpm lint` | ESLint only | | `pnpm lint:fix` | ESLint with auto-fix | | `pnpm test` | Run unit tests with Vitest | +| `pnpm test:e2e` | Run E2E tests with Playwright (Chromium) | | `pnpm typecheck` | TypeScript type check (`tsc --noEmit`) | | `pnpm start` | Start production server | Unit tests live under `tests/unit/` and mirror the `src/` layout (e.g., `tests/unit/core/api/stream-mode.test.ts` tests `src/core/api/stream-mode.ts`). Powered by Vitest; import source modules via the `@/` path alias. +E2E tests live under `tests/e2e/` and use Playwright with Chromium. They mock all backend APIs via `page.route()` network interception and test real page interactions (navigation, chat input, streaming responses). Config: `playwright.config.ts`. + ## Architecture ``` diff --git a/frontend/Makefile b/frontend/Makefile index c2b49861c0..48d23b97b1 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -10,6 +10,9 @@ dev: test: pnpm test +test-e2e: + pnpm test:e2e + lint: pnpm lint diff --git a/frontend/README.md b/frontend/README.md index 6ed73babfb..6db8813010 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -53,6 +53,12 @@ pnpm lint # Run unit tests pnpm test +# One-time setup: install Playwright Chromium browser +pnpm exec playwright install chromium + +# Run E2E tests (builds and starts production server automatically) +pnpm test:e2e + # Build for production pnpm build @@ -86,6 +92,7 @@ NEXT_PUBLIC_LANGGRAPH_BASE_URL="http://localhost:2024" ``` tests/ +├── e2e/ # E2E tests (Playwright, Chromium, mocked backend) └── unit/ # Unit tests (mirrors src/ layout) src/ ├── app/ # Next.js App Router pages @@ -125,6 +132,7 @@ src/ | `pnpm build` | Build for production | | `pnpm start` | Start production server | | `pnpm test` | Run unit tests with Vitest | +| `pnpm test:e2e` | Run E2E tests with Playwright | | `pnpm format` | Check formatting with Prettier | | `pnpm format:write` | Apply formatting with Prettier | | `pnpm lint` | Run ESLint | diff --git a/frontend/package.json b/frontend/package.json index 587be4e3f1..198dba37b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "preview": "next build && next start", "start": "next start", "test": "vitest run", + "test:e2e": "playwright test", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -93,6 +94,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4.0.15", "@types/gsap": "^3.0.0", "@types/node": "^20.14.10", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000000..2673564b6d --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "html", + timeout: 30_000, + + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + webServer: { + command: "pnpm build && pnpm start", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + SKIP_ENV_VALIDATION: "1", + }, + }, +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3660302100..0d5fe8d884 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: version: 1.2.1 better-auth: specifier: ^1.3 - version: 1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3)) + version: 1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3)) canvas-confetti: specifier: ^1.9.4 version: 1.9.4 @@ -160,16 +160,16 @@ importers: version: 5.1.6 next: specifier: ^16.1.7 - version: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) nextra: specifier: ^4.6.1 - version: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) nextra-theme-docs: specifier: ^4.6.1 - version: 4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + version: 4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) nuxt-og-image: specifier: ^5.1.13 version: 5.1.13(@unhead/vue@2.1.4(vue@3.5.28(typescript@5.9.3)))(unstorage@1.17.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vue@3.5.28(typescript@5.9.3)) @@ -228,6 +228,9 @@ importers: '@eslint/eslintrc': specifier: ^3.3.1 version: 3.3.3 + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@tailwindcss/postcss': specifier: ^4.0.15 version: 4.1.18 @@ -1156,6 +1159,11 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -3632,6 +3640,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4794,6 +4807,16 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -6847,6 +6870,10 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@polka/url@1.0.0-next.29': {} '@radix-ui/number@1.1.1': {} @@ -8415,7 +8442,7 @@ snapshots: best-effort-json-parser@1.2.1: {} - better-auth@1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3)): + better-auth@1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -8430,7 +8457,7 @@ snapshots: nanostores: 1.1.0 zod: 4.3.6 optionalDependencies: - next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) vitest: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) @@ -9475,6 +9502,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10724,7 +10754,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 @@ -10744,18 +10774,19 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.1.7 '@next/swc-win32-x64-msvc': 16.1.7 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.59.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + nextra-theme-docs@4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): dependencies: '@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) clsx: 2.1.1 - next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - nextra: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + nextra: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: 19.2.4 react-compiler-runtime: 19.1.0-rc.3(react@19.2.4) react-dom: 19.2.4(react@19.2.4) @@ -10767,7 +10798,7 @@ snapshots: - immer - use-sync-external-store - nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: '@formatjs/intl-localematcher': 0.6.2 '@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -10788,7 +10819,7 @@ snapshots: mdast-util-gfm: 3.1.0 mdast-util-to-hast: 13.2.1 negotiator: 1.0.0 - next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-compiler-runtime: 19.1.0-rc.3(react@19.2.4) react-dom: 19.2.4(react@19.2.4) @@ -11096,6 +11127,14 @@ snapshots: playwright-core@1.58.2: {} + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/frontend/tests/e2e/agent-chat.spec.ts b/frontend/tests/e2e/agent-chat.spec.ts new file mode 100644 index 0000000000..35616705b8 --- /dev/null +++ b/frontend/tests/e2e/agent-chat.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +const MOCK_AGENTS = [ + { + name: "test-agent", + description: "A test agent for E2E tests", + system_prompt: "You are a test agent.", + }, +]; + +test.describe("Agent chat", () => { + test("agent gallery page loads and shows agents", async ({ page }) => { + mockLangGraphAPI(page, { agents: MOCK_AGENTS }); + + await page.goto("/workspace/agents"); + + // The agent card should appear with the agent name + await expect(page.getByText("test-agent")).toBeVisible({ + timeout: 15_000, + }); + }); + + test("agent chat page loads with input box", async ({ page }) => { + mockLangGraphAPI(page, { agents: MOCK_AGENTS }); + + await page.goto("/workspace/agents/test-agent/chats/new"); + + // The prompt input textarea should be visible + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + }); + + test("agent chat page shows agent badge", async ({ page }) => { + mockLangGraphAPI(page, { agents: MOCK_AGENTS }); + + await page.goto("/workspace/agents/test-agent/chats/new"); + + // The agent badge should display in the header (scoped to header to avoid + // matching the welcome area which also shows the agent name) + await expect( + page.locator("header span", { hasText: "test-agent" }), + ).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts new file mode 100644 index 0000000000..490305de96 --- /dev/null +++ b/frontend/tests/e2e/chat.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; + +import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api"; + +test.describe("Chat workspace", () => { + test.beforeEach(async ({ page }) => { + mockLangGraphAPI(page); + }); + + test("new chat page loads with input box", async ({ page }) => { + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + }); + + test("can type a message in the input box", async ({ page }) => { + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + + await textarea.fill("Hello, DeerFlow!"); + await expect(textarea).toHaveValue("Hello, DeerFlow!"); + }); + + test("sending a message triggers API call and shows response", async ({ + page, + }) => { + let streamCalled = false; + await page.route("**/runs/stream", (route) => { + streamCalled = true; + return handleRunStream(route); + }); + + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + + await textarea.fill("Hello"); + await textarea.press("Enter"); + + await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy(); + + // The AI response should appear in the chat + await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/frontend/tests/e2e/landing.spec.ts b/frontend/tests/e2e/landing.spec.ts new file mode 100644 index 0000000000..b9d59f918b --- /dev/null +++ b/frontend/tests/e2e/landing.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +test.describe("Landing page", () => { + test("renders the header and hero section", async ({ page }) => { + await page.goto("/"); + + // Header brand name + await expect( + page.locator("header h1", { hasText: "DeerFlow" }), + ).toBeVisible(); + + // "Get Started" call-to-action button in hero + await expect( + page.getByRole("link", { name: /get started/i }), + ).toBeVisible(); + }); + + test("Get Started link navigates to workspace", async ({ page }) => { + mockLangGraphAPI(page); + + await page.goto("/"); + + const getStarted = page.getByRole("link", { name: /get started/i }); + await getStarted.click(); + + // Should redirect to /workspace/chats/new + await page.waitForURL("**/workspace/chats/new"); + await expect(page).toHaveURL(/\/workspace\/chats\/new/); + }); +}); diff --git a/frontend/tests/e2e/sidebar.spec.ts b/frontend/tests/e2e/sidebar.spec.ts new file mode 100644 index 0000000000..4aac9fa588 --- /dev/null +++ b/frontend/tests/e2e/sidebar.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +test.describe("Sidebar navigation", () => { + test("sidebar contains Chats and Agents nav links", async ({ page }) => { + mockLangGraphAPI(page); + + await page.goto("/workspace/chats/new"); + + // Sidebar uses data-sidebar="menu-button" with asChild rendering on + const sidebar = page.locator("[data-sidebar='sidebar']"); + await expect(sidebar.locator("a[href='/workspace/chats']")).toBeVisible({ + timeout: 15_000, + }); + await expect(sidebar.locator("a[href='/workspace/agents']")).toBeVisible(); + }); + + test("Agents link navigates to agents page", async ({ page }) => { + mockLangGraphAPI(page); + + await page.goto("/workspace/chats/new"); + + const sidebar = page.locator("[data-sidebar='sidebar']"); + const agentsLink = sidebar.locator("a[href='/workspace/agents']"); + await expect(agentsLink).toBeVisible({ timeout: 15_000 }); + await agentsLink.click(); + + await page.waitForURL("**/workspace/agents"); + await expect(page).toHaveURL(/\/workspace\/agents/); + }); +}); diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts new file mode 100644 index 0000000000..19fce310ae --- /dev/null +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from "@playwright/test"; + +import { + mockLangGraphAPI, + MOCK_THREAD_ID, + MOCK_THREAD_ID_2, +} from "./utils/mock-api"; + +const THREADS = [ + { + thread_id: MOCK_THREAD_ID, + title: "First conversation", + updated_at: "2025-06-01T12:00:00Z", + }, + { + thread_id: MOCK_THREAD_ID_2, + title: "Second conversation", + updated_at: "2025-06-02T12:00:00Z", + }, +]; + +test.describe("Thread history", () => { + test("sidebar shows existing threads", async ({ page }) => { + mockLangGraphAPI(page, { threads: THREADS }); + + await page.goto("/workspace/chats/new"); + + // Both thread titles should appear in the sidebar + await expect(page.getByText("First conversation")).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByText("Second conversation")).toBeVisible(); + }); + + test("clicking a thread in sidebar navigates to it", async ({ page }) => { + mockLangGraphAPI(page, { threads: THREADS }); + + await page.goto("/workspace/chats/new"); + + // Wait for sidebar to populate + const firstThread = page.getByText("First conversation"); + await expect(firstThread).toBeVisible({ timeout: 15_000 }); + + // Click on the first thread + await firstThread.click(); + + // Should navigate to that thread's URL + await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID}`); + await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID)); + }); + + test("existing thread loads historical messages", async ({ page }) => { + mockLangGraphAPI(page, { threads: THREADS }); + + // Navigate directly to an existing thread + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + + // The historical AI response should be displayed + await expect( + page.getByText("Response in thread First conversation"), + ).toBeVisible({ timeout: 15_000 }); + }); + + test("chats list page shows all threads", async ({ page }) => { + mockLangGraphAPI(page, { threads: THREADS }); + + await page.goto("/workspace/chats"); + + // Both threads should be listed in the main content area + const main = page.locator("main"); + await expect(main.getByText("First conversation")).toBeVisible({ + timeout: 15_000, + }); + await expect(main.getByText("Second conversation")).toBeVisible(); + }); +}); diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts new file mode 100644 index 0000000000..ca4862c098 --- /dev/null +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -0,0 +1,261 @@ +/** + * Shared mock helpers for E2E tests. + * + * Intercepts all LangGraph / Backend API endpoints so tests can run without + * a real backend. Each test file imports `mockLangGraphAPI` and + * `handleRunStream` from here. + */ + +import type { Page, Route } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Constants — deterministic IDs used across tests +// --------------------------------------------------------------------------- + +export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001"; +export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002"; +export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type MockThread = { + thread_id: string; + title?: string; + updated_at?: string; + agent_name?: string; +}; + +export type MockAgent = { + name: string; + description?: string; + system_prompt?: string; +}; + +export type MockAPIOptions = { + threads?: MockThread[]; + agents?: MockAgent[]; +}; + +// --------------------------------------------------------------------------- +// mockLangGraphAPI +// --------------------------------------------------------------------------- + +/** + * Mock all LangGraph API endpoints that the frontend calls on page load and + * during message sending. Without these mocks the pages would hang waiting + * for a real backend. + */ +export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { + const threads = options?.threads ?? []; + const agents = options?.agents ?? []; + + // Thread search — sidebar thread list & chats list page + void page.route("**/api/langgraph/threads/search", (route) => { + const body = threads.map((t) => ({ + thread_id: t.thread_id, + created_at: "2025-01-01T00:00:00Z", + updated_at: t.updated_at ?? "2025-01-01T00:00:00Z", + metadata: t.agent_name ? { agent_name: t.agent_name } : {}, + status: "idle", + values: { title: t.title ?? "Untitled" }, + })); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); + }); + + // Thread create — called when user sends first message in a new chat + void page.route("**/api/langgraph/threads", (route) => { + if (route.request().method() === "POST") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + thread_id: MOCK_THREAD_ID, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + metadata: {}, + status: "idle", + values: {}, + }), + }); + } + return route.fallback(); + }); + + // Thread update (PATCH) — metadata update after creation + void page.route("**/api/langgraph/threads/*", (route) => { + if (route.request().method() === "PATCH") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ thread_id: MOCK_THREAD_ID }), + }); + } + return route.fallback(); + }); + + // Thread history — useStream fetches state history on mount + void page.route("**/api/langgraph/threads/*/history", (route) => { + const url = route.request().url(); + + // For threads that exist in our mock data, return history with messages + const matchingThread = threads.find((t) => url.includes(t.thread_id)); + if (matchingThread) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + values: { + title: matchingThread.title ?? "Untitled", + messages: [ + { + type: "human", + id: `msg-human-${matchingThread.thread_id}`, + content: [{ type: "text", text: "Previous question" }], + }, + { + type: "ai", + id: `msg-ai-${matchingThread.thread_id}`, + content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`, + }, + ], + }, + next: [], + metadata: {}, + created_at: "2025-01-01T00:00:00Z", + parent_config: null, + }, + ]), + }); + } + + // New threads — empty history + return route.fulfill({ + status: 200, + contentType: "application/json", + body: "[]", + }); + }); + + // Thread state — getState for individual thread + void page.route("**/api/langgraph/threads/*/state", (route) => { + if (route.request().method() === "GET") { + const url = route.request().url(); + const matchingThread = threads.find((t) => url.includes(t.thread_id)); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + values: { + title: matchingThread?.title ?? "Untitled", + messages: matchingThread + ? [ + { + type: "human", + id: `msg-human-${matchingThread.thread_id}`, + content: [{ type: "text", text: "Previous question" }], + }, + { + type: "ai", + id: `msg-ai-${matchingThread.thread_id}`, + content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`, + }, + ] + : [], + }, + next: [], + metadata: {}, + created_at: "2025-01-01T00:00:00Z", + }), + }); + } + return route.fallback(); + }); + + // Run stream — returns a minimal SSE response with an AI message + void page.route("**/api/langgraph/runs/stream", handleRunStream); + void page.route("**/api/langgraph/threads/*/runs/stream", handleRunStream); + + // Agents list — sidebar & gallery page + void page.route("**/api/agents", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ agents }), + }); + } + return route.fallback(); + }); + + // Individual agent — agent chat page + void page.route("**/api/agents/*", (route) => { + if (route.request().method() === "GET") { + const url = route.request().url(); + const agent = agents.find((a) => url.endsWith(`/api/agents/${a.name}`)); + if (agent) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(agent), + }); + } + } + return route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ detail: "Agent not found" }), + }); + }); +} + +// --------------------------------------------------------------------------- +// handleRunStream +// --------------------------------------------------------------------------- + +/** + * Build a minimal SSE stream that the LangGraph SDK can parse. + * The stream returns a single AI message: "Hello from DeerFlow!". + */ +export function handleRunStream(route: Route) { + const events = [ + { + event: "metadata", + data: { run_id: MOCK_RUN_ID, thread_id: MOCK_THREAD_ID }, + }, + { + event: "values", + data: { + messages: [ + { + type: "human", + id: "msg-human-1", + content: [{ type: "text", text: "Hello" }], + }, + { + type: "ai", + id: "msg-ai-1", + content: "Hello from DeerFlow!", + }, + ], + }, + }, + { event: "end", data: {} }, + ]; + + const body = events + .map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`) + .join(""); + + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body, + }); +} From ca1b7d5f48bf46db80898af21d20f1da23ccdf69 Mon Sep 17 00:00:00 2001 From: Shawn Jasper Date: Sat, 18 Apr 2026 08:46:59 +0800 Subject: [PATCH 02/55] fix(sandbox): add missing path masking in ls_tool output (#2317) ls_tool was the only file-system tool that did not call mask_local_paths_in_output() before returning its result, causing host absolute paths (e.g. /Users/.../backend/.deer-flow/knowledge-base/...) to leak to the LLM instead of the expected virtual paths (/mnt/knowledge-base/...). This patch: - Adds the mask_local_paths_in_output() call to ls_tool, consistent with bash_tool, glob_tool and grep_tool. - Initialises thread_data = None before the is_local_sandbox branch (same pattern as glob_tool) so the variable is always in scope. - Adds three new tests covering user-data path masking, skills path masking and the empty-directory edge case. --- .../harness/deerflow/sandbox/tools.py | 3 + backend/tests/test_sandbox_search_tools.py | 70 ++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 089fa725d2..7b09358e78 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -1047,6 +1047,7 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path: sandbox = ensure_sandbox_initialized(runtime) ensure_thread_directories_exist(runtime) requested_path = path + thread_data = None if is_local_sandbox(runtime): thread_data = get_thread_data(runtime) validate_local_tool_path(path, thread_data, read_only=True) @@ -1061,6 +1062,8 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path: if not children: return "(empty)" output = "\n".join(children) + if thread_data is not None: + output = mask_local_paths_in_output(output, thread_data) try: from deerflow.config.app_config import get_app_config diff --git a/backend/tests/test_sandbox_search_tools.py b/backend/tests/test_sandbox_search_tools.py index 6b6c686c4b..88e87a783d 100644 --- a/backend/tests/test_sandbox_search_tools.py +++ b/backend/tests/test_sandbox_search_tools.py @@ -4,7 +4,7 @@ from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox from deerflow.sandbox.local.local_sandbox import LocalSandbox from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches -from deerflow.sandbox.tools import glob_tool, grep_tool +from deerflow.sandbox.tools import glob_tool, grep_tool, ls_tool def _make_runtime(tmp_path): @@ -391,3 +391,71 @@ def test_aio_sandbox_grep_skips_mismatched_line_number_payloads(monkeypatch) -> assert matches == [GrepMatch(path="/mnt/user-data/workspace/app.py", line_number=7, line="TODO = True")] assert truncated is False + + +# --------------------------------------------------------------------------- +# ls_tool — path masking +# --------------------------------------------------------------------------- + + +def test_ls_tool_masks_user_data_host_paths(tmp_path, monkeypatch) -> None: + """ls_tool output must not leak host user-data paths; they should be virtual.""" + runtime = _make_runtime(tmp_path) + workspace = tmp_path / "workspace" + (workspace / "report.txt").write_text("hello\n", encoding="utf-8") + (workspace / "subdir").mkdir() + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + result = ls_tool.func( + runtime=runtime, + description="list workspace", + path="/mnt/user-data/workspace", + ) + + # Virtual paths must be present + assert "/mnt/user-data/workspace" in result + # Host paths must NOT leak + assert str(workspace) not in result + assert str(tmp_path) not in result + + +def test_ls_tool_masks_skills_host_paths(tmp_path, monkeypatch) -> None: + """ls_tool output must not leak host skills paths; they should be virtual.""" + runtime = _make_runtime(tmp_path) + skills_dir = tmp_path / "skills" + (skills_dir / "public").mkdir(parents=True) + (skills_dir / "public" / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + with ( + patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"), + patch("deerflow.sandbox.tools._get_skills_host_path", return_value=str(skills_dir)), + ): + result = ls_tool.func( + runtime=runtime, + description="list skills", + path="/mnt/skills", + ) + + # Virtual paths must be present + assert "/mnt/skills" in result + # Host paths must NOT leak + assert str(skills_dir) not in result + assert str(tmp_path) not in result + + +def test_ls_tool_returns_empty_for_empty_directory(tmp_path, monkeypatch) -> None: + """ls_tool should return '(empty)' for an empty directory.""" + runtime = _make_runtime(tmp_path) + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + result = ls_tool.func( + runtime=runtime, + description="list empty dir", + path="/mnt/user-data/workspace", + ) + + assert result == "(empty)" From 3b91df2b185678d469375cb8a69b5eeda36b8911 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 18 Apr 2026 11:35:19 +0800 Subject: [PATCH 03/55] fix(frontend): add catch-all API rewrite for gateway routes (#2335) When NEXT_PUBLIC_BACKEND_BASE_URL is unset, the frontend proxies API requests to the gateway. Only /api/agents and /api/skills had rewrite rules, causing 404s for /api/models, /api/threads, /api/memory, /api/mcp, /api/suggestions, /api/runs, etc. Add a catch-all /api/:path* rewrite that proxies all remaining gateway API routes. The existing /api/langgraph rewrite takes priority because it is pushed to the array first (Next.js checks rewrites in order). Fixes #2327 Co-authored-by: JasonOA888 --- frontend/next.config.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/next.config.js b/frontend/next.config.js index 6d08b3cba7..0f22635c2f 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -60,6 +60,18 @@ const config = { source: "/api/skills/:path*", destination: `${gatewayURL}/api/skills/:path*`, }); + + // Catch-all for remaining gateway API routes (models, threads, memory, + // mcp, artifacts, uploads, suggestions, runs, etc.) that don't have + // their own NEXT_PUBLIC_* env var toggle. + // + // NOTE: this must come AFTER the /api/langgraph rewrite above so that + // LangGraph routes are matched first when NEXT_PUBLIC_LANGGRAPH_BASE_URL + // is unset. + rewrites.push({ + source: "/api/:path*", + destination: `${gatewayURL}/api/:path*`, + }); } return rewrites; From 1221448029be6b0cd3553e48a18982baaefad29d Mon Sep 17 00:00:00 2001 From: Airene Fang Date: Sat, 18 Apr 2026 19:33:32 +0800 Subject: [PATCH 04/55] =?UTF-8?q?fix(scripts):=20Cloud=20Provider=20Report?= =?UTF-8?q?s=20Security=20Issue=EF=BC=88aliyun=20could=EF=BC=89=20(#2323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ATT&CK矩阵ID:T1059.004 数据来源:进程启动触发检测 告警原因:该进程的命令行显示出反弹shelI的特征 命令行:timeout 1 bash -c exec 3<>/dev/tcp/127.0.0.1/2024 进程路径:/usr/bin/timeout 进程链:-[337650] /usr/sbin/sshd -D -[397971] /usr/sbin/sshd -D -R -[397977]-bash -[398903] make dev -[398920] bash ./scripts/serve.sh --dev -[399037]bash ./scripts/wait-for-port.sh 2024 60 LangGraph --- scripts/wait-for-port.sh | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scripts/wait-for-port.sh b/scripts/wait-for-port.sh index 198c3d0deb..dc0dffa1d3 100755 --- a/scripts/wait-for-port.sh +++ b/scripts/wait-for-port.sh @@ -39,11 +39,6 @@ is_port_listening() { fi fi - if command -v timeout >/dev/null 2>&1; then - timeout 1 bash -c "exec 3<>/dev/tcp/127.0.0.1/$PORT" >/dev/null 2>&1 - return $? - fi - return 1 } From aa6098e6a40166ff29e0971b05f3c561f4739ca1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:54:21 +0800 Subject: [PATCH 05/55] chore(deps): bump langsmith from 0.6.4 to 0.7.31 in /backend (#2291) Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from 0.6.4 to 0.7.31. - [Release notes](https://github.com/langchain-ai/langsmith-sdk/releases) - [Commits](https://github.com/langchain-ai/langsmith-sdk/compare/v0.6.4...v0.7.31) --- updated-dependencies: - dependency-name: langsmith dependency-version: 0.7.31 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/uv.lock | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index af739264bf..3aa4bc7832 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1814,7 +1814,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.6.4" +version = "0.7.31" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -1824,11 +1824,12 @@ dependencies = [ { name = "requests" }, { name = "requests-toolbelt" }, { name = "uuid-utils" }, + { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" }, ] [package.optional-dependencies] From be4663505a27ab7109876c479459bf9f0c651845 Mon Sep 17 00:00:00 2001 From: Willem Jiang Date: Sat, 18 Apr 2026 20:02:51 +0800 Subject: [PATCH 06/55] chroe(script): disable the color log of langgraph --- scripts/serve.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/serve.sh b/scripts/serve.sh index 1601a9eecd..8c1aaf0f0a 100755 --- a/scripts/serve.sh +++ b/scripts/serve.sh @@ -279,7 +279,7 @@ if ! $GATEWAY_MODE; then LANGGRAPH_ALLOW_BLOCKING_FLAG="--allow-blocking" fi run_service "LangGraph" \ - "cd backend && NO_COLOR=1 uv run langgraph dev --no-browser $LANGGRAPH_ALLOW_BLOCKING_FLAG --n-jobs-per-worker $LANGGRAPH_JOBS_PER_WORKER --server-log-level $LANGGRAPH_LOG_LEVEL $LANGGRAPH_EXTRA_FLAGS > ../logs/langgraph.log 2>&1" \ + "cd backend && NO_COLOR=1 CLICOLOR=0 CLICOLOR_FORCE=0 PY_COLORS=0 TERM=dumb uv run langgraph dev --no-browser $LANGGRAPH_ALLOW_BLOCKING_FLAG --n-jobs-per-worker $LANGGRAPH_JOBS_PER_WORKER --server-log-level $LANGGRAPH_LOG_LEVEL $LANGGRAPH_EXTRA_FLAGS 2>&1 | perl -pe 's/\e\[[0-9;]*[[:alpha:]]//g' > ../logs/langgraph.log" \ 2024 60 else echo "⏩ Skipping LangGraph (Gateway mode — runtime embedded in Gateway)" From 24fe5fbd8cea7319366495c21b57509c69414d77 Mon Sep 17 00:00:00 2001 From: imhaoran <117565557+thresh111@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:07:30 +0800 Subject: [PATCH 07/55] =?UTF-8?q?fix(mcp):=20prevent=20RuntimeError=20from?= =?UTF-8?q?=20escaping=20except=20block=20in=20get=5Fcach=E2=80=A6=20(#225?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): prevent RuntimeError from escaping except block in get_cached_mcp_tools When `asyncio.get_event_loop()` raises RuntimeError and the fallback `asyncio.run()` also fails, the exception escapes unhandled because Python does not route exceptions raised inside an `except` block to sibling `except` clauses. Wrap the fallback call in its own try/except so failures are logged and the function returns [] as intended. * fix: use logger.exception to preserve stack traces on MCP init failure --- backend/packages/harness/deerflow/mcp/cache.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/packages/harness/deerflow/mcp/cache.py b/backend/packages/harness/deerflow/mcp/cache.py index 38750e135f..c1121f59d9 100644 --- a/backend/packages/harness/deerflow/mcp/cache.py +++ b/backend/packages/harness/deerflow/mcp/cache.py @@ -118,9 +118,13 @@ def get_cached_mcp_tools() -> list[BaseTool]: loop.run_until_complete(initialize_mcp_tools()) except RuntimeError: # No event loop exists, create one - asyncio.run(initialize_mcp_tools()) - except Exception as e: - logger.error(f"Failed to lazy-initialize MCP tools: {e}") + try: + asyncio.run(initialize_mcp_tools()) + except Exception: + logger.exception("Failed to lazy-initialize MCP tools") + return [] + except Exception: + logger.exception("Failed to lazy-initialize MCP tools") return [] return _mcp_tools_cache or [] From 55474011c9fd4e7aac176df0ce580bcf749a596d Mon Sep 17 00:00:00 2001 From: Shawn Jasper Date: Sat, 18 Apr 2026 22:17:37 +0800 Subject: [PATCH 08/55] fix(subagent): inherit parent agent's tool_groups in task_tool (#2305) * fix(subagent): inherit parent agent's tool_groups in task_tool When a custom agent defines tool_groups (e.g. [file:read, file:write, bash]), the restriction is correctly applied to the lead agent. However, when the lead agent delegates work to a subagent via the task tool, get_available_tools() is called without the groups parameter, causing the subagent to receive ALL tools (including web_search, web_fetch, image_search, etc.) regardless of the parent agent's configuration. This fix propagates tool_groups through run metadata so that task_tool passes the same group filter when building the subagent's tool set. Changes: - agent.py: include tool_groups in run metadata - task_tool.py: read tool_groups from metadata and pass to get_available_tools() * fix: initialize metadata before conditional block and update tests for tool_groups propagation - Initialize metadata = {} before the 'if runtime is not None' block to avoid Ruff F821 (possibly-undefined variable) and simplify the parent_tool_groups expression. - Update existing test assertion to expect groups=None in get_available_tools call signature. - Add 3 new test cases: - test_task_tool_propagates_tool_groups_to_subagent - test_task_tool_no_tool_groups_passes_none - test_task_tool_runtime_none_passes_groups_none --- .../deerflow/agents/lead_agent/agent.py | 1 + .../deerflow/tools/builtins/task_tool.py | 6 +- backend/tests/test_task_tool_core_logic.py | 130 +++++++++++++++++- 3 files changed, 134 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 ffacd7481a..de3ff67664 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -332,6 +332,7 @@ def make_lead_agent(config: RunnableConfig): "reasoning_effort": reasoning_effort, "is_plan_mode": is_plan_mode, "subagent_enabled": subagent_enabled, + "tool_groups": agent_config.tool_groups if agent_config 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 6004999dc4..437fb37acc 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -88,6 +88,7 @@ async def task_tool( thread_id = None parent_model = None trace_id = None + metadata: dict = {} if runtime is not None: sandbox_state = runtime.state.get("sandbox") @@ -107,8 +108,11 @@ async def task_tool( # Lazy import to avoid circular dependency from deerflow.tools import get_available_tools + # Inherit parent agent's tool_groups so subagents respect the same restrictions + parent_tool_groups = metadata.get("tool_groups") + # Subagents should not have subagent tools enabled (prevent recursive nesting) - tools = get_available_tools(model_name=parent_model, subagent_enabled=False) + tools = get_available_tools(model_name=parent_model, groups=parent_tool_groups, subagent_enabled=False) # Create executor executor = SubagentExecutor( diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index 0671872813..5251c69ed0 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -167,14 +167,140 @@ def execute_async(self, prompt, task_id=None): assert captured["executor_kwargs"]["config"].max_turns == 7 assert "Skills Appendix" in captured["executor_kwargs"]["config"].system_prompt - get_available_tools.assert_called_once_with(model_name="ark-model", subagent_enabled=False) + get_available_tools.assert_called_once_with(model_name="ark-model", groups=None, subagent_enabled=False) event_types = [e["type"] for e in events] assert event_types == ["task_started", "task_running", "task_running", "task_completed"] assert events[-1]["result"] == "all done" -def test_task_tool_returns_failed_message(monkeypatch): +def test_task_tool_propagates_tool_groups_to_subagent(monkeypatch): + """Verify tool_groups from parent metadata are passed to get_available_tools(groups=...).""" + config = _make_subagent_config() + parent_tool_groups = ["file:read", "file:write", "bash"] + runtime = SimpleNamespace( + state={ + "sandbox": {"sandbox_id": "local"}, + "thread_data": {"workspace_path": "/tmp/workspace"}, + }, + context={"thread_id": "thread-1"}, + config={"metadata": {"model_name": "ark-model", "trace_id": "trace-1", "tool_groups": parent_tool_groups}}, + ) + events = [] + get_available_tools = MagicMock(return_value=["tool-a"]) + + class DummyExecutor: + def __init__(self, **kwargs): + pass + + 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_skills_prompt_section", lambda: "") + 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", get_available_tools) + + output = _run_task_tool( + runtime=runtime, + description="执行任务", + prompt="file work only", + subagent_type="general-purpose", + tool_call_id="tc-groups", + ) + + assert output == "Task Succeeded. Result: done" + # The key assertion: groups should be propagated from parent metadata + get_available_tools.assert_called_once_with(model_name="ark-model", groups=parent_tool_groups, subagent_enabled=False) + + +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() + # Default _make_runtime() has no tool_groups in metadata + runtime = _make_runtime() + events = [] + get_available_tools = MagicMock(return_value=[]) + + class DummyExecutor: + def __init__(self, **kwargs): + pass + + 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_skills_prompt_section", lambda: "") + monkeypatch.setattr( + task_tool_module, + "get_background_task_result", + lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="ok"), + ) + 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", get_available_tools) + + output = _run_task_tool( + runtime=runtime, + description="执行任务", + prompt="normal work", + subagent_type="general-purpose", + tool_call_id="tc-no-groups", + ) + + assert output == "Task Succeeded. Result: ok" + # No tool_groups in metadata → groups=None (default behavior preserved) + get_available_tools.assert_called_once_with(model_name="ark-model", groups=None, subagent_enabled=False) + + +def test_task_tool_runtime_none_passes_groups_none(monkeypatch): + """Verify that when runtime is None, groups=None is passed (e.g., unknown subagent path exits early, but tools still load correctly).""" + config = _make_subagent_config() + events = [] + get_available_tools = MagicMock(return_value=[]) + + class DummyExecutor: + def __init__(self, **kwargs): + pass + + 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_skills_prompt_section", lambda: "") + monkeypatch.setattr( + task_tool_module, + "get_background_task_result", + lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="ok"), + ) + 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", get_available_tools) + + output = _run_task_tool( + runtime=None, + description="执行任务", + prompt="no runtime", + subagent_type="general-purpose", + tool_call_id="tc-no-runtime", + ) + + assert output == "Task Succeeded. Result: ok" + # runtime is None → metadata is empty dict → groups=None + get_available_tools.assert_called_once_with(model_name=None, groups=None, subagent_enabled=False) + config = _make_subagent_config() events = [] From 5656f9079255c7fee6b28003000f90c00a277395 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:22:40 +0800 Subject: [PATCH 09/55] chore(deps-dev): bump pytest from 9.0.2 to 9.0.3 in /backend (#2349) Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/pyproject.toml | 2 +- backend/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8023fd42ea..2b2e43baa5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ ] [dependency-groups] -dev = ["pytest>=8.0.0", "ruff>=0.14.11"] +dev = ["pytest>=9.0.3", "ruff>=0.14.11"] [tool.uv.workspace] members = ["packages/harness"] diff --git a/backend/uv.lock b/backend/uv.lock index 3aa4bc7832..d6544fb831 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -708,7 +708,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "ruff", specifier = ">=0.14.11" }, ] @@ -3099,7 +3099,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -3108,9 +3108,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { 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]] From 80e210f5bb2f346d8e8136aef4694d9dadea48b0 Mon Sep 17 00:00:00 2001 From: Hinotobi Date: Sat, 18 Apr 2026 22:47:42 +0800 Subject: [PATCH 10/55] [security] fix(uploads): require explicit opt-in for host-side document conversion (#2332) * fix: disable host-side upload conversion by default * fix: address PR review comments on upload conversion gate --- backend/app/gateway/routers/uploads.py | 28 +++++++- backend/docs/FILE_UPLOAD.md | 9 ++- .../harness/deerflow/utils/file_conversion.py | 26 +++++--- backend/tests/test_file_conversion.py | 25 ++++++- backend/tests/test_uploads_router.py | 66 +++++++++++++++++++ config.example.yaml | 8 ++- 6 files changed, 144 insertions(+), 18 deletions(-) diff --git a/backend/app/gateway/routers/uploads.py b/backend/app/gateway/routers/uploads.py index d03639c742..6f8be52a14 100644 --- a/backend/app/gateway/routers/uploads.py +++ b/backend/app/gateway/routers/uploads.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, File, HTTPException, UploadFile from pydantic import BaseModel +from deerflow.config.app_config import get_app_config from deerflow.config.paths import get_paths from deerflow.sandbox.sandbox_provider import SandboxProvider, get_sandbox_provider from deerflow.uploads.manager import ( @@ -57,6 +58,30 @@ def _uses_thread_data_mounts(sandbox_provider: SandboxProvider) -> bool: return bool(getattr(sandbox_provider, "uses_thread_data_mounts", False)) +def _get_uploads_config_value(key: str, default: object) -> object: + """Read a value from the uploads config, supporting dict and attribute access.""" + cfg = get_app_config() + uploads_cfg = getattr(cfg, "uploads", None) + if isinstance(uploads_cfg, dict): + return uploads_cfg.get(key, default) + return getattr(uploads_cfg, key, default) + + +def _auto_convert_documents_enabled() -> bool: + """Return whether automatic host-side document conversion is enabled. + + The secure default is disabled unless an operator explicitly opts in via + uploads.auto_convert_documents in config.yaml. + """ + try: + raw = _get_uploads_config_value("auto_convert_documents", False) + if isinstance(raw, str): + return raw.strip().lower() in {"1", "true", "yes", "on"} + return bool(raw) + except Exception: + return False + + @router.post("", response_model=UploadResponse) async def upload_files( thread_id: str, @@ -79,6 +104,7 @@ async def upload_files( if sync_to_sandbox: sandbox_id = sandbox_provider.acquire(thread_id) sandbox = sandbox_provider.get(sandbox_id) + auto_convert_documents = _auto_convert_documents_enabled() for file in files: if not file.filename: @@ -112,7 +138,7 @@ async def upload_files( logger.info(f"Saved file: {safe_filename} ({len(content)} bytes) to {file_info['path']}") file_ext = file_path.suffix.lower() - if file_ext in CONVERTIBLE_EXTENSIONS: + if auto_convert_documents and file_ext in CONVERTIBLE_EXTENSIONS: md_path = await convert_file_to_markdown(file_path) if md_path: md_virtual_path = upload_virtual_path(md_path.name) diff --git a/backend/docs/FILE_UPLOAD.md b/backend/docs/FILE_UPLOAD.md index f19a9d7a5a..bf6962e4f5 100644 --- a/backend/docs/FILE_UPLOAD.md +++ b/backend/docs/FILE_UPLOAD.md @@ -2,12 +2,12 @@ ## 概述 -DeerFlow 后端提供了完整的文件上传功能,支持多文件上传,并自动将 Office 文档和 PDF 转换为 Markdown 格式。 +DeerFlow 后端提供了完整的文件上传功能,支持多文件上传,并可选地将 Office 文档和 PDF 转换为 Markdown 格式。 ## 功能特性 - ✅ 支持多文件同时上传 -- ✅ 自动转换文档为 Markdown(PDF、PPT、Excel、Word) +- ✅ 可选地转换文档为 Markdown(PDF、PPT、Excel、Word) - ✅ 文件存储在线程隔离的目录中 - ✅ Agent 自动感知已上传的文件 - ✅ 支持文件列表查询和删除 @@ -86,7 +86,7 @@ DELETE /api/threads/{thread_id}/uploads/{filename} ## 支持的文档格式 -以下格式会自动转换为 Markdown: +以下格式在显式启用 `uploads.auto_convert_documents: true` 时会自动转换为 Markdown: - PDF (`.pdf`) - PowerPoint (`.ppt`, `.pptx`) - Excel (`.xls`, `.xlsx`) @@ -94,6 +94,8 @@ DELETE /api/threads/{thread_id}/uploads/{filename} 转换后的 Markdown 文件会保存在同一目录下,文件名为原文件名 + `.md` 扩展名。 +默认情况下,自动转换是关闭的,以避免在网关主机上对不受信任的 Office/PDF 上传执行解析。只有在受信任部署中明确接受此风险时,才应将 `uploads.auto_convert_documents` 设置为 `true`。 + ## Agent 集成 ### 自动文件列举 @@ -207,6 +209,7 @@ backend/.deer-flow/threads/ - 最大文件大小:100MB(可在 nginx.conf 中配置 `client_max_body_size`) - 文件名安全性:系统会自动验证文件路径,防止目录遍历攻击 - 线程隔离:每个线程的上传文件相互隔离,无法跨线程访问 +- 自动文档转换默认关闭;如需启用,需在 `config.yaml` 中显式设置 `uploads.auto_convert_documents: true` ## 技术实现 diff --git a/backend/packages/harness/deerflow/utils/file_conversion.py b/backend/packages/harness/deerflow/utils/file_conversion.py index 68755b6754..f51b47caa5 100644 --- a/backend/packages/harness/deerflow/utils/file_conversion.py +++ b/backend/packages/harness/deerflow/utils/file_conversion.py @@ -19,6 +19,8 @@ import re from pathlib import Path +from deerflow.config.app_config import get_app_config + logger = logging.getLogger(__name__) # File extensions that should be converted to markdown @@ -286,6 +288,15 @@ def extract_outline(md_path: Path) -> list[dict]: return outline +def _get_uploads_config_value(key: str, default: object) -> object: + """Read a value from the uploads config, supporting dict and attribute access.""" + cfg = get_app_config() + uploads_cfg = getattr(cfg, "uploads", None) + if isinstance(uploads_cfg, dict): + return uploads_cfg.get(key, default) + return getattr(uploads_cfg, key, default) + + def _get_pdf_converter() -> str: """Read pdf_converter setting from app config, defaulting to 'auto'. @@ -294,16 +305,11 @@ def _get_pdf_converter() -> str: fall through to unexpected behaviour. """ try: - from deerflow.config.app_config import get_app_config - - cfg = get_app_config() - uploads_cfg = getattr(cfg, "uploads", None) - if uploads_cfg is not None: - raw = str(getattr(uploads_cfg, "pdf_converter", "auto")).strip().lower() - if raw not in _ALLOWED_PDF_CONVERTERS: - logger.warning("Invalid pdf_converter value %r; falling back to 'auto'", raw) - return "auto" - return raw + raw = str(_get_uploads_config_value("pdf_converter", "auto")).strip().lower() + if raw not in _ALLOWED_PDF_CONVERTERS: + logger.warning("Invalid pdf_converter value %r; falling back to 'auto'", raw) + return "auto" + return raw except Exception: pass return "auto" diff --git a/backend/tests/test_file_conversion.py b/backend/tests/test_file_conversion.py index 42abd3b4a8..4c2189bc22 100644 --- a/backend/tests/test_file_conversion.py +++ b/backend/tests/test_file_conversion.py @@ -12,6 +12,7 @@ _MIN_CHARS_PER_PAGE, MAX_OUTLINE_ENTRIES, _do_convert, + _get_pdf_converter, _pymupdf_output_too_sparse, convert_file_to_markdown, extract_outline, @@ -214,9 +215,27 @@ def test_pdf_auto_falls_back_when_pymupdf4llm_not_installed(self, tmp_path): assert result == "MarkItDown fallback" -# --------------------------------------------------------------------------- -# convert_file_to_markdown — async + file writing -# --------------------------------------------------------------------------- +class TestGetPdfConverter: + def test_reads_dict_backed_uploads_config(self): + cfg = MagicMock() + cfg.uploads = {"pdf_converter": "markitdown"} + + with patch("deerflow.utils.file_conversion.get_app_config", return_value=cfg): + assert _get_pdf_converter() == "markitdown" + + def test_reads_attribute_backed_uploads_config(self): + cfg = MagicMock() + cfg.uploads = MagicMock(pdf_converter="pymupdf4llm") + + with patch("deerflow.utils.file_conversion.get_app_config", return_value=cfg): + assert _get_pdf_converter() == "pymupdf4llm" + + def test_invalid_value_falls_back_to_auto(self): + cfg = MagicMock() + cfg.uploads = {"pdf_converter": "not-a-real-converter"} + + with patch("deerflow.utils.file_conversion.get_app_config", return_value=cfg): + assert _get_pdf_converter() == "auto" class TestConvertFileToMarkdown: diff --git a/backend/tests/test_uploads_router.py b/backend/tests/test_uploads_router.py index cc7090a010..f305b998fd 100644 --- a/backend/tests/test_uploads_router.py +++ b/backend/tests/test_uploads_router.py @@ -56,6 +56,34 @@ def test_upload_files_skips_acquire_when_thread_data_is_mounted(tmp_path): provider.get.assert_not_called() +def test_upload_files_does_not_auto_convert_documents_by_default(tmp_path): + thread_uploads_dir = tmp_path / "uploads" + thread_uploads_dir.mkdir(parents=True) + + provider = MagicMock() + provider.uses_thread_data_mounts = True + provider.acquire.return_value = "local" + sandbox = MagicMock() + provider.get.return_value = sandbox + + with ( + patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir), + patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir), + patch.object(uploads, "get_sandbox_provider", return_value=provider), + patch.object(uploads, "_auto_convert_documents_enabled", return_value=False), + patch.object(uploads, "convert_file_to_markdown", AsyncMock()) as convert_mock, + ): + file = UploadFile(filename="report.pdf", file=BytesIO(b"pdf-bytes")) + result = asyncio.run(uploads.upload_files("thread-local", files=[file])) + + assert result.success is True + assert len(result.files) == 1 + assert result.files[0]["filename"] == "report.pdf" + assert "markdown_file" not in result.files[0] + convert_mock.assert_not_called() + assert not (thread_uploads_dir / "report.md").exists() + + def test_upload_files_syncs_non_local_sandbox_and_marks_markdown_file(tmp_path): thread_uploads_dir = tmp_path / "uploads" thread_uploads_dir.mkdir(parents=True) @@ -75,6 +103,7 @@ async def fake_convert(file_path: Path) -> Path: patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir), patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir), patch.object(uploads, "get_sandbox_provider", return_value=provider), + patch.object(uploads, "_auto_convert_documents_enabled", return_value=True), patch.object(uploads, "convert_file_to_markdown", AsyncMock(side_effect=fake_convert)), ): file = UploadFile(filename="report.pdf", file=BytesIO(b"pdf-bytes")) @@ -112,6 +141,7 @@ async def fake_convert(file_path: Path) -> Path: patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir), patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir), patch.object(uploads, "get_sandbox_provider", return_value=provider), + patch.object(uploads, "_auto_convert_documents_enabled", return_value=True), patch.object(uploads, "convert_file_to_markdown", AsyncMock(side_effect=fake_convert)), patch.object(uploads, "_make_file_sandbox_writable") as make_writable, ): @@ -218,3 +248,39 @@ def test_delete_uploaded_file_removes_generated_markdown_companion(tmp_path): assert result == {"success": True, "message": "Deleted report.pdf"} assert not (thread_uploads_dir / "report.pdf").exists() assert not (thread_uploads_dir / "report.md").exists() + + +def test_auto_convert_documents_enabled_defaults_to_false_on_config_errors(): + with patch.object(uploads, "get_app_config", side_effect=RuntimeError("boom")): + assert uploads._auto_convert_documents_enabled() is False + + +def test_auto_convert_documents_enabled_reads_dict_backed_uploads_config(): + cfg = MagicMock() + cfg.uploads = {"auto_convert_documents": True} + + with patch.object(uploads, "get_app_config", return_value=cfg): + assert uploads._auto_convert_documents_enabled() is True + + +def test_auto_convert_documents_enabled_accepts_boolean_and_string_truthy_values(): + false_cfg = MagicMock() + false_cfg.uploads = MagicMock(auto_convert_documents=False) + + true_cfg = MagicMock() + true_cfg.uploads = MagicMock(auto_convert_documents=True) + + string_true_cfg = MagicMock() + string_true_cfg.uploads = MagicMock(auto_convert_documents="YES") + + string_false_cfg = MagicMock() + string_false_cfg.uploads = MagicMock(auto_convert_documents="false") + + with patch.object(uploads, "get_app_config", return_value=false_cfg): + assert uploads._auto_convert_documents_enabled() is False + with patch.object(uploads, "get_app_config", return_value=true_cfg): + assert uploads._auto_convert_documents_enabled() is True + with patch.object(uploads, "get_app_config", return_value=string_true_cfg): + assert uploads._auto_convert_documents_enabled() is True + with patch.object(uploads, "get_app_config", return_value=string_false_cfg): + assert uploads._auto_convert_documents_enabled() is False diff --git a/config.example.yaml b/config.example.yaml index dbaa0e1608..fc3d9c8c1c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -480,7 +480,13 @@ tool_search: # Option 1: Local Sandbox (Default) # Executes commands directly on the host machine uploads: - # PDF-to-Markdown converter used when a PDF is uploaded. + # Automatic Office/PDF conversion runs on the backend host before sandbox + # isolation applies. Keep this disabled unless uploads come from a fully + # trusted source and you intentionally accept host-side parser risk. + auto_convert_documents: false + # Controls which PDF-to-Markdown converter is used whenever PDF conversion + # runs. Automatic upload conversion is gated separately by + # auto_convert_documents. # auto — prefer pymupdf4llm when installed; fall back to MarkItDown for # image-based or encrypted PDFs (recommended default). # pymupdf4llm — always use pymupdf4llm (must be installed: uv add pymupdf4llm). From 7c87dc5bcaddefb9bad7448cb8580da303115cdd Mon Sep 17 00:00:00 2001 From: Xun Date: Sun, 19 Apr 2026 19:27:34 +0800 Subject: [PATCH 11/55] fix(reasoning): prevent LLM-hallucinated HTML tags from rendering as DOM elements (#2321) * fix * add test * fix --- frontend/src/components/ai-elements/reasoning.tsx | 3 ++- frontend/src/core/streamdown/plugins.ts | 9 +++++++++ frontend/tests/unit/core/streamdown/plugins.test.ts | 13 +++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/unit/core/streamdown/plugins.test.ts diff --git a/frontend/src/components/ai-elements/reasoning.tsx b/frontend/src/components/ai-elements/reasoning.tsx index 8c9d5b206d..5f1d2321e7 100644 --- a/frontend/src/components/ai-elements/reasoning.tsx +++ b/frontend/src/components/ai-elements/reasoning.tsx @@ -11,6 +11,7 @@ import { BrainIcon, ChevronDownIcon } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react"; import { Streamdown } from "streamdown"; +import { reasoningPlugins } from "@/core/streamdown/plugins"; import { Shimmer } from "./shimmer"; type ReasoningContextValue = { @@ -177,7 +178,7 @@ export const ReasoningContent = memo( )} {...props} > - {children} + {children} ), ); diff --git a/frontend/src/core/streamdown/plugins.ts b/frontend/src/core/streamdown/plugins.ts index e9214031b6..d576252c55 100644 --- a/frontend/src/core/streamdown/plugins.ts +++ b/frontend/src/core/streamdown/plugins.ts @@ -28,6 +28,15 @@ export const streamdownPluginsWithWordAnimation = { ] as StreamdownProps["rehypePlugins"], }; +// Plugins for reasoning/thinking content — derived from streamdownPlugins but without rehypeRaw, +// to prevent LLM-hallucinated HTML tags (e.g. ) from being rendered as DOM elements. +export const reasoningPlugins = { + remarkPlugins: streamdownPlugins.remarkPlugins, + rehypePlugins: streamdownPlugins.rehypePlugins?.filter( + (p) => p !== rehypeRaw, + ) as StreamdownProps["rehypePlugins"], +}; + // Plugins for human messages - no autolink to prevent URL bleeding into adjacent text export const humanMessagePlugins = { remarkPlugins: [ diff --git a/frontend/tests/unit/core/streamdown/plugins.test.ts b/frontend/tests/unit/core/streamdown/plugins.test.ts new file mode 100644 index 0000000000..efe0ab7190 --- /dev/null +++ b/frontend/tests/unit/core/streamdown/plugins.test.ts @@ -0,0 +1,13 @@ +import rehypeRaw from "rehype-raw"; +import { expect, test } from "vitest"; + +import { reasoningPlugins, streamdownPlugins } from "@/core/streamdown/plugins"; + +test("streamdownPlugins includes rehypeRaw", () => { + expect(streamdownPlugins.rehypePlugins).toContain(rehypeRaw); +}); + +test("reasoningPlugins does not include rehypeRaw", () => { + const flat = reasoningPlugins.rehypePlugins?.flat(); + expect(flat).not.toContain(rehypeRaw); +}); From f514e35a36f30e3608719e59a0272682df5f1f44 Mon Sep 17 00:00:00 2001 From: Nan Gao <88081804+ggnnggez@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:00:58 +0200 Subject: [PATCH 12/55] fix(backend): make clarification messages idempotent (#2350) (#2351) --- .../middlewares/clarification_middleware.py | 9 +++ .../tests/test_clarification_middleware.py | 59 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py index 9e0c2b2591..385508f0fc 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py @@ -3,6 +3,7 @@ import json import logging from collections.abc import Callable +from hashlib import sha256 from typing import override from langchain.agents import AgentState @@ -36,6 +37,13 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]): state_schema = ClarificationMiddlewareState + def _stable_message_id(self, tool_call_id: str, formatted_message: str) -> str: + """Build a deterministic message ID so retried clarification calls replace, not append.""" + if tool_call_id: + return f"clarification:{tool_call_id}" + digest = sha256(formatted_message.encode("utf-8")).hexdigest()[:16] + return f"clarification:{digest}" + def _is_chinese(self, text: str) -> bool: """Check if text contains Chinese characters. @@ -131,6 +139,7 @@ def _handle_clarification(self, request: ToolCallRequest) -> Command: # Create a ToolMessage with the formatted question # This will be added to the message history tool_message = ToolMessage( + id=self._stable_message_id(tool_call_id, formatted_message), content=formatted_message, tool_call_id=tool_call_id, name="ask_clarification", diff --git a/backend/tests/test_clarification_middleware.py b/backend/tests/test_clarification_middleware.py index 9a8118996f..565b09beb8 100644 --- a/backend/tests/test_clarification_middleware.py +++ b/backend/tests/test_clarification_middleware.py @@ -1,8 +1,10 @@ """Tests for ClarificationMiddleware, focusing on options type coercion.""" import json +from types import SimpleNamespace import pytest +from langgraph.graph.message import add_messages from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware @@ -118,3 +120,60 @@ def test_json_string_with_mixed_types(self, middleware): assert "2. 2" in result assert "3. True" in result assert "4. None" in result + + +class TestClarificationCommandIdempotency: + """Clarification tool-call retries should not duplicate messages in state.""" + + def test_repeated_tool_call_uses_stable_message_id(self, middleware): + request = SimpleNamespace( + tool_call={ + "name": "ask_clarification", + "id": "call-clarify-1", + "args": { + "question": "Which environment should I use?", + "clarification_type": "approach_choice", + "options": ["dev", "prod"], + }, + } + ) + + first = middleware.wrap_tool_call(request, lambda _req: pytest.fail("handler should not be called")) + second = middleware.wrap_tool_call(request, lambda _req: pytest.fail("handler should not be called")) + + first_message = first.update["messages"][0] + second_message = second.update["messages"][0] + + assert first_message.id == "clarification:call-clarify-1" + assert second_message.id == first_message.id + assert second_message.tool_call_id == first_message.tool_call_id + + merged = add_messages(add_messages([], [first_message]), [second_message]) + + assert len(merged) == 1 + assert merged[0].id == "clarification:call-clarify-1" + assert merged[0].content == first_message.content + + def test_missing_tool_call_id_still_gets_stable_message_id(self, middleware): + request = SimpleNamespace( + tool_call={ + "name": "ask_clarification", + "args": { + "question": "Which environment should I use?", + "clarification_type": "missing_info", + }, + } + ) + + first = middleware.wrap_tool_call(request, lambda _req: pytest.fail("handler should not be called")) + second = middleware.wrap_tool_call(request, lambda _req: pytest.fail("handler should not be called")) + + first_message = first.update["messages"][0] + second_message = second.update["messages"][0] + + assert first_message.id.startswith("clarification:") + assert second_message.id == first_message.id + + merged = add_messages(add_messages([], [first_message]), [second_message]) + + assert len(merged) == 1 From a62ca5dd47e3bc9b3a98be699133c9c96e20bf27 Mon Sep 17 00:00:00 2001 From: Xun Date: Sun, 19 Apr 2026 22:30:22 +0800 Subject: [PATCH 13/55] fix: Catch httpx.ReadError in the error handling (#2309) * fix: Catch httpx.ReadError in the error handling * fix --- .../llm_error_handling_middleware.py | 2 + .../test_llm_error_handling_middleware.py | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py index 0c20c72869..4ef9f5e7d4 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py @@ -160,6 +160,8 @@ def _classify_error(self, exc: BaseException) -> tuple[bool, str]: "APITimeoutError", "APIConnectionError", "InternalServerError", + "ReadError", # httpx.ReadError: connection dropped mid-stream + "RemoteProtocolError", # httpx: server closed connection unexpectedly }: return True, "transient" if status_code in _RETRIABLE_STATUS_CODES: diff --git a/backend/tests/test_llm_error_handling_middleware.py b/backend/tests/test_llm_error_handling_middleware.py index 13b730aa30..62ca243fd2 100644 --- a/backend/tests/test_llm_error_handling_middleware.py +++ b/backend/tests/test_llm_error_handling_middleware.py @@ -297,6 +297,82 @@ def test_circuit_breaker_does_not_trip_on_non_retriable_errors(monkeypatch: pyte assert middleware._check_circuit() is False +# ---------- ReadError / RemoteProtocolError retriable classification ---------- + + +class _ReadError(Exception): + """Local stand-in for httpx.ReadError — same class name, no httpx dependency.""" + + +class _RemoteProtocolError(Exception): + """Local stand-in for httpx.RemoteProtocolError — same class name, no httpx dependency.""" + + +_ReadError.__name__ = "ReadError" +_RemoteProtocolError.__name__ = "RemoteProtocolError" + + +def test_classify_error_read_error_is_retriable() -> None: + middleware = _build_middleware() + exc = _ReadError("Connection dropped mid-stream") + exc.__class__.__name__ = "ReadError" + retriable, reason = middleware._classify_error(exc) + assert retriable is True + assert reason == "transient" + + +def test_classify_error_remote_protocol_error_is_retriable() -> None: + middleware = _build_middleware() + exc = _RemoteProtocolError("Server closed connection unexpectedly") + exc.__class__.__name__ = "RemoteProtocolError" + retriable, reason = middleware._classify_error(exc) + assert retriable is True + assert reason == "transient" + + +def test_sync_read_error_triggers_retry_loop(monkeypatch: pytest.MonkeyPatch) -> None: + middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=10, retry_cap_delay_ms=10) + attempts = 0 + waits: list[float] = [] + monkeypatch.setattr("time.sleep", lambda d: waits.append(d)) + + def handler(_request) -> AIMessage: + nonlocal attempts + attempts += 1 + raise _ReadError("Connection dropped mid-stream") + + result = middleware.wrap_model_call(SimpleNamespace(), handler) + + assert isinstance(result, AIMessage) + assert "temporarily unavailable" in result.content + assert attempts == 3 # exhausted all retries + assert len(waits) == 2 # slept between attempts 1→2 and 2→3 + + +@pytest.mark.anyio +async def test_async_read_error_triggers_retry_loop(monkeypatch: pytest.MonkeyPatch) -> None: + middleware = _build_middleware(retry_max_attempts=3, retry_base_delay_ms=10, retry_cap_delay_ms=10) + attempts = 0 + waits: list[float] = [] + + async def fake_sleep(d: float) -> None: + waits.append(d) + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + + async def handler(_request) -> AIMessage: + nonlocal attempts + attempts += 1 + raise _ReadError("Connection dropped mid-stream") + + result = await middleware.awrap_model_call(SimpleNamespace(), handler) + + assert isinstance(result, AIMessage) + assert "temporarily unavailable" in result.content + assert attempts == 3 # exhausted all retries + assert len(waits) == 2 # slept between attempts 1→2 and 2→3 + + @pytest.mark.anyio async def test_async_circuit_breaker_trips_and_recovers(monkeypatch: pytest.MonkeyPatch) -> None: """Verify async version of circuit breaker correctly handles state transitions.""" From 05f1da03e5a5eaa7033f98bc02f28fb62930f7f7 Mon Sep 17 00:00:00 2001 From: YYMa Date: Sun, 19 Apr 2026 22:41:00 +0800 Subject: [PATCH 14/55] fix(script): use portable locale for langgraph log pipeline on macOS (#2361) --- scripts/serve.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/serve.sh b/scripts/serve.sh index 8c1aaf0f0a..0d40ebe76d 100755 --- a/scripts/serve.sh +++ b/scripts/serve.sh @@ -279,7 +279,7 @@ if ! $GATEWAY_MODE; then LANGGRAPH_ALLOW_BLOCKING_FLAG="--allow-blocking" fi run_service "LangGraph" \ - "cd backend && NO_COLOR=1 CLICOLOR=0 CLICOLOR_FORCE=0 PY_COLORS=0 TERM=dumb uv run langgraph dev --no-browser $LANGGRAPH_ALLOW_BLOCKING_FLAG --n-jobs-per-worker $LANGGRAPH_JOBS_PER_WORKER --server-log-level $LANGGRAPH_LOG_LEVEL $LANGGRAPH_EXTRA_FLAGS 2>&1 | perl -pe 's/\e\[[0-9;]*[[:alpha:]]//g' > ../logs/langgraph.log" \ + "cd backend && NO_COLOR=1 CLICOLOR=0 CLICOLOR_FORCE=0 PY_COLORS=0 TERM=dumb uv run langgraph dev --no-browser $LANGGRAPH_ALLOW_BLOCKING_FLAG --n-jobs-per-worker $LANGGRAPH_JOBS_PER_WORKER --server-log-level $LANGGRAPH_LOG_LEVEL $LANGGRAPH_EXTRA_FLAGS 2>&1 | LC_ALL=C LC_CTYPE=C LANG=C perl -pe 's/\e\[[0-9;]*[[:alpha:]]//g' > ../logs/langgraph.log" \ 2024 60 else echo "⏩ Skipping LangGraph (Gateway mode — runtime embedded in Gateway)" From c99865f53dc7d82a888a326463b146625d128ae2 Mon Sep 17 00:00:00 2001 From: Admire <64821731+LittleChenLiya@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:42:55 +0800 Subject: [PATCH 15/55] fix(token-usage): enable stream usage for openai-compatible models (#2217) * fix(token-usage): enable stream usage for openai-compatible models * fix(token-usage): narrow stream_usage default to ChatOpenAI --- .../harness/deerflow/models/factory.py | 18 ++++ backend/tests/test_model_factory.py | 93 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/backend/packages/harness/deerflow/models/factory.py b/backend/packages/harness/deerflow/models/factory.py index a47f46d73d..bd2828e947 100644 --- a/backend/packages/harness/deerflow/models/factory.py +++ b/backend/packages/harness/deerflow/models/factory.py @@ -30,6 +30,22 @@ def _vllm_disable_chat_template_kwargs(chat_template_kwargs: dict) -> dict: return disable_kwargs +def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_config: dict) -> None: + """Enable stream usage for OpenAI-compatible models unless explicitly configured. + + LangChain only auto-enables ``stream_usage`` for OpenAI models when no custom + base URL or client is configured. DeerFlow frequently uses OpenAI-compatible + gateways, so token usage tracking would otherwise stay empty and the + TokenUsageMiddleware would have nothing to log. + """ + if model_use_path != "langchain_openai:ChatOpenAI": + return + if "stream_usage" in model_settings_from_config: + return + if "base_url" in model_settings_from_config or "openai_api_base" in model_settings_from_config: + model_settings_from_config["stream_usage"] = True + + def create_chat_model(name: str | None = None, thinking_enabled: bool = False, **kwargs) -> BaseChatModel: """Create a chat model instance from the config. @@ -97,6 +113,8 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, * kwargs.pop("reasoning_effort", None) model_settings_from_config.pop("reasoning_effort", None) + _enable_stream_usage_by_default(model_config.use, model_settings_from_config) + # For Codex Responses API models: map thinking mode to reasoning_effort from deerflow.models.openai_codex_provider import CodexChatModel diff --git a/backend/tests/test_model_factory.py b/backend/tests/test_model_factory.py index 9bb6915b0c..b7badb9913 100644 --- a/backend/tests/test_model_factory.py +++ b/backend/tests/test_model_factory.py @@ -597,6 +597,99 @@ def __init__(self, **kwargs): assert captured.get("api_key") == "test-key" assert captured.get("temperature") == 1.0 assert captured.get("max_tokens") == 4096 + assert captured.get("stream_usage") is True + + +def test_openai_compatible_provider_respects_explicit_stream_usage(monkeypatch): + """Explicit stream_usage should not be overwritten by the factory default.""" + model = ModelConfig( + name="minimax-m2.5", + display_name="MiniMax M2.5", + description=None, + use="langchain_openai:ChatOpenAI", + model="MiniMax-M2.5", + base_url="https://api.minimax.io/v1", + api_key="test-key", + stream_usage=False, + supports_vision=True, + supports_thinking=False, + ) + cfg = _make_app_config([model]) + _patch_factory(monkeypatch, cfg) + + captured: dict = {} + + class CapturingModel(FakeChatModel): + def __init__(self, **kwargs): + captured.update(kwargs) + BaseChatModel.__init__(self, **kwargs) + + monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) + + factory_module.create_chat_model(name="minimax-m2.5") + + assert captured.get("stream_usage") is False + + +def test_openai_compatible_provider_enables_stream_usage_for_openai_api_base(monkeypatch): + """openai_api_base should trigger stream_usage default for ChatOpenAI.""" + model = ModelConfig( + name="openai-compatible", + display_name="OpenAI-Compatible", + description=None, + use="langchain_openai:ChatOpenAI", + model="example-model", + openai_api_base="https://example.com/v1", + api_key="test-key", + supports_vision=False, + supports_thinking=False, + ) + cfg = _make_app_config([model]) + _patch_factory(monkeypatch, cfg) + + captured: dict = {} + + class CapturingModel(FakeChatModel): + def __init__(self, **kwargs): + captured.update(kwargs) + BaseChatModel.__init__(self, **kwargs) + + monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) + + factory_module.create_chat_model(name="openai-compatible") + + assert captured.get("openai_api_base") == "https://example.com/v1" + assert captured.get("stream_usage") is True + + +def test_non_openai_provider_does_not_receive_stream_usage_default(monkeypatch): + """Non-OpenAI providers with base_url should not receive stream_usage by default.""" + model = ModelConfig( + name="ollama-local", + display_name="Ollama Local", + description=None, + use="langchain_ollama:ChatOllama", + model="qwen2.5", + base_url="http://127.0.0.1:11434", + supports_vision=False, + supports_thinking=False, + ) + cfg = _make_app_config([model]) + _patch_factory(monkeypatch, cfg) + + captured: dict = {} + + class CapturingModel(FakeChatModel): + def __init__(self, **kwargs): + captured.update(kwargs) + BaseChatModel.__init__(self, **kwargs) + + monkeypatch.setattr(factory_module, "resolve_class", lambda path, base: CapturingModel) + + factory_module.create_chat_model(name="ollama-local") + + assert captured.get("base_url") == "http://127.0.0.1:11434" + assert "stream_usage" not in captured def test_openai_compatible_provider_multiple_models(monkeypatch): From 4be857f64bf713cf52a8944fb2882aa5126f2c54 Mon Sep 17 00:00:00 2001 From: KiteEater <145987840+Kiteeater@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:00:05 +0800 Subject: [PATCH 16/55] fix: use Apple Container image pull syntax (#2366) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5881e9f602..b21d860ae8 100644 --- a/Makefile +++ b/Makefile @@ -99,7 +99,7 @@ setup-sandbox: echo ""; \ if command -v container >/dev/null 2>&1 && [ "$$(uname)" = "Darwin" ]; then \ echo "Detected Apple Container on macOS, pulling image..."; \ - container pull "$$IMAGE" || echo "⚠ Apple Container pull failed, will try Docker"; \ + container image pull "$$IMAGE" || echo "⚠ Apple Container pull failed, will try Docker"; \ fi; \ if command -v docker >/dev/null 2>&1; then \ echo "Pulling image using Docker..."; \ From f2013f47aaf01d6b976fbc17af1473306334888b Mon Sep 17 00:00:00 2001 From: Eilen Shin <136898293+Eilen6316@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:36:16 +0800 Subject: [PATCH 17/55] fix command palette hydration mismatch (#2301) * fix command palette hydration mismatch * style: format command dialog description --- frontend/src/components/ui/command.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/ui/command.tsx b/frontend/src/components/ui/command.tsx index ee7450af2f..03b8b00d35 100644 --- a/frontend/src/components/ui/command.tsx +++ b/frontend/src/components/ui/command.tsx @@ -9,7 +9,6 @@ import { Dialog, DialogContent, DialogDescription, - DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -44,14 +43,12 @@ function CommandDialog({ }) { return ( - - {title} - {description} - + {title} + {description} {children} From fc94e90f6caed2a0198af9836314dc79111c9548 Mon Sep 17 00:00:00 2001 From: imhaoran <117565557+thresh111@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:17:30 +0800 Subject: [PATCH 18/55] =?UTF-8?q?fix(setup-agent):=20prevent=20data=20loss?= =?UTF-8?q?=20when=20setup=20fails=20on=20existing=20agen=E2=80=A6=20(#225?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(setup-agent): prevent data loss when setup fails on existing agent directory Record whether the agent directory pre-existed before mkdir, and only run shutil.rmtree cleanup when the directory was newly created during this call. Previously, any failure would delete the entire directory including pre-existing SOUL.md and config.yaml. * fix: address PR review — init variables before try, remove unused result * style: fix ruff I001 import block formatting in test file * style: add missing blank lines between top-level definitions in test file --- .../tools/builtins/setup_agent_tool.py | 6 +- backend/tests/test_setup_agent_tool.py | 87 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) 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 922ad7b683..a42f8bbef3 100644 --- a/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py @@ -27,11 +27,13 @@ def setup_agent( agent_name: str | None = runtime.context.get("agent_name") if runtime.context else None agent_dir = None + is_new_dir = False try: agent_name = validate_agent_name(agent_name) paths = get_paths() agent_dir = paths.agent_dir(agent_name) if agent_name else paths.base_dir + is_new_dir = not agent_dir.exists() agent_dir.mkdir(parents=True, exist_ok=True) if agent_name: @@ -58,8 +60,8 @@ def setup_agent( except Exception as e: import shutil - if agent_name and agent_dir is not None and agent_dir.exists(): - # Cleanup the custom agent directory only if it was created but an error occurred during setup + if agent_name and is_new_dir and agent_dir is not None and agent_dir.exists(): + # Cleanup the custom agent directory only if it was newly created during this call shutil.rmtree(agent_dir) logger.error(f"[agent_creator] Failed to create agent '{agent_name}': {e}", exc_info=True) return Command(update={"messages": [ToolMessage(content=f"Error: {e}", tool_call_id=runtime.tool_call_id)]}) diff --git a/backend/tests/test_setup_agent_tool.py b/backend/tests/test_setup_agent_tool.py index 72ac03fb56..482fe13589 100644 --- a/backend/tests/test_setup_agent_tool.py +++ b/backend/tests/test_setup_agent_tool.py @@ -1,16 +1,48 @@ +"""Tests for setup_agent tool — validates agent name security and data loss prevention.""" + from __future__ import annotations from pathlib import Path from types import SimpleNamespace +from unittest.mock import MagicMock, patch from deerflow.tools.builtins.setup_agent_tool import setup_agent +# --- Helpers --- + class _DummyRuntime(SimpleNamespace): context: dict tool_call_id: str +def _make_runtime(agent_name: str | None = "test-agent") -> MagicMock: + runtime = MagicMock() + runtime.context = {"agent_name": agent_name} + runtime.tool_call_id = "call_1" + return runtime + + +def _make_paths_mock(tmp_path: Path): + paths = MagicMock() + paths.base_dir = tmp_path + paths.agent_dir = lambda name: tmp_path / "agents" / name + return paths + + +def _call_setup_agent(tmp_path: Path, soul: str, description: str, agent_name: str = "test-agent"): + """Call the underlying setup_agent function directly, bypassing langchain tool wrapper.""" + with patch("deerflow.tools.builtins.setup_agent_tool.get_paths", return_value=_make_paths_mock(tmp_path)): + return setup_agent.func( + soul=soul, + description=description, + runtime=_make_runtime(agent_name), + ) + + +# --- Agent name validation tests --- + + def test_setup_agent_rejects_invalid_agent_name_before_writing(tmp_path, monkeypatch): monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) outside_dir = tmp_path.parent / "outside-target" @@ -38,3 +70,58 @@ def test_setup_agent_rejects_absolute_agent_name_before_writing(tmp_path, monkey assert "Invalid agent name" in messages[0].content assert not (tmp_path / "agents").exists() assert not (Path(absolute_agent) / "SOUL.md").exists() + + +# --- Data loss prevention tests --- + + +class TestSetupAgentNoDataLoss: + """Ensure shutil.rmtree only removes directories created during the current call.""" + + def test_existing_agent_dir_preserved_on_failure(self, tmp_path: Path): + """If the agent directory already exists and setup fails, + the directory and its contents must NOT be deleted.""" + agent_dir = tmp_path / "agents" / "test-agent" + agent_dir.mkdir(parents=True) + old_soul = agent_dir / "SOUL.md" + old_soul.write_text("original soul content") + + with patch("deerflow.tools.builtins.setup_agent_tool.get_paths", return_value=_make_paths_mock(tmp_path)): + # Force soul_file.write_text to raise after directory already exists + with patch.object(Path, "write_text", side_effect=OSError("disk full")): + setup_agent.func( + soul="new soul", + description="desc", + runtime=_make_runtime(), + ) + + # Directory must still exist + assert agent_dir.exists(), "Pre-existing agent directory was deleted on failure" + # Original SOUL.md should still be on disk (not deleted by rmtree) + assert old_soul.exists(), "Pre-existing SOUL.md was deleted on failure" + + def test_new_agent_dir_cleaned_up_on_failure(self, tmp_path: Path): + """If the agent directory is newly created and setup fails, + the directory should be cleaned up.""" + agent_dir = tmp_path / "agents" / "test-agent" + assert not agent_dir.exists() + + with patch("deerflow.tools.builtins.setup_agent_tool.get_paths", return_value=_make_paths_mock(tmp_path)): + with patch("yaml.dump", side_effect=OSError("write error")): + setup_agent.func( + soul="new soul", + description="desc", + runtime=_make_runtime(), + ) + + # Newly created directory should be cleaned up + assert not agent_dir.exists(), "Newly created agent directory was not cleaned up on failure" + + def test_successful_setup_creates_files(self, tmp_path: Path): + """Happy path: setup_agent creates config.yaml and SOUL.md.""" + _call_setup_agent(tmp_path, soul="# My Agent", description="A test agent") + + agent_dir = tmp_path / "agents" / "test-agent" + assert agent_dir.exists() + assert (agent_dir / "SOUL.md").read_text() == "# My Agent" + assert (agent_dir / "config.yaml").exists() From 6dce26a52e1e4c2118b6222c3308fceddf254c37 Mon Sep 17 00:00:00 2001 From: Ansel <238927804+asong56@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:25:03 -0400 Subject: [PATCH 19/55] fix: resolve tool duplication and skill parser YAML inconsistencies (#1803) (#2107) * Refactor tests for SKILL.md parser Updated tests for SKILL.md parser to handle quoted names and descriptions correctly. Added new tests for parsing plain and single-quoted names, and ensured multi-line descriptions are processed properly. * Implement tool name validation and deduplication Add tool name mismatch warning and deduplication logic * Refactor skill file parsing and error handling * Add tests for tool name deduplication Added tests for tool name deduplication in get_available_tools(). Ensured that duplicates are not returned, the first occurrence is kept, and warnings are logged for skipped duplicates. * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Update minimal config to include tools list * Update test for nonexistent skill file Ensure the test for nonexistent files checks for None. * Refactor tool loading and add skill management support Refactor tool loading logic to include skill management tools based on configuration and clean up comments. * Enhance code comments for tool loading logic Added comments to clarify the purpose of various code sections related to tool loading and configuration. * Fix assertion for duplicate tool name warning * Fix indentation issues in tools.py * Fix the lint error of test_tool_deduplication * Fix the lint error of tools.py * Fix the lint error * Fix the lint error * make format --------- Co-authored-by: Willem Jiang Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../harness/deerflow/skills/parser.py | 117 +++------ .../packages/harness/deerflow/tools/tools.py | 35 ++- backend/tests/test_skills_parser.py | 232 +++++++++--------- backend/tests/test_tool_deduplication.py | 106 ++++++++ 4 files changed, 297 insertions(+), 193 deletions(-) create mode 100644 backend/tests/test_tool_deduplication.py diff --git a/backend/packages/harness/deerflow/skills/parser.py b/backend/packages/harness/deerflow/skills/parser.py index d2a3af67bc..63bcfef7cf 100644 --- a/backend/packages/harness/deerflow/skills/parser.py +++ b/backend/packages/harness/deerflow/skills/parser.py @@ -2,21 +2,24 @@ import re from pathlib import Path +import yaml + from .types import Skill logger = logging.getLogger(__name__) def parse_skill_file(skill_file: Path, category: str, relative_path: Path | None = None) -> Skill | None: - """ - Parse a SKILL.md file and extract metadata. + """Parse a SKILL.md file and extract metadata. Args: - skill_file: Path to the SKILL.md file - category: Category of the skill ('public' or 'custom') + skill_file: Path to the SKILL.md file. + category: Category of the skill ('public' or 'custom'). + relative_path: Relative path from the category root to the skill + directory. Defaults to the skill directory name when omitted. Returns: - Skill object if parsing succeeds, None otherwise + Skill object if parsing succeeds, None otherwise. """ if not skill_file.exists() or skill_file.name != "SKILL.md": return None @@ -24,90 +27,42 @@ def parse_skill_file(skill_file: Path, category: str, relative_path: Path | None try: content = skill_file.read_text(encoding="utf-8") - # Extract YAML front matter - # Pattern: ---\nkey: value\n--- + # Extract YAML front-matter block between leading ``---`` fences. front_matter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL) - if not front_matter_match: return None - front_matter = front_matter_match.group(1) - - # Parse YAML front matter with basic multiline string support - metadata = {} - lines = front_matter.split("\n") - current_key = None - current_value = [] - is_multiline = False - multiline_style = None - indent_level = None - - for line in lines: - if is_multiline: - if not line.strip(): - current_value.append("") - continue - - current_indent = len(line) - len(line.lstrip()) - - if indent_level is None: - if current_indent > 0: - indent_level = current_indent - current_value.append(line[indent_level:]) - continue - elif current_indent >= indent_level: - current_value.append(line[indent_level:]) - continue - - # If we reach here, it's either a new key or the end of multiline - if current_key and is_multiline: - if multiline_style == "|": - metadata[current_key] = "\n".join(current_value).rstrip() - else: - text = "\n".join(current_value).rstrip() - # Replace single newlines with spaces for folded blocks - metadata[current_key] = re.sub(r"(?", "|"): - current_key = key - is_multiline = True - multiline_style = value - current_value = [] - indent_level = None - else: - metadata[key] = value - - if current_key and is_multiline: - if multiline_style == "|": - metadata[current_key] = "\n".join(current_value).rstrip() - else: - text = "\n".join(current_value).rstrip() - metadata[current_key] = re.sub(r"(? Path: - """Write a SKILL.md file and return its path.""" - skill_file = tmp_path / "SKILL.md" - skill_file.write_text(content, encoding="utf-8") +def _write_skill(tmp_path: Path, front_matter: str, body: str = "# My Skill\n") -> Path: + """Write a minimal SKILL.md and return the path.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + skill_file = skill_dir / "SKILL.md" + skill_file.write_text(f"---\n{front_matter}\n---\n{body}", encoding="utf-8") return skill_file -class TestParseSkillFile: - def test_valid_skill_file(self, tmp_path): - skill_file = _write_skill( - tmp_path, - "---\nname: my-skill\ndescription: A test skill\nlicense: MIT\n---\n\n# My Skill\n", - ) - result = parse_skill_file(skill_file, "public") - assert result is not None - assert result.name == "my-skill" - assert result.description == "A test skill" - assert result.license == "MIT" - assert result.category == "public" - assert result.enabled is True - assert result.skill_dir == tmp_path - assert result.skill_file == skill_file - - def test_missing_name_returns_none(self, tmp_path): - skill_file = _write_skill( - tmp_path, - "---\ndescription: A test skill\n---\n\nBody\n", - ) - assert parse_skill_file(skill_file, "public") is None - - def test_missing_description_returns_none(self, tmp_path): - skill_file = _write_skill( - tmp_path, - "---\nname: my-skill\n---\n\nBody\n", - ) - assert parse_skill_file(skill_file, "public") is None - - def test_no_front_matter_returns_none(self, tmp_path): - skill_file = _write_skill(tmp_path, "# Just a markdown file\n\nNo front matter here.\n") - assert parse_skill_file(skill_file, "public") is None - - def test_nonexistent_file_returns_none(self, tmp_path): - skill_file = tmp_path / "SKILL.md" - assert parse_skill_file(skill_file, "public") is None - - def test_wrong_filename_returns_none(self, tmp_path): - wrong_file = tmp_path / "README.md" - wrong_file.write_text("---\nname: test\ndescription: test\n---\n", encoding="utf-8") - assert parse_skill_file(wrong_file, "public") is None - - def test_optional_license_field(self, tmp_path): - skill_file = _write_skill( - tmp_path, - "---\nname: my-skill\ndescription: A test skill\n---\n\nBody\n", - ) - result = parse_skill_file(skill_file, "custom") - assert result is not None - assert result.license is None - assert result.category == "custom" - - def test_custom_relative_path(self, tmp_path): - skill_file = _write_skill( - tmp_path, - "---\nname: nested-skill\ndescription: Nested\n---\n\nBody\n", - ) - rel = Path("group/nested-skill") - result = parse_skill_file(skill_file, "public", relative_path=rel) - assert result is not None - assert result.relative_path == rel - - def test_default_relative_path_is_parent_name(self, tmp_path): - skill_file = _write_skill( - tmp_path, - "---\nname: my-skill\ndescription: Test\n---\n\nBody\n", - ) - result = parse_skill_file(skill_file, "public") - assert result is not None - assert result.relative_path == Path(tmp_path.name) - - def test_colons_in_description(self, tmp_path): - skill_file = _write_skill( - tmp_path, - "---\nname: my-skill\ndescription: A skill: does things\n---\n\nBody\n", - ) - result = parse_skill_file(skill_file, "public") - assert result is not None - assert result.description == "A skill: does things" - - def test_multiline_yaml_folded_description(self, tmp_path): - skill_file = _write_skill( - tmp_path, - "---\nname: multiline-skill\ndescription: >\n This is a multiline\n description for a skill.\n\n It spans multiple lines.\nlicense: MIT\n---\n\nBody\n", - ) - result = parse_skill_file(skill_file, "public") - assert result is not None - assert result.name == "multiline-skill" - assert result.description == "This is a multiline description for a skill.\n\nIt spans multiple lines." - assert result.license == "MIT" - - def test_multiline_yaml_literal_description(self, tmp_path): - skill_file = _write_skill( - tmp_path, - "---\nname: pipe-skill\ndescription: |\n First line.\n Second line.\n---\n\nBody\n", - ) - result = parse_skill_file(skill_file, "public") - assert result is not None - assert result.name == "pipe-skill" - assert result.description == "First line.\nSecond line." - - def test_empty_front_matter_returns_none(self, tmp_path): - skill_file = _write_skill(tmp_path, "---\n\n---\n\nBody\n") - assert parse_skill_file(skill_file, "public") is None +# --------------------------------------------------------------------------- +# Basic parsing +# --------------------------------------------------------------------------- + + +def test_parse_plain_name(tmp_path): + """Unquoted name is parsed correctly.""" + skill_file = _write_skill(tmp_path, "name: my-skill\ndescription: A test skill") + skill = parse_skill_file(skill_file, category="custom") + assert skill is not None + assert skill.name == "my-skill" + + +def test_parse_quoted_name_no_quotes_in_result(tmp_path): + """Quoted name (YAML string) must not include surrounding quotes in result. + + Regression: the old hand-rolled parser stored ``'"my-skill"'`` instead of + ``'my-skill'`` when the YAML value was wrapped in double-quotes. + """ + skill_file = _write_skill(tmp_path, 'name: "my-skill"\ndescription: A test skill') + skill = parse_skill_file(skill_file, category="custom") + assert skill is not None + assert skill.name == "my-skill", f"Expected 'my-skill', got {skill.name!r}" + + +def test_parse_single_quoted_name(tmp_path): + """Single-quoted YAML strings are also handled correctly.""" + skill_file = _write_skill(tmp_path, "name: 'my-skill'\ndescription: A test skill") + skill = parse_skill_file(skill_file, category="custom") + assert skill is not None + assert skill.name == "my-skill" + + +def test_parse_description_returned(tmp_path): + """Description field is correctly extracted.""" + skill_file = _write_skill(tmp_path, "name: my-skill\ndescription: Does amazing things") + skill = parse_skill_file(skill_file, category="custom") + assert skill is not None + assert skill.description == "Does amazing things" + + +def test_parse_multiline_description(tmp_path): + """Multi-line YAML descriptions are collapsed correctly by yaml.safe_load.""" + front_matter = "name: my-skill\ndescription: >\n A folded\n description" + skill_file = _write_skill(tmp_path, front_matter) + skill = parse_skill_file(skill_file, category="custom") + assert skill is not None + assert "folded" in skill.description + + +def test_parse_license_field(tmp_path): + """Optional license field is captured when present.""" + skill_file = _write_skill(tmp_path, "name: my-skill\ndescription: Test\nlicense: MIT") + skill = parse_skill_file(skill_file, category="custom") + assert skill is not None + assert skill.license == "MIT" + + +def test_parse_missing_name_returns_none(tmp_path): + """Skills missing a name field are rejected.""" + skill_file = _write_skill(tmp_path, "description: A test skill") + skill = parse_skill_file(skill_file, category="custom") + assert skill is None + + +def test_parse_missing_description_returns_none(tmp_path): + """Skills missing a description field are rejected.""" + skill_file = _write_skill(tmp_path, "name: my-skill") + skill = parse_skill_file(skill_file, category="custom") + assert skill is None + + +def test_parse_no_front_matter_returns_none(tmp_path): + """Files without YAML front-matter delimiters return None.""" + skill_dir = tmp_path / "no-fm" + skill_dir.mkdir() + skill_file = skill_dir / "SKILL.md" + skill_file.write_text("# No front matter here\n", encoding="utf-8") + skill = parse_skill_file(skill_file, category="public") + assert skill is None + + +def test_parse_invalid_yaml_returns_none(tmp_path): + """Malformed YAML front-matter is handled gracefully (returns None).""" + skill_file = _write_skill(tmp_path, "name: [unclosed") + skill = parse_skill_file(skill_file, category="custom") + assert skill is None + + +def test_parse_category_stored(tmp_path): + """Category is propagated into the returned Skill object.""" + skill_file = _write_skill(tmp_path, "name: my-skill\ndescription: Test") + skill = parse_skill_file(skill_file, category="public") + assert skill is not None + assert skill.category == "public" + + +def test_parse_nonexistent_file_returns_none(tmp_path): + """Non-existent files are handled gracefully.""" + skill = parse_skill_file(tmp_path / "ghost" / "SKILL.md", category="custom") + assert skill is None diff --git a/backend/tests/test_tool_deduplication.py b/backend/tests/test_tool_deduplication.py new file mode 100644 index 0000000000..35ec0bea60 --- /dev/null +++ b/backend/tests/test_tool_deduplication.py @@ -0,0 +1,106 @@ +"""Tests for tool name deduplication in get_available_tools() (issue #1803). + +Duplicate tool registrations previously passed through silently and could +produce mangled function-name schemas that caused 100% tool call failures. +``get_available_tools()`` now deduplicates by name, config-loaded tools taking +priority, and logs a warning for every skipped duplicate. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from langchain_core.tools import BaseTool, tool + +from deerflow.tools.tools import get_available_tools + +# --------------------------------------------------------------------------- +# Fixture tools +# --------------------------------------------------------------------------- + + +@tool +def _tool_alpha(x: str) -> str: + """Alpha tool.""" + return x + + +@tool +def _tool_alpha_dup(x: str) -> str: + """Duplicate of alpha — same name, different object.""" + return x + + +# Rename duplicate to share the same .name as _tool_alpha +_tool_alpha_dup.name = _tool_alpha.name # type: ignore[attr-defined] + + +@tool +def _tool_beta(x: str) -> str: + """Beta tool.""" + return x + + +# --------------------------------------------------------------------------- +# Deduplication behaviour +# --------------------------------------------------------------------------- + + +def _make_minimal_config(tools): + """Return an AppConfig-like mock with the given tools list.""" + config = MagicMock() + config.tools = tools + config.models = [] + config.tool_search.enabled = False + config.sandbox = MagicMock() + return config + + +@patch("deerflow.tools.tools.get_app_config") +@patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True) +@patch("deerflow.tools.tools.reset_deferred_registry") +def test_no_duplicates_returned(mock_reset, mock_bash, mock_cfg): + """get_available_tools() never returns two tools with the same name.""" + mock_cfg.return_value = _make_minimal_config([]) + + # Patch the builtin tools so we control exactly what comes back. + with patch("deerflow.tools.tools.BUILTIN_TOOLS", [_tool_alpha, _tool_alpha_dup, _tool_beta]): + result = get_available_tools(include_mcp=False) + + names = [t.name for t in result] + assert len(names) == len(set(names)), f"Duplicate names detected: {names}" + + +@patch("deerflow.tools.tools.get_app_config") +@patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True) +@patch("deerflow.tools.tools.reset_deferred_registry") +def test_first_occurrence_wins(mock_reset, mock_bash, mock_cfg): + """When duplicates exist, the first occurrence is kept.""" + mock_cfg.return_value = _make_minimal_config([]) + + sentinel_alpha = MagicMock(spec=BaseTool, name="_sentinel") + sentinel_alpha.name = _tool_alpha.name # same name + sentinel_alpha_dup = MagicMock(spec=BaseTool, name="_sentinel_dup") + sentinel_alpha_dup.name = _tool_alpha.name # same name — should be dropped + + with patch("deerflow.tools.tools.BUILTIN_TOOLS", [sentinel_alpha, sentinel_alpha_dup, _tool_beta]): + result = get_available_tools(include_mcp=False) + + returned_alpha = next(t for t in result if t.name == _tool_alpha.name) + assert returned_alpha is sentinel_alpha + + +@patch("deerflow.tools.tools.get_app_config") +@patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True) +@patch("deerflow.tools.tools.reset_deferred_registry") +def test_duplicate_triggers_warning(mock_reset, mock_bash, mock_cfg, caplog): + """A warning is logged for every skipped duplicate.""" + import logging + + mock_cfg.return_value = _make_minimal_config([]) + + with patch("deerflow.tools.tools.BUILTIN_TOOLS", [_tool_alpha, _tool_alpha_dup]): + with caplog.at_level(logging.WARNING, logger="deerflow.tools.tools"): + get_available_tools(include_mcp=False) + + assert any("Duplicate tool name" in r.message for r in caplog.records), "Expected a duplicate-tool warning in log output" From ef04174194629aa833bb56a4c5326fae860b7113 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:41:28 +0800 Subject: [PATCH 20/55] Fix invalid HTML nesting in reasoning trigger during complex task rendering (#2382) * Initial plan * fix(frontend): avoid invalid paragraph nesting in reasoning trigger Agent-Logs-Url: https://github.com/bytedance/deer-flow/sessions/4c9eb0c2-ff29-4629-a61c-4e33d736d918 Co-authored-by: WillemJiang <219644+WillemJiang@users.noreply.github.com> * test(frontend): strengthen reasoning trigger DOM nesting assertion Agent-Logs-Url: https://github.com/bytedance/deer-flow/sessions/4c9eb0c2-ff29-4629-a61c-4e33d736d918 Co-authored-by: WillemJiang <219644+WillemJiang@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: WillemJiang <219644+WillemJiang@users.noreply.github.com> --- .../src/components/ai-elements/reasoning.tsx | 4 +-- .../tests/unit/core/reasoning-trigger.test.ts | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/unit/core/reasoning-trigger.test.ts diff --git a/frontend/src/components/ai-elements/reasoning.tsx b/frontend/src/components/ai-elements/reasoning.tsx index 5f1d2321e7..b8e0bfcbcf 100644 --- a/frontend/src/components/ai-elements/reasoning.tsx +++ b/frontend/src/components/ai-elements/reasoning.tsx @@ -123,9 +123,9 @@ const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => { return Thinking...; } if (duration === undefined) { - return

Thought for a few seconds

; + return Thought for a few seconds; } - return

Thought for {duration} seconds

; + return Thought for {duration} seconds; }; export const ReasoningTrigger = memo( diff --git a/frontend/tests/unit/core/reasoning-trigger.test.ts b/frontend/tests/unit/core/reasoning-trigger.test.ts new file mode 100644 index 0000000000..2fbe97977e --- /dev/null +++ b/frontend/tests/unit/core/reasoning-trigger.test.ts @@ -0,0 +1,28 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { expect, test, vi } from "vitest"; + +vi.mock("streamdown", () => ({ + Streamdown: ({ children }: { children: string }) => + createElement("div", null, children), +})); + +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "@/components/ai-elements/reasoning"; + +test("ReasoningTrigger default message uses phrasing content", () => { + const html = renderToStaticMarkup( + createElement( + Reasoning, + { isStreaming: false, defaultOpen: false }, + createElement(ReasoningTrigger, null), + createElement(ReasoningContent, null, "test"), + ), + ); + + expect(html).toContain("Thought for a few seconds"); + expect(html).not.toMatch(/]*>[\s\S]*? Date: Mon, 20 Apr 2026 21:53:18 -0400 Subject: [PATCH 21/55] fix: remove unnecessary f-string prefixes and unused import (#2352) - Remove f-string prefix on 7 strings with no placeholders (F541) in analyze.py, aggregate_benchmark.py, run_loop.py, generate_review.py - Remove unused `os` import in quick_validate.py (F401) Found by ruff via HUMMBL Arbiter (https://hummbl.io/audit). --- skills/public/data-analysis/scripts/analyze.py | 6 +++--- skills/public/skill-creator/eval-viewer/generate_review.py | 6 +++--- skills/public/skill-creator/scripts/aggregate_benchmark.py | 2 +- skills/public/skill-creator/scripts/quick_validate.py | 1 - skills/public/skill-creator/scripts/run_loop.py | 2 +- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/skills/public/data-analysis/scripts/analyze.py b/skills/public/data-analysis/scripts/analyze.py index b38ae476a9..bc85d88a66 100644 --- a/skills/public/data-analysis/scripts/analyze.py +++ b/skills/public/data-analysis/scripts/analyze.py @@ -215,14 +215,14 @@ def action_inspect(con: duckdb.DuckDBPyConnection, table_map: dict[str, str]) -> non_null_sql = f'SELECT {", ".join(non_null_parts)} FROM "{table_name}"' try: non_null_counts = con.execute(non_null_sql).fetchone() - output_parts.append(f"\nNon-null counts:") + output_parts.append("\nNon-null counts:") for i, c in enumerate(col_names): output_parts.append(f" {c}: {non_null_counts[i]} / {row_count}") except Exception: pass # Sample data (first 5 rows) - output_parts.append(f"\nSample data (first 5 rows):") + output_parts.append("\nSample data (first 5 rows):") try: sample = con.execute(f'SELECT * FROM "{table_name}" LIMIT 5').fetchdf() output_parts.append(sample.to_string(index=False)) @@ -465,7 +465,7 @@ def action_summary( LIMIT 5 """).fetchall() if top_vals: - output_parts.append(f" top values:") + output_parts.append(" top values:") for val, freq in top_vals: pct = (freq / row_count * 100) if row_count > 0 else 0 output_parts.append(f" {val}: {freq} ({pct:.1f}%)") diff --git a/skills/public/skill-creator/eval-viewer/generate_review.py b/skills/public/skill-creator/eval-viewer/generate_review.py index 7fa5978631..26e2c193e9 100644 --- a/skills/public/skill-creator/eval-viewer/generate_review.py +++ b/skills/public/skill-creator/eval-viewer/generate_review.py @@ -447,8 +447,8 @@ def main() -> None: port = server.server_address[1] url = f"http://localhost:{port}" - print(f"\n Eval Viewer") - print(f" ─────────────────────────────────") + print("\n Eval Viewer") + print(" ─────────────────────────────────") print(f" URL: {url}") print(f" Workspace: {workspace}") print(f" Feedback: {feedback_path}") @@ -456,7 +456,7 @@ def main() -> None: print(f" Previous: {args.previous_workspace} ({len(previous)} runs)") if benchmark_path: print(f" Benchmark: {benchmark_path}") - print(f"\n Press Ctrl+C to stop.\n") + print("\n Press Ctrl+C to stop.\n") webbrowser.open(url) diff --git a/skills/public/skill-creator/scripts/aggregate_benchmark.py b/skills/public/skill-creator/scripts/aggregate_benchmark.py index fa44b384d0..7a117bdd78 100755 --- a/skills/public/skill-creator/scripts/aggregate_benchmark.py +++ b/skills/public/skill-creator/scripts/aggregate_benchmark.py @@ -389,7 +389,7 @@ def main(): configs = [k for k in run_summary if k != "delta"] delta = run_summary.get("delta", {}) - print(f"\nSummary:") + print("\nSummary:") for config in configs: pr = run_summary[config]["pass_rate"]["mean"] label = config.replace("_", " ").title() diff --git a/skills/public/skill-creator/scripts/quick_validate.py b/skills/public/skill-creator/scripts/quick_validate.py index ed8e1dddce..fdc5142a77 100755 --- a/skills/public/skill-creator/scripts/quick_validate.py +++ b/skills/public/skill-creator/scripts/quick_validate.py @@ -4,7 +4,6 @@ """ import sys -import os import re import yaml from pathlib import Path diff --git a/skills/public/skill-creator/scripts/run_loop.py b/skills/public/skill-creator/scripts/run_loop.py index 30a263d674..5a9f979b89 100755 --- a/skills/public/skill-creator/scripts/run_loop.py +++ b/skills/public/skill-creator/scripts/run_loop.py @@ -188,7 +188,7 @@ def print_eval_stats(label, results, elapsed): # Improve the description based on train results if verbose: - print(f"\nImproving description...", file=sys.stderr) + print("\nImproving description...", file=sys.stderr) t0 = time.time() # Strip test scores from history so improvement model can't see them From 5ba1dacf25085d038db09bf27c5120821f61a777 Mon Sep 17 00:00:00 2001 From: Shawn Jasper Date: Tue, 21 Apr 2026 16:10:14 +0800 Subject: [PATCH 22/55] fix: rename present_file to present_files in docs and prompts (#2393) The tool is registered as `present_files` (plural) in present_file_tool.py, but four references in documentation and prompt strings incorrectly used the singular form `present_file`. This could cause confusion and potentially lead to incorrect tool invocations. Changed files: - backend/docs/GUARDRAILS.md - backend/docs/ARCHITECTURE.md - backend/packages/harness/deerflow/agents/lead_agent/prompt.py (2 occurrences) --- backend/docs/ARCHITECTURE.md | 2 +- backend/docs/GUARDRAILS.md | 2 +- backend/packages/harness/deerflow/agents/lead_agent/prompt.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 3b60cfea8a..cc0993f7f0 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -199,7 +199,7 @@ class ThreadState(AgentState): │ Built-in Tools │ │ Configured Tools │ │ MCP Tools │ │ (packages/harness/deerflow/tools/) │ │ (config.yaml) │ │ (extensions.json) │ ├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤ -│ - present_file │ │ - web_search │ │ - github │ +│ - present_files │ │ - web_search │ │ - github │ │ - ask_clarification │ │ - web_fetch │ │ - filesystem │ │ - view_image │ │ - bash │ │ - postgres │ │ │ │ - read_file │ │ - brave-search │ diff --git a/backend/docs/GUARDRAILS.md b/backend/docs/GUARDRAILS.md index 81fc4be478..28a309ca32 100644 --- a/backend/docs/GUARDRAILS.md +++ b/backend/docs/GUARDRAILS.md @@ -296,7 +296,7 @@ These are the tool names your provider will see in `request.tool_name`: | `web_search` | Web search query | | `web_fetch` | Fetch URL content | | `image_search` | Image search | -| `present_file` | Present file to user | +| `present_files` | Present file to user | | `view_image` | Display image | | `ask_clarification` | Ask user a question | | `task` | Delegate to subagent | diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 71af2e653a..dda49a1dee 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -420,7 +420,7 @@ def _build_subagent_section(max_concurrent: int) -> str: - Treat `/mnt/user-data/workspace` as your default current working directory for coding and file-editing tasks - When writing scripts or commands that create/read files from the workspace, prefer relative paths such as `hello.txt`, `../uploads/data.csv`, and `../outputs/report.md` - Avoid hardcoding `/mnt/user-data/...` inside generated scripts when a relative path from the workspace is enough -- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_file` tool +- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_files` tool {acp_section} @@ -648,7 +648,7 @@ def _build_acp_section() -> str: "- ACP agents (e.g. codex, claude_code) run in their own independent workspace — NOT in `/mnt/user-data/`\n" "- When writing prompts for ACP agents, describe the task only — do NOT reference `/mnt/user-data` paths\n" "- ACP agent results are accessible at `/mnt/acp-workspace/` (read-only) — use `ls`, `read_file`, or `bash cp` to retrieve output files\n" - "- To deliver ACP output to the user: copy from `/mnt/acp-workspace/` to `/mnt/user-data/outputs/`, then use `present_file`" + "- To deliver ACP output to the user: copy from `/mnt/acp-workspace/` to `/mnt/user-data/outputs/`, then use `present_files`" ) From 1ca2621285e8b9930932adea835f393ba31c6f43 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:14:11 +0800 Subject: [PATCH 23/55] chore(deps): bump lxml from 6.0.2 to 6.1.0 in /backend (#2427) Bumps [lxml](https://github.com/lxml/lxml) from 6.0.2 to 6.1.0. - [Release notes](https://github.com/lxml/lxml/releases) - [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt) - [Commits](https://github.com/lxml/lxml/compare/lxml-6.0.2...lxml-6.1.0) --- updated-dependencies: - dependency-name: lxml dependency-version: 6.1.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/uv.lock | 152 ++++++++++++++++++++++++------------------------ 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index d6544fb831..a42aa17b75 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1856,82 +1856,82 @@ wheels = [ [[package]] name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, - { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, - { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, - { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, - { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, - { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, - { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, ] [[package]] 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 24/55] 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 a42aa17b75..7c248b2531 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 25/55] 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 f558d1d714..2413d6e497 100644 --- a/backend/debug.py +++ b/backend/debug.py @@ -20,6 +20,7 @@ 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 26/55] 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 0d5fe8d884..65074f4cd0 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 27/55] 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 0000000000..0e99997a22 --- /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 38698d2d4f..ab328d5fb8 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 7bc91344f2..d7c6358d89 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 28/55] 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 198dba37b2..ed8b0a950f 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 65074f4cd0..fc79edd09c 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 29/55] 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 2413d6e497..2031557c11 100644 --- a/backend/debug.py +++ b/backend/debug.py @@ -24,6 +24,14 @@ 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 2b2e43baa5..220ac23d64 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 30/55] 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 39d17498fa..92f50b3248 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 @@ 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 0000000000..9319c62688 --- /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 31/55] 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 92002d75b7..ff4476893e 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 dda49a1dee..2ccacac68d 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 @@ def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str: """ +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 b5f885d5ac..e7219284d6 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 @@ def get_max_turns_for(self, agent_name: str, builtin_default: int) -> int: 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 8554e7d4d5..a2c961b9d0 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 5529bec2c5..b42cebacf4 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -13,7 +13,7 @@ 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 @@ def _create_agent(self): 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 @@ def _build_initial_state(self, task: str) -> dict[str, Any]: 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 @@ async def _aexecute(self, task: str, result_holder: SubagentResult | None = None 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 e54f69f76e..b34d7e9bd7 100644 --- a/backend/packages/harness/deerflow/subagents/registry.py +++ b/backend/packages/harness/deerflow/subagents/registry.py @@ -10,53 +10,100 @@ 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 a42f8bbef3..793ccb13a9 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 437fb37acc..fbe41ded7e 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 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 d0e5a949f0..015206877b 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 0000000000..f121ccf256 --- /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 5251c69ed0..1358c5bec4 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -143,7 +143,7 @@ def execute_async(self, prompt, task_id=None): 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 execute_async(self, prompt, task_id=None): 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 execute_async(self, prompt, task_id=None): 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 @@ async def cancel_on_first_sleep(_: float) -> None: 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 @@ async def cancel_on_first_sleep(_: float) -> None: 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 @@ async def cancel_on_first_sleep(_: float) -> None: 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 fc3d9c8c1c..1e649bba9b 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 6b2a510bff..ce1d8ce184 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 0ff0efff16..53e09ba66d 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 32/55] 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 7c248b2531..716b7e07a5 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 33/55] 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 1358c5bec4..b39f09dadc 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -201,7 +201,6 @@ def execute_async(self, prompt, task_id=None): 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 execute_async(self, prompt, task_id=None): 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 execute_async(self, prompt, task_id=None): 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 34/55] 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 3adc5458ab..c4fc1ac819 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 @@ async def crawl(self, url: str, return_format: str = "html", timeout: int = 10) 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 5a1d6f6fac..b1856e4ae3 100644 --- a/backend/tests/test_jina_client.py +++ b/backend/tests/test_jina_client.py @@ -80,6 +80,28 @@ async def mock_post(self, url, **kwargs): 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 35/55] 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 ac54e674d0..bfda014917 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 0966b8c488..7e782dcbc1 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -409,7 +409,7 @@ async def aupdate_memory( 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 dd131ac281..c17b463877 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 @@ async def _agenerate_title_result(self, state: TitleMiddlewareState) -> dict | N 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 51986cc714..a8fc90a4e0 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 fce8cd0fb8..37e81c471e 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -598,6 +598,7 @@ def test_async_update_memory_uses_ainvoke(self): 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 4dcaa691c8..088cb2c116 100644 --- a/backend/tests/test_security_scanner.py +++ b/backend/tests/test_security_scanner.py @@ -5,6 +5,27 @@ 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 fee07dd440..0e70b45d6a 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 ce7376e2e4..684de23452 100644 --- a/backend/tests/test_title_middleware_core_logic.py +++ b/backend/tests/test_title_middleware_core_logic.py @@ -93,6 +93,7 @@ def test_generate_title_uses_async_model_and_respects_max_chars(self, monkeypatc 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 36/55] 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 4e46d2e712..0076848e00 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 2031557c11..3e0694cefb 100644 --- a/backend/debug.py +++ b/backend/debug.py @@ -19,10 +19,6 @@ 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 @@ 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 37/55] 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 ae15fa47db..ce01d3c271 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 3a111564b1..fadc25fa69 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 38/55] 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 ca1e8dda16..773d27e3d3 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 de3ff67664..f17aab6ce5 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 fba44c2153..651b64a728 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 @@ def _maybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None: 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 @@ async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | 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 @@ async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | ] } + 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 f132e58cd4..fab268ec51 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 12a4d01434..dc95dc4da3 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -207,3 +207,27 @@ def _fake_middleware(**kwargs): 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 d327c94c43..79ca8b01cd 100644 --- a/backend/tests/test_summarization_middleware.py +++ b/backend/tests/test_summarization_middleware.py @@ -4,7 +4,7 @@ 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 1e649bba9b..1c5bf4129b 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 39/55] 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 f17aab6ce5..1d1efe5b0b 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 fbe41ded7e..59613272cc 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 @@ 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 b39f09dadc..1ae008df2c 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -223,6 +223,90 @@ def execute_async(self, prompt, task_id=None): 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 40/55] 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 604cdf37ca..f92d901587 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 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 @@ def _filter_tools(self, request: ModelRequest) -> ModelRequest: 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 @@ def _filter_tools(self, request: ModelRequest) -> ModelRequest: 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 @@ def wrap_model_call( ) -> 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 @@ async def awrap_model_call( 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 ffbe2060f3..88f4e3112a 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 @@ def search(self, query: str) -> list[BaseTool]: 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 8f71144c5d..428bfec3d2 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 @@ def test_entries(self, registry): 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 @@ def test_tool_search_keyword_promotes_all_matches(self, registry): 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 41/55] 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 7dc22a9efd..3b3c40a271 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -12,6 +12,7 @@ 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 1d1efe5b0b..3b336a3773 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 @@ 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 782306e388..e0fcda2940 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 dc95dc4da3..a3bc21cfbd 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -113,6 +113,54 @@ def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None): 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 42/55] 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 bd2828e947..aec9b291af 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 0000000000..5f0d12e834 --- /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 220ac23d64..fe280d701b 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 0000000000..552966c37c --- /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 716b7e07a5..bd26308691 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 1c5bf4129b..32a94105a9 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 43/55] 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 2da0a678fb..ae8c948b06 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 @@ def execute_command(self, command: str) -> str: 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 18e180e3b3..328b1d48df 100644 --- a/backend/tests/test_local_sandbox_provider_mounts.py +++ b/backend/tests/test_local_sandbox_provider_mounts.py @@ -255,7 +255,9 @@ def mock_run(*args, **kwargs): 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 44/55] 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 efe2ea0c42..b7320f8ccc 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 718ac2ba3f..bcd50c6457 100644 --- a/backend/packages/harness/deerflow/mcp/tools.py +++ b/backend/packages/harness/deerflow/mcp/tools.py @@ -12,6 +12,7 @@ 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 0000000000..08432de98d --- /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 dc0e224ea9..118c5d6dbb 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 45/55] 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 | 55 ++-- .../test_claude_provider_prompt_caching.py | 249 ++++++++++++++++++ 2 files changed, 282 insertions(+), 22 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 2c00503130..35a15494d0 100644 --- a/backend/packages/harness/deerflow/models/claude_provider.py +++ b/backend/packages/harness/deerflow/models/claude_provider.py @@ -190,23 +190,33 @@ def _apply_oauth_billing(self, payload: dict) -> None: ) 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 @@ def _apply_prompt_caching(self, payload: dict) -> None: 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"}, - } - ] - - # Cache the last tool definition + new_block = {"type": "text", "text": content} + msg["content"] = [new_block] + candidates.append(new_block) + + # 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 0000000000..e212b7329a --- /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 46/55] 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 c9ad6a6ec7..65cb36cf5c 100644 --- a/backend/app/channels/slack.py +++ b/backend/app/channels/slack.py @@ -16,13 +16,31 @@ _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 @@ def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None: 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 7fc4126533..e6fb0213fc 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -2046,6 +2046,11 @@ def post_message(**kwargs): 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 @@ def test_numeric_allowed_users_match_string_event_user_id(self): "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 @@ def submit_coro(coro, loop): 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 32a94105a9..b9f7a96327 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 47/55] 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 0000000000..c79d53b510 --- /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 241ca71af6..b7cb2840bc 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 b21d860ae8..0d31b7c9f0 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 e9ca2c1741..59461ee995 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 48/55] 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 8d17f74819..8042733c27 100644 --- a/backend/app/channels/service.py +++ b/backend/app/channels/service.py @@ -23,6 +23,16 @@ "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 @@ async def start(self) -> None: 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 e6fb0213fc..bdb4584e5b 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -2011,6 +2011,65 @@ def test_config_service_urls_override_env(self, monkeypatch): 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 af8c0cfb7830c885765fca8d5d023d6cfca045a5 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:13:17 +0800 Subject: [PATCH 49/55] fix(harness): constrain view_image to thread data paths (#2557) * fix(harness): constrain view_image to thread data paths Fixes #2530 * fix(harness): address view_image review findings * style(harness): format view_image changes * fix(harness): address view_image review comments --- .../harness/deerflow/agents/factory.py | 6 +- .../harness/deerflow/sandbox/tools.py | 7 +- .../tools/builtins/view_image_tool.py | 113 +++++++++--- backend/tests/test_create_deerflow_agent.py | 24 ++- backend/tests/test_view_image_tool.py | 164 ++++++++++++++++++ 5 files changed, 282 insertions(+), 32 deletions(-) create mode 100644 backend/tests/test_view_image_tool.py diff --git a/backend/packages/harness/deerflow/agents/factory.py b/backend/packages/harness/deerflow/agents/factory.py index 57361edbac..bd57d733d0 100644 --- a/backend/packages/harness/deerflow/agents/factory.py +++ b/backend/packages/harness/deerflow/agents/factory.py @@ -254,9 +254,11 @@ def _assemble_from_features( from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware chain.append(ViewImageMiddleware()) - from deerflow.tools.builtins import view_image_tool - extra_tools.append(view_image_tool) + if feat.sandbox is not False: + from deerflow.tools.builtins import view_image_tool + + extra_tools.append(view_image_tool) # --- [11] Subagent --- if feat.subagent is not False: diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 7b09358e78..72b8da8345 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -548,7 +548,7 @@ def validate_local_tool_path(path: str, thread_data: ThreadDataState | None, *, This function is a security gate — it checks whether *path* may be accessed and raises on violation. It does **not** resolve the virtual path to a host path; callers are responsible for resolution via - ``_resolve_and_validate_user_data_path`` or ``_resolve_skills_path``. + ``resolve_and_validate_user_data_path`` or ``_resolve_skills_path``. Allowed virtual-path families: - ``/mnt/user-data/*`` — always allowed (read + write) @@ -635,6 +635,11 @@ def _resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState return str(resolved) +def resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState) -> str: + """Resolve a /mnt/user-data virtual path and validate it stays in bounds.""" + return _resolve_and_validate_user_data_path(path, thread_data) + + def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState | None) -> None: """Validate absolute paths in local-sandbox bash commands. diff --git a/backend/packages/harness/deerflow/tools/builtins/view_image_tool.py b/backend/packages/harness/deerflow/tools/builtins/view_image_tool.py index e47ab19386..3dedcab70c 100644 --- a/backend/packages/harness/deerflow/tools/builtins/view_image_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/view_image_tool.py @@ -8,7 +8,42 @@ from langgraph.types import Command from langgraph.typing import ContextT -from deerflow.agents.thread_state import ThreadState +from deerflow.agents.thread_state import ThreadDataState, ThreadState +from deerflow.config.paths import VIRTUAL_PATH_PREFIX + +_ALLOWED_IMAGE_VIRTUAL_ROOTS = ( + f"{VIRTUAL_PATH_PREFIX}/workspace", + f"{VIRTUAL_PATH_PREFIX}/uploads", + f"{VIRTUAL_PATH_PREFIX}/outputs", +) +_ALLOWED_IMAGE_VIRTUAL_ROOTS_TEXT = ", ".join(_ALLOWED_IMAGE_VIRTUAL_ROOTS) +_MAX_IMAGE_BYTES = 20 * 1024 * 1024 +_EXTENSION_TO_MIME = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +} + + +def _is_allowed_image_virtual_path(image_path: str) -> bool: + return any(image_path == root or image_path.startswith(f"{root}/") for root in _ALLOWED_IMAGE_VIRTUAL_ROOTS) + + +def _detect_image_mime(image_data: bytes) -> str | None: + if image_data.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if image_data.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if len(image_data) >= 12 and image_data.startswith(b"RIFF") and image_data[8:12] == b"WEBP": + return "image/webp" + return None + + +def _sanitize_image_error(error: Exception, thread_data: ThreadDataState | None) -> str: + from deerflow.sandbox.tools import mask_local_paths_in_output + + return mask_local_paths_in_output(f"{type(error).__name__}: {error}", thread_data) @tool("view_image", parse_docstring=True) @@ -29,22 +64,39 @@ def view_image_tool( - For multiple files at once (use present_files instead) Args: - image_path: Absolute path to the image file. Common formats supported: jpg, jpeg, png, webp. + image_path: Absolute /mnt/user-data virtual path to the image file. Common formats supported: jpg, jpeg, png, webp. """ - from deerflow.sandbox.tools import get_thread_data, replace_virtual_path + from deerflow.sandbox.exceptions import SandboxRuntimeError + from deerflow.sandbox.tools import ( + get_thread_data, + resolve_and_validate_user_data_path, + validate_local_tool_path, + ) - # Replace virtual path with actual path - # /mnt/user-data/* paths are mapped to thread-specific directories thread_data = get_thread_data(runtime) - actual_path = replace_virtual_path(image_path, thread_data) - # Validate that the path is absolute - path = Path(actual_path) - if not path.is_absolute(): + if not _is_allowed_image_virtual_path(image_path): + return Command( + update={ + "messages": [ + ToolMessage( + f"Error: Only image paths under {_ALLOWED_IMAGE_VIRTUAL_ROOTS_TEXT} are allowed", + tool_call_id=tool_call_id, + ) + ] + }, + ) + + try: + validate_local_tool_path(image_path, thread_data, read_only=True) + actual_path = resolve_and_validate_user_data_path(image_path, thread_data) + except (PermissionError, SandboxRuntimeError) as e: return Command( - update={"messages": [ToolMessage(f"Error: Path must be absolute, got: {image_path}", tool_call_id=tool_call_id)]}, + update={"messages": [ToolMessage(f"Error: {str(e)}", tool_call_id=tool_call_id)]}, ) + path = Path(actual_path) + # Validate that the file exists if not path.exists(): return Command( @@ -58,33 +110,48 @@ def view_image_tool( ) # Validate image extension - valid_extensions = {".jpg", ".jpeg", ".png", ".webp"} - if path.suffix.lower() not in valid_extensions: + expected_mime_type = _EXTENSION_TO_MIME.get(path.suffix.lower()) + if expected_mime_type is None: return Command( - update={"messages": [ToolMessage(f"Error: Unsupported image format: {path.suffix}. Supported formats: {', '.join(valid_extensions)}", tool_call_id=tool_call_id)]}, + update={"messages": [ToolMessage(f"Error: Unsupported image format: {path.suffix}. Supported formats: {', '.join(_EXTENSION_TO_MIME)}", tool_call_id=tool_call_id)]}, ) # Detect MIME type from file extension mime_type, _ = mimetypes.guess_type(actual_path) if mime_type is None: - # Fallback to default MIME types for common image formats - extension_to_mime = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".webp": "image/webp", - } - mime_type = extension_to_mime.get(path.suffix.lower(), "application/octet-stream") + mime_type = expected_mime_type + + try: + image_size = path.stat().st_size + except OSError as e: + return Command( + update={"messages": [ToolMessage(f"Error reading image metadata: {_sanitize_image_error(e, thread_data)}", tool_call_id=tool_call_id)]}, + ) + if image_size > _MAX_IMAGE_BYTES: + return Command( + update={"messages": [ToolMessage(f"Error: Image file is too large: {image_size} bytes. Maximum supported size is {_MAX_IMAGE_BYTES} bytes", tool_call_id=tool_call_id)]}, + ) # Read image file and convert to base64 try: with open(actual_path, "rb") as f: image_data = f.read() - image_base64 = base64.b64encode(image_data).decode("utf-8") except Exception as e: return Command( - update={"messages": [ToolMessage(f"Error reading image file: {str(e)}", tool_call_id=tool_call_id)]}, + update={"messages": [ToolMessage(f"Error reading image file: {_sanitize_image_error(e, thread_data)}", tool_call_id=tool_call_id)]}, + ) + + detected_mime_type = _detect_image_mime(image_data) + if detected_mime_type is None: + return Command( + update={"messages": [ToolMessage("Error: File contents do not match a supported image format", tool_call_id=tool_call_id)]}, + ) + if detected_mime_type != expected_mime_type: + return Command( + update={"messages": [ToolMessage(f"Error: Image contents are {detected_mime_type}, but file extension indicates {expected_mime_type}", tool_call_id=tool_call_id)]}, ) + mime_type = detected_mime_type + image_base64 = base64.b64encode(image_data).decode("utf-8") # Update viewed_images in state # The merge_viewed_images reducer will handle merging with existing images diff --git a/backend/tests/test_create_deerflow_agent.py b/backend/tests/test_create_deerflow_agent.py index 03fee20555..fb403ed7f4 100644 --- a/backend/tests/test_create_deerflow_agent.py +++ b/backend/tests/test_create_deerflow_agent.py @@ -116,12 +116,12 @@ def test_middleware_and_features_conflict(): # --------------------------------------------------------------------------- -# 7. Vision feature auto-injects view_image_tool +# 7. Vision feature auto-injects view_image_tool when thread data is available # --------------------------------------------------------------------------- @patch("deerflow.agents.factory.create_agent") def test_vision_injects_view_image_tool(mock_create_agent): mock_create_agent.return_value = MagicMock() - feat = RuntimeFeatures(vision=True, sandbox=False) + feat = RuntimeFeatures(vision=True, sandbox=True) create_deerflow_agent(_make_mock_model(), features=feat) @@ -130,6 +130,18 @@ def test_vision_injects_view_image_tool(mock_create_agent): assert "view_image" in tool_names +@patch("deerflow.agents.factory.create_agent") +def test_vision_without_sandbox_does_not_inject_view_image_tool(mock_create_agent): + mock_create_agent.return_value = MagicMock() + feat = RuntimeFeatures(vision=True, sandbox=False) + + create_deerflow_agent(_make_mock_model(), features=feat) + + call_kwargs = mock_create_agent.call_args[1] + tool_names = [t.name for t in call_kwargs["tools"]] + assert "view_image" not in tool_names + + def test_view_image_middleware_preserves_viewed_images_reducer(): middleware_hints = get_type_hints(ViewImageMiddleware.state_schema, include_extras=True) thread_hints = get_type_hints(ThreadState, include_extras=True) @@ -301,11 +313,11 @@ def test_always_on_error_handling(mock_create_agent): # --------------------------------------------------------------------------- -# 17. Vision with custom middleware still injects tool +# 17. Vision with custom middleware follows thread-data availability # --------------------------------------------------------------------------- @patch("deerflow.agents.factory.create_agent") -def test_vision_custom_middleware_still_injects_tool(mock_create_agent): - """Custom vision middleware still gets the view_image_tool auto-injected.""" +def test_vision_custom_middleware_without_sandbox_does_not_inject_tool(mock_create_agent): + """Custom vision middleware without thread data does not get view_image_tool auto-injected.""" from langchain.agents.middleware import AgentMiddleware mock_create_agent.return_value = MagicMock() @@ -319,7 +331,7 @@ class MyVision(AgentMiddleware): call_kwargs = mock_create_agent.call_args[1] tool_names = [t.name for t in call_kwargs["tools"]] - assert "view_image" in tool_names + assert "view_image" not in tool_names # =========================================================================== diff --git a/backend/tests/test_view_image_tool.py b/backend/tests/test_view_image_tool.py new file mode 100644 index 0000000000..eb7db890cc --- /dev/null +++ b/backend/tests/test_view_image_tool.py @@ -0,0 +1,164 @@ +import base64 +import importlib +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from deerflow.tools.builtins.view_image_tool import view_image_tool + +view_image_module = importlib.import_module("deerflow.tools.builtins.view_image_tool") + +PNG_BYTES = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==") + + +def _make_thread_data(tmp_path: Path) -> dict[str, str]: + user_data = tmp_path / "threads" / "thread-1" / "user-data" + workspace = user_data / "workspace" + uploads = user_data / "uploads" + outputs = user_data / "outputs" + for directory in (workspace, uploads, outputs): + directory.mkdir(parents=True) + + return { + "workspace_path": str(workspace), + "uploads_path": str(uploads), + "outputs_path": str(outputs), + } + + +def _make_runtime(thread_data: dict[str, str]) -> SimpleNamespace: + return SimpleNamespace( + state={"thread_data": thread_data}, + context={"thread_id": "thread-1"}, + config={}, + ) + + +def _message_content(result) -> str: + return result.update["messages"][0].content + + +def test_view_image_rejects_external_absolute_path(tmp_path: Path) -> None: + thread_data = _make_thread_data(tmp_path) + outside_image = tmp_path / "outside.png" + outside_image.write_bytes(PNG_BYTES) + + result = view_image_tool.func( + runtime=_make_runtime(thread_data), + image_path=str(outside_image), + tool_call_id="tc-external", + ) + + assert "Only image paths under /mnt/user-data" in _message_content(result) + assert "viewed_images" not in result.update + + +def test_view_image_reads_virtual_uploads_path(tmp_path: Path) -> None: + thread_data = _make_thread_data(tmp_path) + image_path = Path(thread_data["uploads_path"]) / "sample.png" + image_path.write_bytes(PNG_BYTES) + + result = view_image_tool.func( + runtime=_make_runtime(thread_data), + image_path="/mnt/user-data/uploads/sample.png", + tool_call_id="tc-uploads", + ) + + assert _message_content(result) == "Successfully read image" + viewed_image = result.update["viewed_images"]["/mnt/user-data/uploads/sample.png"] + assert viewed_image["base64"] == base64.b64encode(PNG_BYTES).decode("utf-8") + assert viewed_image["mime_type"] == "image/png" + + +def test_view_image_rejects_spoofed_extension(tmp_path: Path) -> None: + thread_data = _make_thread_data(tmp_path) + image_path = Path(thread_data["uploads_path"]) / "not-really.png" + image_path.write_bytes(b"not an image") + + result = view_image_tool.func( + runtime=_make_runtime(thread_data), + image_path="/mnt/user-data/uploads/not-really.png", + tool_call_id="tc-spoofed", + ) + + assert "contents do not match" in _message_content(result) + assert "viewed_images" not in result.update + + +def test_view_image_rejects_mismatched_magic_bytes(tmp_path: Path) -> None: + thread_data = _make_thread_data(tmp_path) + image_path = Path(thread_data["uploads_path"]) / "jpeg-named-png.png" + image_path.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg") + + result = view_image_tool.func( + runtime=_make_runtime(thread_data), + image_path="/mnt/user-data/uploads/jpeg-named-png.png", + tool_call_id="tc-mismatch", + ) + + assert "file extension indicates image/png" in _message_content(result) + assert "viewed_images" not in result.update + + +def test_view_image_rejects_oversized_image(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + thread_data = _make_thread_data(tmp_path) + image_path = Path(thread_data["uploads_path"]) / "sample.png" + image_path.write_bytes(PNG_BYTES) + monkeypatch.setattr(view_image_module, "_MAX_IMAGE_BYTES", len(PNG_BYTES) - 1) + + result = view_image_tool.func( + runtime=_make_runtime(thread_data), + image_path="/mnt/user-data/uploads/sample.png", + tool_call_id="tc-oversized", + ) + + assert "Image file is too large" in _message_content(result) + assert "viewed_images" not in result.update + + +def test_view_image_sanitizes_read_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + thread_data = _make_thread_data(tmp_path) + image_path = Path(thread_data["uploads_path"]) / "sample.png" + image_path.write_bytes(PNG_BYTES) + + def _open(*args, **kwargs): + raise PermissionError(f"permission denied: {image_path}") + + monkeypatch.setattr("builtins.open", _open) + + result = view_image_tool.func( + runtime=_make_runtime(thread_data), + image_path="/mnt/user-data/uploads/sample.png", + tool_call_id="tc-read-error", + ) + + message = _message_content(result) + assert "Error reading image file" in message + assert str(image_path) not in message + assert str(Path(thread_data["uploads_path"])) not in message + assert "/mnt/user-data/uploads/sample.png" in message + assert "viewed_images" not in result.update + + +@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows") +def test_view_image_rejects_uploads_symlink_escape(tmp_path: Path) -> None: + thread_data = _make_thread_data(tmp_path) + outside_image = tmp_path / "outside-target.png" + outside_image.write_bytes(PNG_BYTES) + + link_path = Path(thread_data["uploads_path"]) / "escape.png" + try: + link_path.symlink_to(outside_image) + except OSError as exc: + pytest.skip(f"symlink creation failed: {exc}") + + result = view_image_tool.func( + runtime=_make_runtime(thread_data), + image_path="/mnt/user-data/uploads/escape.png", + tool_call_id="tc-symlink", + ) + + assert "path traversal" in _message_content(result) + assert "viewed_images" not in result.update From f7dfb88a306615ce6ec90a9bf7fabba86113f529 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:47:56 +0800 Subject: [PATCH 50/55] fix(aio-sandbox): redact env values in container logs (#2562) * fix(aio-sandbox): redact env values in container logs Fixes #2534 * fix(aio-sandbox): address env log review comments --- .../community/aio_sandbox/local_backend.py | 44 ++++++++- .../tests/test_aio_sandbox_local_backend.py | 92 ++++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py index 4b680df2d0..15cbe3b78b 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py @@ -9,6 +9,7 @@ import json import logging import os +import shlex import subprocess from datetime import datetime @@ -86,6 +87,46 @@ def _format_container_mount(runtime: str, host_path: str, container_path: str, r return ["-v", mount_spec] +def _redact_container_command_for_log(cmd: list[str]) -> list[str]: + """Return a Docker/Container command with environment values redacted.""" + redacted: list[str] = [] + redact_next_env = False + + for arg in cmd: + if redact_next_env: + if "=" in arg: + key = arg.split("=", 1)[0] + redacted.append(f"{key}=" if key else "") + else: + redacted.append(arg) + redact_next_env = False + continue + + if arg in {"-e", "--env"}: + redacted.append(arg) + redact_next_env = True + continue + + if arg.startswith("--env="): + value = arg.removeprefix("--env=") + if "=" in value: + key = value.split("=", 1)[0] + redacted.append(f"--env={key}=" if key else "--env=") + else: + redacted.append(arg) + continue + + redacted.append(arg) + + return redacted + + +def _format_container_command_for_log(cmd: list[str]) -> str: + if os.name == "nt": + return subprocess.list2cmdline(cmd) + return shlex.join(cmd) + + class LocalContainerBackend(SandboxBackend): """Backend that manages sandbox containers locally using Docker or Apple Container. @@ -464,7 +505,8 @@ def _start_container( cmd.append(self._image) - logger.info(f"Starting container using {self._runtime}: {' '.join(cmd)}") + log_cmd = _format_container_command_for_log(_redact_container_command_for_log(cmd)) + logger.info(f"Starting container using {self._runtime}: {log_cmd}") try: result = subprocess.run(cmd, capture_output=True, text=True, check=True) diff --git a/backend/tests/test_aio_sandbox_local_backend.py b/backend/tests/test_aio_sandbox_local_backend.py index d0b99bec1e..d74786682f 100644 --- a/backend/tests/test_aio_sandbox_local_backend.py +++ b/backend/tests/test_aio_sandbox_local_backend.py @@ -1,4 +1,8 @@ -from deerflow.community.aio_sandbox.local_backend import _format_container_mount +import logging +import os +from types import SimpleNamespace + +from deerflow.community.aio_sandbox.local_backend import LocalContainerBackend, _format_container_command_for_log, _format_container_mount, _redact_container_command_for_log def test_format_container_mount_uses_mount_syntax_for_docker_windows_paths(): @@ -26,3 +30,89 @@ def test_format_container_mount_keeps_volume_syntax_for_apple_container(): "-v", "/host/path:/mnt/path:ro", ] + + +def test_redact_container_command_for_log_redacts_env_values(): + redacted = _redact_container_command_for_log( + [ + "docker", + "run", + "-e", + "API_KEY=secret-value", + "--env=TOKEN=token-value", + "--name", + "sandbox", + "image", + ] + ) + + assert "API_KEY=" in redacted + assert "--env=TOKEN=" in redacted + assert "secret-value" not in " ".join(redacted) + assert "token-value" not in " ".join(redacted) + + +def test_redact_container_command_for_log_keeps_inherited_env_names(): + redacted = _redact_container_command_for_log( + [ + "docker", + "run", + "-e", + "API_KEY", + "--env=TOKEN", + "--name", + "sandbox", + "image", + ] + ) + + assert redacted == [ + "docker", + "run", + "-e", + "API_KEY", + "--env=TOKEN", + "--name", + "sandbox", + "image", + ] + + +def test_format_container_command_for_log_uses_windows_quoting(monkeypatch): + monkeypatch.setattr(os, "name", "nt") + + command = _format_container_command_for_log(["docker", "run", "--name", "sandbox one", "image"]) + + assert command == 'docker run --name "sandbox one" image' + + +def test_start_container_logs_redacted_env_values(monkeypatch, caplog): + backend = LocalContainerBackend( + image="sandbox:latest", + base_port=8080, + container_prefix="sandbox", + config_mounts=[], + environment={"API_KEY": "secret-value", "NORMAL": "visible-value"}, + ) + monkeypatch.setattr(backend, "_runtime", "docker") + + captured_cmd: list[str] = [] + + def fake_run(cmd, **kwargs): + captured_cmd.extend(cmd) + return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0) + + monkeypatch.setattr("subprocess.run", fake_run) + + with caplog.at_level(logging.INFO, logger="deerflow.community.aio_sandbox.local_backend"): + backend._start_container("sandbox-test", 18080) + + joined_cmd = " ".join(captured_cmd) + assert "API_KEY=secret-value" in joined_cmd + assert "NORMAL=visible-value" in joined_cmd + + log_output = "\n".join(record.getMessage() for record in caplog.records) + assert "API_KEY=" in log_output + assert "NORMAL=" in log_output + assert "secret-value" not in log_output + assert "visible-value" not in log_output From 707ed328dd3b3364c304fac8ea0409bc86c2e1a6 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:56:11 +0800 Subject: [PATCH 51/55] fix(skills): scan skill archives before install (#2561) * fix(skills): scan skill archives before install Fixes #2536 * fix(skills): scan archive support files before install * style(skills): format archive installer * fix(skills): address archive install review comments --- backend/app/gateway/routers/skills.py | 4 +- .../harness/deerflow/skills/__init__.py | 4 +- .../harness/deerflow/skills/installer.py | 111 ++++++++++- backend/tests/test_client.py | 19 +- backend/tests/test_client_e2e.py | 9 + backend/tests/test_skills_custom_router.py | 80 ++++++++ backend/tests/test_skills_installer.py | 182 ++++++++++++++++++ 7 files changed, 400 insertions(+), 9 deletions(-) diff --git a/backend/app/gateway/routers/skills.py b/backend/app/gateway/routers/skills.py index 5fac32d414..7f0b869a89 100644 --- a/backend/app/gateway/routers/skills.py +++ b/backend/app/gateway/routers/skills.py @@ -11,7 +11,7 @@ from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config from deerflow.skills import Skill, load_skills -from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive +from deerflow.skills.installer import SkillAlreadyExistsError, ainstall_skill_from_archive from deerflow.skills.manager import ( append_history, atomic_write, @@ -119,7 +119,7 @@ async def list_skills() -> SkillsListResponse: async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse: try: skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path) - result = install_skill_from_archive(skill_file_path) + result = await ainstall_skill_from_archive(skill_file_path) await refresh_skills_system_prompt_cache_async() return SkillInstallResponse(**result) except FileNotFoundError as e: diff --git a/backend/packages/harness/deerflow/skills/__init__.py b/backend/packages/harness/deerflow/skills/__init__.py index bbdca0650a..4fcb7cc0de 100644 --- a/backend/packages/harness/deerflow/skills/__init__.py +++ b/backend/packages/harness/deerflow/skills/__init__.py @@ -1,4 +1,4 @@ -from .installer import SkillAlreadyExistsError, install_skill_from_archive +from .installer import SkillAlreadyExistsError, SkillSecurityScanError, ainstall_skill_from_archive, install_skill_from_archive from .loader import get_skills_root_path, load_skills from .types import Skill from .validation import ALLOWED_FRONTMATTER_PROPERTIES, _validate_skill_frontmatter @@ -10,5 +10,7 @@ "ALLOWED_FRONTMATTER_PROPERTIES", "_validate_skill_frontmatter", "install_skill_from_archive", + "ainstall_skill_from_archive", "SkillAlreadyExistsError", + "SkillSecurityScanError", ] diff --git a/backend/packages/harness/deerflow/skills/installer.py b/backend/packages/harness/deerflow/skills/installer.py index f7234336e4..e583678179 100644 --- a/backend/packages/harness/deerflow/skills/installer.py +++ b/backend/packages/harness/deerflow/skills/installer.py @@ -4,6 +4,8 @@ Both Gateway and Client delegate to these functions. """ +import asyncio +import concurrent.futures import logging import posixpath import shutil @@ -13,15 +15,23 @@ from pathlib import Path, PurePosixPath, PureWindowsPath from deerflow.skills.loader import get_skills_root_path +from deerflow.skills.security_scanner import scan_skill_content from deerflow.skills.validation import _validate_skill_frontmatter logger = logging.getLogger(__name__) +_PROMPT_INPUT_DIRS = {"references", "templates"} +_PROMPT_INPUT_SUFFIXES = frozenset({".json", ".markdown", ".md", ".rst", ".txt", ".yaml", ".yml"}) + class SkillAlreadyExistsError(ValueError): """Raised when a skill with the same name is already installed.""" +class SkillSecurityScanError(ValueError): + """Raised when a skill archive fails security scanning.""" + + def is_unsafe_zip_member(info: zipfile.ZipInfo) -> bool: """Return True if the zip member path is absolute or attempts directory traversal.""" name = info.filename @@ -114,7 +124,78 @@ def safe_extract_skill_archive( dst.write(chunk) -def install_skill_from_archive( +def _is_script_support_file(rel_path: Path) -> bool: + return bool(rel_path.parts) and rel_path.parts[0] == "scripts" + + +def _should_scan_support_file(rel_path: Path) -> bool: + if _is_script_support_file(rel_path): + return True + return bool(rel_path.parts) and rel_path.parts[0] in _PROMPT_INPUT_DIRS and rel_path.suffix.lower() in _PROMPT_INPUT_SUFFIXES + + +def _move_staged_skill_into_reserved_target(staging_target: Path, target: Path) -> None: + installed = False + reserved = False + try: + target.mkdir(mode=0o700) + reserved = True + for child in staging_target.iterdir(): + shutil.move(str(child), target / child.name) + installed = True + except FileExistsError as e: + raise SkillAlreadyExistsError(f"Skill '{target.name}' already exists") from e + finally: + if reserved and not installed and target.exists(): + shutil.rmtree(target) + + +async def _scan_skill_file_or_raise(skill_dir: Path, path: Path, skill_name: str, *, executable: bool) -> None: + rel_path = path.relative_to(skill_dir).as_posix() + location = f"{skill_name}/{rel_path}" + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError as e: + raise SkillSecurityScanError(f"Security scan failed for skill '{skill_name}': {location} must be valid UTF-8") from e + + try: + result = await scan_skill_content(content, executable=executable, location=location) + except Exception as e: + raise SkillSecurityScanError(f"Security scan failed for {location}: {e}") from e + + decision = getattr(result, "decision", None) + reason = str(getattr(result, "reason", "") or "No reason provided.") + if decision == "block": + if rel_path == "SKILL.md": + raise SkillSecurityScanError(f"Security scan blocked skill '{skill_name}': {reason}") + raise SkillSecurityScanError(f"Security scan blocked {location}: {reason}") + if executable and decision != "allow": + raise SkillSecurityScanError(f"Security scan rejected executable {location}: {reason}") + if decision not in {"allow", "warn"}: + raise SkillSecurityScanError(f"Security scan failed for {location}: invalid scanner decision {decision!r}") + + +async def _scan_skill_archive_contents_or_raise(skill_dir: Path, skill_name: str) -> None: + """Run the skill security scanner against all installable text and script files.""" + skill_md = skill_dir / "SKILL.md" + await _scan_skill_file_or_raise(skill_dir, skill_md, skill_name, executable=False) + + for path in sorted(skill_dir.rglob("*")): + if not path.is_file(): + continue + + rel_path = path.relative_to(skill_dir) + if rel_path == Path("SKILL.md"): + continue + if path.name == "SKILL.md": + raise SkillSecurityScanError(f"Security scan failed for skill '{skill_name}': nested SKILL.md is not allowed at {skill_name}/{rel_path.as_posix()}") + if not _should_scan_support_file(rel_path): + continue + + await _scan_skill_file_or_raise(skill_dir, path, skill_name, executable=_is_script_support_file(rel_path)) + + +async def ainstall_skill_from_archive( zip_path: str | Path, *, skills_root: Path | None = None, @@ -173,7 +254,12 @@ def install_skill_from_archive( if target.exists(): raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists") - shutil.copytree(skill_dir, target) + await _scan_skill_archive_contents_or_raise(skill_dir, skill_name) + + with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=custom_dir) as staging_root: + staging_target = Path(staging_root) / skill_name + shutil.copytree(skill_dir, staging_target) + _move_staged_skill_into_reserved_target(staging_target, target) logger.info("Skill %r installed to %s", skill_name, target) return { @@ -181,3 +267,24 @@ def install_skill_from_archive( "skill_name": skill_name, "message": f"Skill '{skill_name}' installed successfully", } + + +def _run_async_install(coro): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is not None and loop.is_running(): + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + return executor.submit(asyncio.run, coro).result() + return asyncio.run(coro) + + +def install_skill_from_archive( + zip_path: str | Path, + *, + skills_root: Path | None = None, +) -> dict: + """Install a skill from a .skill archive (ZIP).""" + return _run_async_install(ainstall_skill_from_archive(zip_path, skills_root=skills_root)) diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index 14b52d077d..5f613bf628 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -49,6 +49,17 @@ def client(mock_app_config): return DeerFlowClient() +@pytest.fixture +def allow_skill_security_scan(): + async def _scan(*args, **kwargs): + from deerflow.skills.security_scanner import ScanResult + + return ScanResult(decision="allow", reason="ok") + + with patch("deerflow.skills.installer.scan_skill_content", _scan): + yield + + # --------------------------------------------------------------------------- # __init__ # --------------------------------------------------------------------------- @@ -1195,7 +1206,7 @@ def test_update_skill_not_found(self, client): with pytest.raises(ValueError, match="not found"): client.update_skill("nonexistent", enabled=True) - def test_install_skill(self, client): + def test_install_skill(self, client, allow_skill_security_scan): with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) @@ -2015,7 +2026,7 @@ def test_memory_full_lifecycle(self, client): class TestScenarioSkillInstallAndUse: """Scenario: Install a skill → verify it appears → toggle it.""" - def test_install_then_toggle(self, client): + def test_install_then_toggle(self, client, allow_skill_security_scan): """Install .skill archive → list to verify → disable → verify disabled.""" with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) @@ -2261,7 +2272,7 @@ def test_get_skill(self, client): parsed = SkillResponse(**result) assert parsed.name == "web-search" - def test_install_skill(self, client, tmp_path): + def test_install_skill(self, client, tmp_path, allow_skill_security_scan): skill_dir = tmp_path / "my-skill" skill_dir.mkdir() (skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\nBody\n") @@ -2459,7 +2470,7 @@ def test_dotdot_path_in_archive_rejected(self, client): with pytest.raises(ValueError, match="unsafe"): client.install_skill(archive) - def test_symlinks_skipped_during_extraction(self, client): + def test_symlinks_skipped_during_extraction(self, client, allow_skill_security_scan): """Symlink entries in the archive are skipped (never written to disk).""" import stat as stat_mod diff --git a/backend/tests/test_client_e2e.py b/backend/tests/test_client_e2e.py index b26e5bff1a..197994aada 100644 --- a/backend/tests/test_client_e2e.py +++ b/backend/tests/test_client_e2e.py @@ -522,6 +522,15 @@ def test_get_artifact_traversal_within_prefix_blocked(self, e2e_env): class TestSkillInstallation: """install_skill() with real ZIP handling and filesystem.""" + @pytest.fixture(autouse=True) + def _allow_skill_security_scan(self, monkeypatch): + async def _scan(*args, **kwargs): + from deerflow.skills.security_scanner import ScanResult + + return ScanResult(decision="allow", reason="ok") + + monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan) + @pytest.fixture(autouse=True) def _isolate_skills_dir(self, tmp_path, monkeypatch): """Redirect skill installation to a temp directory.""" diff --git a/backend/tests/test_skills_custom_router.py b/backend/tests/test_skills_custom_router.py index e78eb54d79..bb7ecf6d75 100644 --- a/backend/tests/test_skills_custom_router.py +++ b/backend/tests/test_skills_custom_router.py @@ -1,5 +1,6 @@ import errno import json +import zipfile from pathlib import Path from types import SimpleNamespace @@ -35,6 +36,85 @@ def _make_skill(name: str, *, enabled: bool) -> Skill: ) +def _make_skill_archive(tmp_path: Path, name: str, content: str | None = None) -> Path: + archive = tmp_path / f"{name}.skill" + skill_content = content or _skill_content(name) + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr(f"{name}/SKILL.md", skill_content) + return archive + + +def test_install_skill_archive_runs_security_scan(monkeypatch, tmp_path): + skills_root = tmp_path / "skills" + (skills_root / "custom").mkdir(parents=True) + archive = _make_skill_archive(tmp_path, "archive-skill") + scan_calls = [] + refresh_calls = [] + + async def _scan(content, *, executable, location): + from deerflow.skills.security_scanner import ScanResult + + scan_calls.append({"content": content, "executable": executable, "location": location}) + return ScanResult(decision="allow", reason="ok") + + async def _refresh(): + refresh_calls.append("refresh") + + monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive) + monkeypatch.setattr("deerflow.skills.installer.get_skills_root_path", lambda: skills_root) + monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan) + monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh) + + app = FastAPI() + app.include_router(skills_router.router) + + with TestClient(app) as client: + response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/archive-skill.skill"}) + + assert response.status_code == 200 + assert response.json()["skill_name"] == "archive-skill" + assert (skills_root / "custom" / "archive-skill" / "SKILL.md").exists() + assert scan_calls == [ + { + "content": _skill_content("archive-skill"), + "executable": False, + "location": "archive-skill/SKILL.md", + } + ] + assert refresh_calls == ["refresh"] + + +def test_install_skill_archive_security_scan_block_returns_400(monkeypatch, tmp_path): + skills_root = tmp_path / "skills" + (skills_root / "custom").mkdir(parents=True) + archive = _make_skill_archive(tmp_path, "blocked-skill") + refresh_calls = [] + + async def _scan(*args, **kwargs): + from deerflow.skills.security_scanner import ScanResult + + return ScanResult(decision="block", reason="prompt injection") + + async def _refresh(): + refresh_calls.append("refresh") + + monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive) + monkeypatch.setattr("deerflow.skills.installer.get_skills_root_path", lambda: skills_root) + monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan) + monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh) + + app = FastAPI() + app.include_router(skills_router.router) + + with TestClient(app) as client: + response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/blocked-skill.skill"}) + + assert response.status_code == 400 + assert "Security scan blocked skill 'blocked-skill': prompt injection" in response.json()["detail"] + assert not (skills_root / "custom" / "blocked-skill").exists() + assert refresh_calls == [] + + def test_custom_skills_router_lifecycle(monkeypatch, tmp_path): skills_root = tmp_path / "skills" custom_dir = skills_root / "custom" / "demo-skill" diff --git a/backend/tests/test_skills_installer.py b/backend/tests/test_skills_installer.py index c5da4b0703..a7c9eae560 100644 --- a/backend/tests/test_skills_installer.py +++ b/backend/tests/test_skills_installer.py @@ -1,5 +1,6 @@ """Tests for deerflow.skills.installer — shared skill installation logic.""" +import shutil import stat import zipfile from pathlib import Path @@ -7,6 +8,7 @@ import pytest from deerflow.skills.installer import ( + SkillSecurityScanError, install_skill_from_archive, is_symlink_member, is_unsafe_zip_member, @@ -14,6 +16,7 @@ safe_extract_skill_archive, should_ignore_archive_entry, ) +from deerflow.skills.security_scanner import ScanResult # --------------------------------------------------------------------------- # is_unsafe_zip_member @@ -169,6 +172,13 @@ def test_normal_archive(self, tmp_path): class TestInstallSkillFromArchive: + @pytest.fixture(autouse=True) + def _allow_security_scan(self, monkeypatch): + async def _scan(*args, **kwargs): + return ScanResult(decision="allow", reason="ok") + + monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan) + def _make_skill_zip(self, tmp_path: Path, skill_name: str = "test-skill") -> Path: """Create a valid .skill archive.""" zip_path = tmp_path / f"{skill_name}.skill" @@ -188,6 +198,178 @@ def test_success(self, tmp_path): assert result["skill_name"] == "test-skill" assert (skills_root / "custom" / "test-skill" / "SKILL.md").exists() + def test_scans_skill_markdown_before_install(self, tmp_path, monkeypatch): + zip_path = self._make_skill_zip(tmp_path) + skills_root = tmp_path / "skills" + skills_root.mkdir() + calls = [] + + async def _scan(content, *, executable, location): + calls.append({"content": content, "executable": executable, "location": location}) + return ScanResult(decision="allow", reason="ok") + + monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan) + + install_skill_from_archive(zip_path, skills_root=skills_root) + + assert calls == [ + { + "content": "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n", + "executable": False, + "location": "test-skill/SKILL.md", + } + ] + + def test_scans_support_files_and_scripts_before_install(self, tmp_path, monkeypatch): + zip_path = tmp_path / "test-skill.skill" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("test-skill/SKILL.md", "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n") + zf.writestr("test-skill/references/guide.md", "# Guide\n") + zf.writestr("test-skill/templates/prompt.txt", "Use care.\n") + zf.writestr("test-skill/scripts/run.sh", "#!/bin/sh\necho ok\n") + zf.writestr("test-skill/assets/logo.png", b"\x89PNG\r\n\x1a\n") + zf.writestr("test-skill/references/.env", "TOKEN=secret\n") + zf.writestr("test-skill/templates/config.cfg", "TOKEN=secret\n") + skills_root = tmp_path / "skills" + skills_root.mkdir() + calls = [] + + async def _scan(content, *, executable, location): + calls.append({"content": content, "executable": executable, "location": location}) + return ScanResult(decision="allow", reason="ok") + + monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan) + + install_skill_from_archive(zip_path, skills_root=skills_root) + + assert calls == [ + { + "content": "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n", + "executable": False, + "location": "test-skill/SKILL.md", + }, + { + "content": "# Guide\n", + "executable": False, + "location": "test-skill/references/guide.md", + }, + { + "content": "#!/bin/sh\necho ok\n", + "executable": True, + "location": "test-skill/scripts/run.sh", + }, + { + "content": "Use care.\n", + "executable": False, + "location": "test-skill/templates/prompt.txt", + }, + ] + assert all("secret" not in call["content"] for call in calls) + + def test_nested_skill_markdown_prevents_install(self, tmp_path): + zip_path = tmp_path / "test-skill.skill" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("test-skill/SKILL.md", "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n") + zf.writestr("test-skill/references/other/SKILL.md", "# Nested skill\n") + skills_root = tmp_path / "skills" + skills_root.mkdir() + + with pytest.raises(SkillSecurityScanError, match="nested SKILL.md"): + install_skill_from_archive(zip_path, skills_root=skills_root) + + assert not (skills_root / "custom" / "test-skill").exists() + + def test_script_warn_prevents_install(self, tmp_path, monkeypatch): + zip_path = tmp_path / "test-skill.skill" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("test-skill/SKILL.md", "---\nname: test-skill\ndescription: A test skill\n---\n\n# test-skill\n") + zf.writestr("test-skill/scripts/run.sh", "#!/bin/sh\necho ok\n") + skills_root = tmp_path / "skills" + skills_root.mkdir() + + async def _scan(*args, executable, **kwargs): + if executable: + return ScanResult(decision="warn", reason="script needs review") + return ScanResult(decision="allow", reason="ok") + + monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan) + + with pytest.raises(SkillSecurityScanError, match="rejected executable.*script needs review"): + install_skill_from_archive(zip_path, skills_root=skills_root) + + assert not (skills_root / "custom" / "test-skill").exists() + + def test_security_scan_block_prevents_install(self, tmp_path, monkeypatch): + zip_path = self._make_skill_zip(tmp_path, skill_name="blocked-skill") + skills_root = tmp_path / "skills" + skills_root.mkdir() + + async def _scan(*args, **kwargs): + return ScanResult(decision="block", reason="prompt injection") + + monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan) + + with pytest.raises(SkillSecurityScanError, match="Security scan blocked.*prompt injection"): + install_skill_from_archive(zip_path, skills_root=skills_root) + + assert not (skills_root / "custom" / "blocked-skill").exists() + + def test_copy_failure_does_not_leave_partial_install(self, tmp_path, monkeypatch): + zip_path = self._make_skill_zip(tmp_path) + skills_root = tmp_path / "skills" + skills_root.mkdir() + + def _copytree(src, dst): + partial = Path(dst) + partial.mkdir(parents=True) + (partial / "partial.txt").write_text("partial", encoding="utf-8") + raise OSError("copy failed") + + monkeypatch.setattr("deerflow.skills.installer.shutil.copytree", _copytree) + + with pytest.raises(OSError, match="copy failed"): + install_skill_from_archive(zip_path, skills_root=skills_root) + + custom_dir = skills_root / "custom" + assert not (custom_dir / "test-skill").exists() + assert not [path for path in custom_dir.iterdir() if path.name.startswith(".installing-test-skill-")] + + def test_concurrent_target_creation_does_not_get_clobbered(self, tmp_path, monkeypatch): + zip_path = self._make_skill_zip(tmp_path) + skills_root = tmp_path / "skills" + skills_root.mkdir() + target = skills_root / "custom" / "test-skill" + original_copytree = shutil.copytree + + def _copytree(src, dst): + target.mkdir(parents=True) + (target / "marker.txt").write_text("external", encoding="utf-8") + return original_copytree(src, dst) + + monkeypatch.setattr("deerflow.skills.installer.shutil.copytree", _copytree) + + with pytest.raises(ValueError, match="already exists"): + install_skill_from_archive(zip_path, skills_root=skills_root) + + assert (target / "marker.txt").read_text(encoding="utf-8") == "external" + assert not (target / "SKILL.md").exists() + + def test_move_failure_cleans_reserved_target(self, tmp_path, monkeypatch): + zip_path = self._make_skill_zip(tmp_path) + skills_root = tmp_path / "skills" + skills_root.mkdir() + + def _move(src, dst): + Path(dst).write_text("partial", encoding="utf-8") + raise OSError("move failed") + + monkeypatch.setattr("deerflow.skills.installer.shutil.move", _move) + + with pytest.raises(OSError, match="move failed"): + install_skill_from_archive(zip_path, skills_root=skills_root) + + assert not (skills_root / "custom" / "test-skill").exists() + def test_duplicate_raises(self, tmp_path): zip_path = self._make_skill_zip(tmp_path) skills_root = tmp_path / "skills" From 39c5da94f3b723b9062fdf00522cc69cdc837640 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:59:46 +0800 Subject: [PATCH 52/55] fix(sandbox): prevent local custom mount symlink escapes (#2558) * fix(sandbox): prevent local custom mount symlink escapes Fixes #2506 * fix(sandbox): harden custom mount symlink handling * fix(sandbox): format internal symlink directory listings --- .../deerflow/sandbox/local/list_dir.py | 24 ++- .../deerflow/sandbox/local/local_sandbox.py | 76 ++++++-- .../test_local_sandbox_provider_mounts.py | 165 +++++++++++++++++- 3 files changed, 242 insertions(+), 23 deletions(-) diff --git a/backend/packages/harness/deerflow/sandbox/local/list_dir.py b/backend/packages/harness/deerflow/sandbox/local/list_dir.py index b1031d3405..35e51f848f 100644 --- a/backend/packages/harness/deerflow/sandbox/local/list_dir.py +++ b/backend/packages/harness/deerflow/sandbox/local/list_dir.py @@ -22,6 +22,13 @@ def list_dir(path: str, max_depth: int = 2) -> list[str]: if not root_path.is_dir(): return result + def _is_within_root(candidate: Path) -> bool: + try: + candidate.relative_to(root_path) + return True + except ValueError: + return False + def _traverse(current_path: Path, current_depth: int) -> None: """Recursively traverse directories up to max_depth.""" if current_depth > max_depth: @@ -32,8 +39,23 @@ def _traverse(current_path: Path, current_depth: int) -> None: if should_ignore_name(item.name): continue + if item.is_symlink(): + try: + item_resolved = item.resolve() + if not _is_within_root(item_resolved): + continue + except OSError: + continue + post_fix = "/" if item_resolved.is_dir() else "" + result.append(str(item_resolved) + post_fix) + continue + + item_resolved = item.resolve() + if not _is_within_root(item_resolved): + continue + post_fix = "/" if item.is_dir() else "" - result.append(str(item.resolve()) + post_fix) + result.append(str(item_resolved) + post_fix) # Recurse into subdirectories if not at max depth if item.is_dir() and current_depth < max_depth: diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py index ae8c948b06..116a621595 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py @@ -5,6 +5,7 @@ import subprocess from dataclasses import dataclass from pathlib import Path +from typing import NamedTuple from deerflow.sandbox.local.list_dir import list_dir from deerflow.sandbox.sandbox import Sandbox @@ -20,6 +21,11 @@ class PathMapping: read_only: bool = False +class ResolvedPath(NamedTuple): + path: str + mapping: PathMapping | None + + class LocalSandbox(Sandbox): @staticmethod def _shell_name(shell: str) -> str: @@ -91,7 +97,23 @@ def _is_read_only_path(self, resolved_path: str) -> bool: return best_mapping.read_only - def _resolve_path(self, path: str) -> str: + def _find_path_mapping(self, path: str) -> tuple[PathMapping, str] | None: + path_str = str(path) + + for mapping in sorted(self.path_mappings, key=lambda m: len(m.container_path.rstrip("/") or "/"), reverse=True): + container_path = mapping.container_path.rstrip("/") or "/" + if container_path == "/": + if path_str.startswith("/"): + return mapping, path_str.lstrip("/") + continue + + if path_str == container_path or path_str.startswith(container_path + "/"): + relative = path_str[len(container_path) :].lstrip("/") + return mapping, relative + + return None + + def _resolve_path_with_mapping(self, path: str) -> ResolvedPath: """ Resolve container path to actual local path using mappings. @@ -99,22 +121,30 @@ def _resolve_path(self, path: str) -> str: path: Path that might be a container path Returns: - Resolved local path + Resolved local path and the matched mapping, if any """ path_str = str(path) - # Try each mapping (longest prefix first for more specific matches) - for mapping in sorted(self.path_mappings, key=lambda m: len(m.container_path), reverse=True): - container_path = mapping.container_path - local_path = mapping.local_path - if path_str == container_path or path_str.startswith(container_path + "/"): - # Replace the container path prefix with local path - relative = path_str[len(container_path) :].lstrip("/") - resolved = str(Path(local_path) / relative) if relative else local_path - return resolved + mapping_match = self._find_path_mapping(path_str) + if mapping_match is None: + return ResolvedPath(path_str, None) - # No mapping found, return original path - return path_str + mapping, relative = mapping_match + local_root = Path(mapping.local_path).resolve() + resolved_path = (local_root / relative).resolve() if relative else local_root + + try: + resolved_path.relative_to(local_root) + except ValueError as exc: + raise PermissionError(errno.EACCES, "Access denied: path escapes mounted directory", path_str) from exc + + return ResolvedPath(str(resolved_path), mapping) + + def _resolve_path(self, path: str) -> str: + return self._resolve_path_with_mapping(path).path + + def _is_resolved_path_read_only(self, resolved: ResolvedPath) -> bool: + return bool(resolved.mapping and resolved.mapping.read_only) or self._is_read_only_path(resolved.path) def _reverse_resolve_path(self, path: str) -> str: """ @@ -309,8 +339,14 @@ def execute_command(self, command: str) -> str: def list_dir(self, path: str, max_depth=2) -> list[str]: resolved_path = self._resolve_path(path) entries = list_dir(resolved_path, max_depth) - # Reverse resolve local paths back to container paths in output - return [self._reverse_resolve_paths_in_output(entry) for entry in entries] + # Reverse resolve local paths back to container paths and preserve + # list_dir's trailing "/" marker for directories. + result: list[str] = [] + for entry in entries: + is_dir = entry.endswith(("/", "\\")) + reversed_entry = self._reverse_resolve_path(entry.rstrip("/\\")) if is_dir else self._reverse_resolve_path(entry) + result.append(f"{reversed_entry}/" if is_dir and not reversed_entry.endswith("/") else reversed_entry) + return result def read_file(self, path: str) -> str: resolved_path = self._resolve_path(path) @@ -329,8 +365,9 @@ def read_file(self, path: str) -> str: raise type(e)(e.errno, e.strerror, path) from None def write_file(self, path: str, content: str, append: bool = False) -> None: - resolved_path = self._resolve_path(path) - if self._is_read_only_path(resolved_path): + resolved = self._resolve_path_with_mapping(path) + resolved_path = resolved.path + if self._is_resolved_path_read_only(resolved): raise OSError(errno.EROFS, "Read-only file system", path) try: dir_path = os.path.dirname(resolved_path) @@ -384,8 +421,9 @@ def grep( ], truncated def update_file(self, path: str, content: bytes) -> None: - resolved_path = self._resolve_path(path) - if self._is_read_only_path(resolved_path): + resolved = self._resolve_path_with_mapping(path) + resolved_path = resolved.path + if self._is_resolved_path_read_only(resolved): raise OSError(errno.EROFS, "Read-only file system", path) try: dir_path = os.path.dirname(resolved_path) diff --git a/backend/tests/test_local_sandbox_provider_mounts.py b/backend/tests/test_local_sandbox_provider_mounts.py index 328b1d48df..1468e005cd 100644 --- a/backend/tests/test_local_sandbox_provider_mounts.py +++ b/backend/tests/test_local_sandbox_provider_mounts.py @@ -1,4 +1,5 @@ import errno +from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -8,6 +9,13 @@ from deerflow.sandbox.local.local_sandbox_provider import LocalSandboxProvider +def _symlink_to(target, link, *, target_is_directory=False): + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlinks are not available: {exc}") + + class TestPathMapping: def test_path_mapping_dataclass(self): mapping = PathMapping(container_path="/mnt/skills", local_path="/home/user/skills", read_only=True) @@ -29,7 +37,7 @@ def test_resolve_path_exact_match(self): ], ) resolved = sandbox._resolve_path("/mnt/skills") - assert resolved == "/home/user/skills" + assert resolved == str(Path("/home/user/skills").resolve()) def test_resolve_path_nested_path(self): sandbox = LocalSandbox( @@ -39,7 +47,7 @@ def test_resolve_path_nested_path(self): ], ) resolved = sandbox._resolve_path("/mnt/skills/agent/prompt.py") - assert resolved == "/home/user/skills/agent/prompt.py" + assert resolved == str(Path("/home/user/skills/agent/prompt.py").resolve()) def test_resolve_path_no_mapping(self): sandbox = LocalSandbox( @@ -61,7 +69,7 @@ def test_resolve_path_longest_prefix_first(self): ) resolved = sandbox._resolve_path("/mnt/skills/file.py") # Should match /mnt/skills first (longer prefix) - assert resolved == "/home/user/skills/file.py" + assert resolved == str(Path("/home/user/skills/file.py").resolve()) def test_reverse_resolve_path_exact_match(self, tmp_path): skills_dir = tmp_path / "skills" @@ -175,6 +183,157 @@ def test_update_file_blocked_on_read_only(self, tmp_path): assert exc_info.value.errno == errno.EROFS +class TestSymlinkEscapes: + def test_read_file_blocks_symlink_escape_from_mount(self, tmp_path): + mount_dir = tmp_path / "mount" + mount_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + (outside_dir / "secret.txt").write_text("secret") + _symlink_to(outside_dir, mount_dir / "escape", target_is_directory=True) + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False), + ], + ) + + with pytest.raises(PermissionError) as exc_info: + sandbox.read_file("/mnt/data/escape/secret.txt") + + assert exc_info.value.errno == errno.EACCES + + def test_write_file_blocks_symlink_escape_from_mount(self, tmp_path): + mount_dir = tmp_path / "mount" + mount_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + victim = outside_dir / "victim.txt" + victim.write_text("original") + _symlink_to(outside_dir, mount_dir / "escape", target_is_directory=True) + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False), + ], + ) + + with pytest.raises(PermissionError) as exc_info: + sandbox.write_file("/mnt/data/escape/victim.txt", "changed") + + assert exc_info.value.errno == errno.EACCES + assert victim.read_text() == "original" + + def test_write_file_uses_matched_read_only_mount_for_symlink_target(self, tmp_path): + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + writable_dir = repo_dir / "writable" + writable_dir.mkdir() + _symlink_to(writable_dir, repo_dir / "link-to-writable", target_is_directory=True) + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/repo", local_path=str(repo_dir), read_only=True), + PathMapping(container_path="/mnt/repo/writable", local_path=str(writable_dir), read_only=False), + ], + ) + + with pytest.raises(OSError) as exc_info: + sandbox.write_file("/mnt/repo/link-to-writable/file.txt", "bypass") + + assert exc_info.value.errno == errno.EROFS + assert not (writable_dir / "file.txt").exists() + + def test_list_dir_does_not_follow_symlink_escape_from_mount(self, tmp_path): + mount_dir = tmp_path / "mount" + mount_dir.mkdir() + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + (outside_dir / "secret.txt").write_text("secret") + _symlink_to(outside_dir, mount_dir / "escape", target_is_directory=True) + (mount_dir / "visible.txt").write_text("visible") + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False), + ], + ) + + entries = sandbox.list_dir("/mnt/data", max_depth=2) + + assert "/mnt/data/visible.txt" in entries + assert all("secret.txt" not in entry for entry in entries) + assert all("outside" not in entry for entry in entries) + + def test_list_dir_formats_internal_directory_symlink_like_directory(self, tmp_path): + mount_dir = tmp_path / "mount" + nested_dir = mount_dir / "nested" + linked_dir = nested_dir / "linked-dir" + linked_dir.mkdir(parents=True) + _symlink_to(linked_dir, mount_dir / "dir-link", target_is_directory=True) + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/data", local_path=str(mount_dir), read_only=False), + ], + ) + + entries = sandbox.list_dir("/mnt/data", max_depth=1) + + assert "/mnt/data/nested/" in entries + assert "/mnt/data/nested/linked-dir/" in entries + assert "/mnt/data/dir-link" not in entries + + def test_write_file_blocks_symlink_into_nested_read_only_mount(self, tmp_path): + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + protected_dir = repo_dir / "protected" + protected_dir.mkdir() + _symlink_to(protected_dir, repo_dir / "link-to-protected", target_is_directory=True) + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/repo", local_path=str(repo_dir), read_only=False), + PathMapping(container_path="/mnt/repo/protected", local_path=str(protected_dir), read_only=True), + ], + ) + + with pytest.raises(OSError) as exc_info: + sandbox.write_file("/mnt/repo/link-to-protected/file.txt", "bypass") + + assert exc_info.value.errno == errno.EROFS + assert not (protected_dir / "file.txt").exists() + + def test_update_file_blocks_symlink_into_nested_read_only_mount(self, tmp_path): + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + protected_dir = repo_dir / "protected" + protected_dir.mkdir() + existing = protected_dir / "file.txt" + existing.write_bytes(b"original") + _symlink_to(protected_dir, repo_dir / "link-to-protected", target_is_directory=True) + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/repo", local_path=str(repo_dir), read_only=False), + PathMapping(container_path="/mnt/repo/protected", local_path=str(protected_dir), read_only=True), + ], + ) + + with pytest.raises(OSError) as exc_info: + sandbox.update_file("/mnt/repo/link-to-protected/file.txt", b"changed") + + assert exc_info.value.errno == errno.EROFS + assert existing.read_bytes() == b"original" + + class TestMultipleMounts: def test_multiple_read_write_mounts(self, tmp_path): skills_dir = tmp_path / "skills" From 6bd88fe14cfe4d4b83199047326d4455bd656d55 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:18:41 +0800 Subject: [PATCH 53/55] fix(sandbox): block host bash traversal escapes (#2560) * fix(sandbox): block host bash traversal escapes Fixes #2535 * fix(sandbox): harden local bash path guards * fix(sandbox): avoid bash cd argument false positives * Fix the lint error Add function to resolve and validate user data path. * Fix the lint error --------- Co-authored-by: Willem Jiang --- .../harness/deerflow/sandbox/tools.py | 278 ++++++++++++++++-- backend/tests/test_sandbox_tools_security.py | 120 ++++++++ 2 files changed, 373 insertions(+), 25 deletions(-) diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 72b8da8345..73534eb2d7 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -22,6 +22,9 @@ _ABSOLUTE_PATH_PATTERN = re.compile(r"(?()]+)") _FILE_URL_PATTERN = re.compile(r"\bfile://\S+", re.IGNORECASE) +_URL_WITH_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE) +_URL_IN_COMMAND_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s\"'`;&|<>()]+", re.IGNORECASE) +_DOTDOT_PATH_SEGMENT_PATTERN = re.compile(r"(?:^|[/\\=])\.\.(?:$|[/\\])") _LOCAL_BASH_SYSTEM_PATH_PREFIXES = ( "/bin/", "/usr/bin/", @@ -37,6 +40,42 @@ _MAX_GLOB_MAX_RESULTS = 1000 _DEFAULT_GREP_MAX_RESULTS = 100 _MAX_GREP_MAX_RESULTS = 500 +_LOCAL_BASH_CWD_COMMANDS = {"cd", "pushd"} +_LOCAL_BASH_COMMAND_WRAPPERS = {"command", "builtin"} +_LOCAL_BASH_COMMAND_PREFIX_KEYWORDS = {"!", "{", "case", "do", "elif", "else", "for", "if", "select", "then", "time", "until", "while"} +_LOCAL_BASH_COMMAND_END_KEYWORDS = {"}", "done", "esac", "fi"} +_LOCAL_BASH_ROOT_PATH_COMMANDS = { + "awk", + "cat", + "cp", + "du", + "find", + "grep", + "head", + "less", + "ln", + "ls", + "more", + "mv", + "rm", + "sed", + "tail", + "tar", +} +_SHELL_COMMAND_SEPARATORS = {";", "&&", "||", "|", "|&", "&", "(", ")"} +_SHELL_REDIRECTION_OPERATORS = { + "<", + ">", + "<<", + ">>", + "<<<", + "<>", + ">&", + "<&", + "&>", + "&>>", + ">|", +} def _get_skills_container_path() -> str: @@ -635,6 +674,214 @@ def _resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState return str(resolved) +def _is_non_file_url_token(token: str) -> bool: + """Return True for URL tokens that should not be interpreted as paths.""" + values = [token] + if "=" in token: + values.append(token.split("=", 1)[1]) + + for value in values: + match = _URL_WITH_SCHEME_PATTERN.match(value) + if match and not value.lower().startswith("file://"): + return True + return False + + +def _non_file_url_spans(command: str) -> list[tuple[int, int]]: + spans = [] + for match in _URL_IN_COMMAND_PATTERN.finditer(command): + if not match.group().lower().startswith("file://"): + spans.append(match.span()) + return spans + + +def _is_in_spans(position: int, spans: list[tuple[int, int]]) -> bool: + return any(start <= position < end for start, end in spans) + + +def _has_dotdot_path_segment(token: str) -> bool: + if _is_non_file_url_token(token): + return False + return bool(_DOTDOT_PATH_SEGMENT_PATTERN.search(token)) + + +def _split_shell_tokens(command: str) -> list[str]: + try: + normalized = command.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ; ") + lexer = shlex.shlex(normalized, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + lexer.commenters = "" + return list(lexer) + except ValueError: + # The shell will reject malformed quoting later; keep validation + # best-effort instead of turning syntax errors into security messages. + return command.split() + + +def _is_shell_command_separator(token: str) -> bool: + return token in _SHELL_COMMAND_SEPARATORS + + +def _is_shell_redirection_operator(token: str) -> bool: + return token in _SHELL_REDIRECTION_OPERATORS + + +def _is_shell_assignment(token: str) -> bool: + name, separator, _ = token.partition("=") + if not separator or not name: + return False + return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name)) + + +def _is_allowed_local_bash_absolute_path(path: str, allowed_paths: list[str], *, allow_system_paths: bool) -> bool: + # Check for MCP filesystem server allowed paths + if any(path.startswith(allowed_path) or path == allowed_path.rstrip("/") for allowed_path in allowed_paths): + _reject_path_traversal(path) + return True + + if path == VIRTUAL_PATH_PREFIX or path.startswith(f"{VIRTUAL_PATH_PREFIX}/"): + _reject_path_traversal(path) + return True + + # Allow skills container path (resolved by tools.py before passing to sandbox) + if _is_skills_path(path): + _reject_path_traversal(path) + return True + + # Allow ACP workspace path (path-traversal check only) + if _is_acp_workspace_path(path): + _reject_path_traversal(path) + return True + + # Allow custom mount container paths + if _is_custom_mount_path(path): + _reject_path_traversal(path) + return True + + if allow_system_paths and any(path == prefix.rstrip("/") or path.startswith(prefix) for prefix in _LOCAL_BASH_SYSTEM_PATH_PREFIXES): + return True + + return False + + +def _next_cd_target(tokens: list[str], start_index: int) -> tuple[str | None, int]: + index = start_index + while index < len(tokens): + token = tokens[index] + if _is_shell_command_separator(token): + return None, index + if _is_shell_redirection_operator(token): + index += 2 + continue + if token == "--": + index += 1 + continue + if token in {"-L", "-P", "-e", "-@"}: + index += 1 + continue + if token.startswith("-") and token != "-": + index += 1 + continue + return token, index + 1 + return None, index + + +def _validate_local_bash_cwd_target(command_name: str, target: str | None, allowed_paths: list[str]) -> None: + if target is None or target == "-": + raise PermissionError(f"Unsafe working directory change in command: {command_name}. Use paths under {VIRTUAL_PATH_PREFIX}") + if target.startswith(("$", "`")): + raise PermissionError(f"Unsafe working directory change in command: {command_name} {target}. Use paths under {VIRTUAL_PATH_PREFIX}") + if target.startswith("~"): + raise PermissionError(f"Unsafe working directory change in command: {command_name} {target}. Use paths under {VIRTUAL_PATH_PREFIX}") + if target.startswith("/"): + _reject_path_traversal(target) + if not _is_allowed_local_bash_absolute_path(target, allowed_paths, allow_system_paths=False): + raise PermissionError(f"Unsafe working directory change in command: {command_name} {target}. Use paths under {VIRTUAL_PATH_PREFIX}") + + +def _looks_like_unsafe_cwd_target(target: str | None) -> bool: + if target is None: + return False + return target == "-" or target.startswith(("$", "`", "~", "/", "..")) or _has_dotdot_path_segment(target) + + +def _validate_local_bash_root_path_args(command_name: str, tokens: list[str], start_index: int) -> None: + if command_name not in _LOCAL_BASH_ROOT_PATH_COMMANDS: + return + + index = start_index + while index < len(tokens): + token = tokens[index] + if _is_shell_command_separator(token): + return + if _is_shell_redirection_operator(token): + index += 2 + continue + if token == "/" and not _is_non_file_url_token(token): + raise PermissionError(f"Unsafe absolute paths in command: /. Use paths under {VIRTUAL_PATH_PREFIX}") + index += 1 + + +def _validate_local_bash_shell_tokens(command: str, allowed_paths: list[str]) -> None: + """Conservatively reject relative path escapes missed by absolute-path scanning.""" + if re.search(r"\$\([^)]*\b(?:cd|pushd)\b", command): + raise PermissionError(f"Unsafe working directory change in command substitution. Use paths under {VIRTUAL_PATH_PREFIX}") + + tokens = _split_shell_tokens(command) + + for token in tokens: + if _is_shell_command_separator(token) or _is_shell_redirection_operator(token): + continue + if _has_dotdot_path_segment(token): + raise PermissionError("Access denied: path traversal detected") + + at_command_start = True + index = 0 + while index < len(tokens): + token = tokens[index] + + if _is_shell_command_separator(token): + at_command_start = True + index += 1 + continue + + if _is_shell_redirection_operator(token): + index += 1 + continue + + if at_command_start and _is_shell_assignment(token): + index += 1 + continue + + command_name = token.rsplit("/", 1)[-1] + if at_command_start and command_name in _LOCAL_BASH_COMMAND_PREFIX_KEYWORDS | _LOCAL_BASH_COMMAND_END_KEYWORDS: + index += 1 + continue + + if not at_command_start: + index += 1 + continue + + at_command_start = False + if command_name in _LOCAL_BASH_COMMAND_WRAPPERS and index + 1 < len(tokens): + wrapped_name = tokens[index + 1].rsplit("/", 1)[-1] + if wrapped_name in _LOCAL_BASH_CWD_COMMANDS: + target, next_index = _next_cd_target(tokens, index + 2) + _validate_local_bash_cwd_target(wrapped_name, target, allowed_paths) + index = next_index + continue + _validate_local_bash_root_path_args(wrapped_name, tokens, index + 2) + + if command_name not in _LOCAL_BASH_CWD_COMMANDS: + _validate_local_bash_root_path_args(command_name, tokens, index + 1) + index += 1 + continue + + target, next_index = _next_cd_target(tokens, index + 1) + _validate_local_bash_cwd_target(command_name, target, allowed_paths) + index = next_index + + def resolve_and_validate_user_data_path(path: str, thread_data: ThreadDataState) -> str: """Resolve a /mnt/user-data virtual path and validate it stays in bounds.""" return _resolve_and_validate_user_data_path(path, thread_data) @@ -665,33 +912,14 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState unsafe_paths: list[str] = [] allowed_paths = _get_mcp_allowed_paths() + _validate_local_bash_shell_tokens(command, allowed_paths) + url_spans = _non_file_url_spans(command) - for absolute_path in _ABSOLUTE_PATH_PATTERN.findall(command): - # Check for MCP filesystem server allowed paths - if any(absolute_path.startswith(path) or absolute_path == path.rstrip("/") for path in allowed_paths): - _reject_path_traversal(absolute_path) - continue - - if absolute_path == VIRTUAL_PATH_PREFIX or absolute_path.startswith(f"{VIRTUAL_PATH_PREFIX}/"): - _reject_path_traversal(absolute_path) - continue - - # Allow skills container path (resolved by tools.py before passing to sandbox) - if _is_skills_path(absolute_path): - _reject_path_traversal(absolute_path) + for match in _ABSOLUTE_PATH_PATTERN.finditer(command): + if _is_in_spans(match.start(), url_spans): continue - - # Allow ACP workspace path (path-traversal check only) - if _is_acp_workspace_path(absolute_path): - _reject_path_traversal(absolute_path) - continue - - # Allow custom mount container paths - if _is_custom_mount_path(absolute_path): - _reject_path_traversal(absolute_path) - continue - - if any(absolute_path == prefix.rstrip("/") or absolute_path.startswith(prefix) for prefix in _LOCAL_BASH_SYSTEM_PATH_PREFIXES): + absolute_path = match.group() + if _is_allowed_local_bash_absolute_path(absolute_path, allowed_paths, allow_system_paths=True): continue unsafe_paths.append(absolute_path) diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 8c67cd50a6..57466a0feb 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -346,6 +346,104 @@ def test_validate_local_bash_command_paths_blocks_traversal_in_skills() -> None: ) +@pytest.mark.parametrize( + "command", + [ + "cat ../uploads/secret.txt", + "cat subdir/../../secret.txt", + "python script.py --input=../secret.txt", + "echo ok > ../outputs/result.txt", + ], +) +def test_validate_local_bash_command_paths_blocks_relative_dotdot_segments(command: str) -> None: + with pytest.raises(PermissionError, match="path traversal"): + validate_local_bash_command_paths(command, _THREAD_DATA) + + +def test_validate_local_bash_command_paths_blocks_cd_root_escape() -> None: + with pytest.raises(PermissionError, match="Unsafe working directory"): + validate_local_bash_command_paths("cd / && cat etc/passwd", _THREAD_DATA) + + +def test_validate_local_bash_command_paths_blocks_cd_parent_escape() -> None: + with pytest.raises(PermissionError, match="path traversal"): + validate_local_bash_command_paths("cd .. && cat etc/passwd", _THREAD_DATA) + + +def test_validate_local_bash_command_paths_blocks_cd_env_var_escape() -> None: + with pytest.raises(PermissionError, match="Unsafe working directory"): + validate_local_bash_command_paths("cd $HOME && cat .ssh/id_rsa", _THREAD_DATA) + + +def test_validate_local_bash_command_paths_blocks_multiline_cd_escape() -> None: + with pytest.raises(PermissionError, match="Unsafe working directory"): + validate_local_bash_command_paths("echo ok\ncd $HOME && cat .ssh/id_rsa", _THREAD_DATA) + + +@pytest.mark.parametrize( + "command", + [ + "command cd / && cat etc/passwd", + "builtin cd $HOME && cat .ssh/id_rsa", + "if cd $HOME; then cat .ssh/id_rsa; fi", + "{ cd /; cat etc/passwd; }", + 'echo "$(cd $HOME && cat .ssh/id_rsa)"', + ], +) +def test_validate_local_bash_command_paths_blocks_complex_cd_escapes(command: str) -> None: + with pytest.raises(PermissionError, match="Unsafe working directory"): + validate_local_bash_command_paths(command, _THREAD_DATA) + + +@pytest.mark.parametrize( + "command", + [ + "ls /", + "ln -s / root && cat root/etc/passwd", + "command ls /", + ], +) +def test_validate_local_bash_command_paths_blocks_bare_root_path(command: str) -> None: + with pytest.raises(PermissionError, match="Unsafe absolute paths"): + validate_local_bash_command_paths(command, _THREAD_DATA) + + +@pytest.mark.parametrize( + "command", + [ + "echo cd /", + "printf '%s\\n' pushd /", + ], +) +def test_validate_local_bash_command_paths_allows_cd_words_as_arguments(command: str) -> None: + validate_local_bash_command_paths(command, _THREAD_DATA) + + +def test_validate_local_bash_command_paths_allows_workspace_relative_paths() -> None: + validate_local_bash_command_paths( + "mkdir -p reports && python script.py data/input.csv > reports/out.txt", + _THREAD_DATA, + ) + + +def test_validate_local_bash_command_paths_allows_cd_virtual_workspace_with_relative_paths() -> None: + validate_local_bash_command_paths( + "cd /mnt/user-data/workspace && cat data/input.csv > reports/out.txt", + _THREAD_DATA, + ) + + +def test_validate_local_bash_command_paths_allows_http_url_dotdot_segments() -> None: + validate_local_bash_command_paths( + "curl https://example.com/packages/../archive.tar.gz -o /mnt/user-data/workspace/archive.tar.gz", + _THREAD_DATA, + ) + validate_local_bash_command_paths( + "curl http://example.com/packages/../archive.tar.gz -o /mnt/user-data/workspace/archive.tar.gz", + _THREAD_DATA, + ) + + def test_bash_tool_rejects_host_bash_when_local_sandbox_default(monkeypatch) -> None: runtime = SimpleNamespace( state={"sandbox": {"sandbox_id": "local"}, "thread_data": _THREAD_DATA.copy()}, @@ -367,6 +465,28 @@ def test_bash_tool_rejects_host_bash_when_local_sandbox_default(monkeypatch) -> assert "Host bash execution is disabled" in result +def test_bash_tool_blocks_relative_traversal_before_host_execution(monkeypatch) -> None: + runtime = SimpleNamespace( + state={"sandbox": {"sandbox_id": "local"}, "thread_data": _THREAD_DATA.copy()}, + context={"thread_id": "thread-1"}, + ) + + monkeypatch.setattr( + "deerflow.sandbox.tools.ensure_sandbox_initialized", + lambda runtime: SimpleNamespace(execute_command=lambda command: pytest.fail("unsafe command should not execute")), + ) + monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None) + monkeypatch.setattr("deerflow.sandbox.tools.is_host_bash_allowed", lambda: True) + + result = bash_tool.func( + runtime=runtime, + description="run command", + command="cat ../uploads/secret.txt", + ) + + assert "path traversal" in result + + # ---------- Skills path tests ---------- From 395c14357b60926a63af2142ac96bbb670ecb768 Mon Sep 17 00:00:00 2001 From: pyp0327 <108285878+pyp0327@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:09:31 +0800 Subject: [PATCH 54/55] chore(adpator):Adapt MindIE engine model and improve testing and fixes (#2523) 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) * fix(mindie): preserve string args without JSON quotes in XML tool call serialization * fix(mindie): preserve string args without JSON quotes in XML tool call serialization * test_mindie_provider:format * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(mindie): prevent nested tool_call params from leaking into outer args * fixed by escaping XML entities in _fix_messages and unescaping during parse, with regression tests added. --------- Co-authored-by: Willem Jiang Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../deerflow/models/mindie_provider.py | 24 ++++-- backend/tests/test_mindie_provider.py | 83 ++++++++++++++++++- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/backend/packages/harness/deerflow/models/mindie_provider.py b/backend/packages/harness/deerflow/models/mindie_provider.py index 5f0d12e834..a75ae0aba6 100644 --- a/backend/packages/harness/deerflow/models/mindie_provider.py +++ b/backend/packages/harness/deerflow/models/mindie_provider.py @@ -1,4 +1,5 @@ import ast +import html import json import re import uuid @@ -36,8 +37,8 @@ def _fix_messages(messages: list) -> list: 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} ") + args_xml = " ".join(f"{html.escape(v if isinstance(v, str) else json.dumps(v, ensure_ascii=False), quote=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 @@ -80,13 +81,24 @@ def _parse_xml_tool_call_to_dict(content: str) -> tuple[str, list[dict]]: func_match = re.search(r"]+)>", inner_content) if not func_match: continue - function_name = func_match.group(1).strip() + function_name = html.unescape(func_match.group(1).strip()) + + # Ignore nested tool blocks when extracting parameters for this call. + # Nested `` sections represent separate invocations and + # their `` tags must not leak into the current call args. + param_source_parts: list[str] = [] + nested_cursor = 0 + for nested_start, nested_end, _ in _iter_tool_call_blocks(inner_content): + param_source_parts.append(inner_content[nested_cursor:nested_start]) + nested_cursor = nested_end + param_source_parts.append(inner_content[nested_cursor:]) + param_source = "".join(param_source_parts) 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() + for param_match in param_pattern.finditer(param_source): + key = html.unescape(param_match.group(1).strip()) + raw_value = html.unescape(param_match.group(2).strip()) # Attempt to deserialize string values into native Python types # to satisfy downstream Pydantic validation. diff --git a/backend/tests/test_mindie_provider.py b/backend/tests/test_mindie_provider.py index 552966c37c..78bc0d9725 100644 --- a/backend/tests/test_mindie_provider.py +++ b/backend/tests/test_mindie_provider.py @@ -91,7 +91,7 @@ def test_ai_message_with_tool_calls_serialised_to_xml(self): assert isinstance(out, AIMessage) assert "" in out.content assert "" in out.content - assert '"London"' in out.content + assert "London" in out.content assert not getattr(out, "tool_calls", []) def test_ai_message_text_preserved_before_xml(self): @@ -116,6 +116,22 @@ def test_ai_message_multiple_tool_calls(self): assert "" in content assert "" in content + def test_ai_message_tool_args_are_xml_escaped(self): + msg = AIMessage( + content="", + tool_calls=[ + { + "name": "fn<&>", + "args": {"k<&>": "v<&>"}, + "id": "id1", + } + ], + ) + result = _fix_messages([msg]) + content = result[0].content + assert "" in content + assert "v<&>" in content + # ── ToolMessage → HumanMessage ──────────────────────────────────────────── def test_tool_message_becomes_human_message(self): @@ -185,6 +201,15 @@ def test_multiple_tool_calls_parsed(self): assert calls[0]["name"] == "a" assert calls[1]["name"] == "b" + def test_nested_tool_call_blocks_do_not_break_parsing(self): + content = "12" + clean, calls = _parse_xml_tool_call_to_dict(content) + assert clean == "" + assert len(calls) == 1 + assert calls[0]["name"] == "outer" + assert calls[0]["args"] == {"q": 1} + assert "x" not in calls[0]["args"] + def test_text_before_tool_call_preserved(self): content = "Here is the answer.\nv" clean, calls = _parse_xml_tool_call_to_dict(content) @@ -226,6 +251,12 @@ def test_unique_ids_generated(self): _, c2 = _parse_xml_tool_call_to_dict(block) assert c1[0]["id"] != c2[0]["id"] + def test_escaped_entities_are_unescaped(self): + content = "v<&>" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["name"] == "fn<&>" + assert calls[0]["args"]["k<&>"] == "v<&>" + # ═════════════════════════════════════════════════════════════════════════════ # 3. MindIEChatModel._patch_result_with_tools @@ -244,6 +275,12 @@ def test_escaped_newlines_fixed(self): patched = model._patch_result_with_tools(result) assert patched.generations[0].message.content == "line1\nline2" + def test_escaped_newlines_inside_code_fence_preserved(self): + model = self._model() + result = _make_chat_result('text\\n```json\n{"k":"a\\\\nb"}\n```\\nend') + patched = model._patch_result_with_tools(result) + assert patched.generations[0].message.content == 'text\n```json\n{"k":"a\\\\nb"}\n```\nend' + def test_xml_tool_calls_extracted(self): model = self._model() content = "1+1" @@ -281,6 +318,50 @@ def test_non_string_content_skipped(self): assert patched is not None +class TestMindIEInit: + def test_timeout_kwargs_are_normalized(self): + captured = {} + + def fake_init(self, **kwargs): + captured.update(kwargs) + + with patch("deerflow.models.mindie_provider.ChatOpenAI.__init__", new=fake_init): + MindIEChatModel( + model="mindie-test", + api_key="test-key", + connect_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + pool_timeout=4.0, + ) + + timeout = captured.get("timeout") + assert timeout is not None + assert timeout.connect == 1.0 + assert timeout.read == 2.0 + assert timeout.write == 3.0 + assert timeout.pool == 4.0 + + def test_explicit_timeout_takes_precedence(self): + captured = {} + + def fake_init(self, **kwargs): + captured.update(kwargs) + + with patch("deerflow.models.mindie_provider.ChatOpenAI.__init__", new=fake_init): + MindIEChatModel( + model="mindie-test", + api_key="test-key", + timeout=9.0, + connect_timeout=1.0, + read_timeout=2.0, + write_timeout=3.0, + pool_timeout=4.0, + ) + + assert captured.get("timeout") == 9.0 + + # ═════════════════════════════════════════════════════════════════════════════ # 4. MindIEChatModel._generate (sync) # ═════════════════════════════════════════════════════════════════════════════ From e4457a8dc3138de3b406538cf32437183d2a30ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicholas=20Pi=C3=ABl?= Date: Tue, 28 Apr 2026 12:40:08 +0200 Subject: [PATCH 55/55] fix(lint): satisfy ruff on argus patches after upstream merge Upstream's lint workflow caught three issues in our patches that the post-merge tree exposed: - prompt.py: two lines in the block exceeded line-length=240 (E501). Tightened the prose; same meaning, both lines now 224. - test_postgres_aprune.py: ruff I001 wanted the deerflow import grouped with other imports, but it has to live below pytest.importorskip(). Already had # noqa: E402 for that reason; extended to E402, I001. - factory.py + test_postgres_aprune.py: ruff format wanted the weakref dict declaration and two set comprehensions on single lines. Applied uvx ruff format. No behavior change. --- .../harness/deerflow/agents/lead_agent/prompt.py | 4 ++-- .../packages/harness/deerflow/models/factory.py | 4 +--- backend/tests/test_postgres_aprune.py | 14 +++----------- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 3cc0e4cf66..420c792ba7 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -452,14 +452,14 @@ def _build_subagent_section(max_concurrent: int) -> str: - Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_files` tool {acp_section} -**Prefer targeted edits over full rewrites.** When modifying a file you wrote earlier in this conversation, the cost of rewriting is real: every byte of the rewritten file has to be re-emitted as model output and lives in conversation history forever. A 5-line fix in a 500-line file should be 5 lines, not 500. +**Prefer targeted edits over full rewrites.** Rewriting a file you wrote earlier is expensive: every byte gets re-emitted and lives in conversation history forever. A 5-line fix in a 500-line file should be 5 lines, not 500. **Tool ordering when changing files:** 1. `str_replace` — for targeted modifications. Use this whenever you can describe the change as "replace X with Y" in an existing file. Faster than rewriting because only the diff goes through the LLM. 2. `write_file` — for *new* files, or when the change covers more than ~80% of the file's contents. 3. `bash` heredocs (`cat < file`) — avoid for any file you might edit later. The full content gets baked into conversation history. Reserve heredocs for one-off scripts the agent itself will execute and discard. -**Before editing a file you wrote earlier in the same conversation, call `read_file` first.** Don't rely on memory of what you wrote — context can drift. `read_file` is cheap; `str_replace` failing because of a stale "expected old content" is expensive. +**Before editing a file you wrote earlier in the same conversation, call `read_file` first.** Don't rely on memory — context drifts. `read_file` is cheap; `str_replace` failing on stale "expected old content" is expensive. **For deliverables in `/mnt/user-data/outputs`:** write the file once with `write_file`. If you find a bug after writing, use `str_replace` to fix in place. Do not re-run a HEREDOC `cat > ...` to rewrite the whole file. diff --git a/backend/packages/harness/deerflow/models/factory.py b/backend/packages/harness/deerflow/models/factory.py index f924398e95..c353df54e9 100644 --- a/backend/packages/harness/deerflow/models/factory.py +++ b/backend/packages/harness/deerflow/models/factory.py @@ -29,9 +29,7 @@ # # WeakValueDictionary so the client is GC'd when its loop is GC'd; no leak # across long-lived processes that handle thousands of short-lived loops. -_PER_LOOP_HTTPX_CLIENTS: "weakref.WeakValueDictionary[int, httpx.AsyncClient]" = ( - weakref.WeakValueDictionary() -) +_PER_LOOP_HTTPX_CLIENTS: "weakref.WeakValueDictionary[int, httpx.AsyncClient]" = weakref.WeakValueDictionary() def _httpx_client_for_current_loop() -> httpx.AsyncClient | None: diff --git a/backend/tests/test_postgres_aprune.py b/backend/tests/test_postgres_aprune.py index f14e373abf..ea2c984aff 100644 --- a/backend/tests/test_postgres_aprune.py +++ b/backend/tests/test_postgres_aprune.py @@ -23,7 +23,7 @@ # checkpointer at image build time, so the test runs in our CI. pytest.importorskip("langgraph.checkpoint.postgres.aio") -from deerflow.agents.checkpointer import _postgres_aprune # noqa: E402 +from deerflow.agents.checkpointer import _postgres_aprune # noqa: E402, I001 # --------------------------------------------------------------------------- @@ -101,10 +101,7 @@ async def test_keep_latest_issues_two_deletes(self): await _postgres_aprune._aprune(saver, ["t1", "t2"], strategy="keep_latest") assert len(saver.cursor.calls) == 2 - tables_touched = [ - "checkpoints" if "FROM checkpoints" in sql else "checkpoint_writes" - for sql, _ in saver.cursor.calls - ] + tables_touched = ["checkpoints" if "FROM checkpoints" in sql else "checkpoint_writes" for sql, _ in saver.cursor.calls] assert set(tables_touched) == {"checkpoints", "checkpoint_writes"} # Blobs must not be touched for sql, _ in saver.cursor.calls: @@ -119,12 +116,7 @@ async def test_delete_all_touches_all_three_tables(self): await _postgres_aprune._aprune(saver, ["t1"], strategy="delete_all") assert len(saver.cursor.calls) == 3 - tables = { - "checkpoints" if "FROM checkpoints " in sql else - "checkpoint_blobs" if "FROM checkpoint_blobs " in sql else - "checkpoint_writes" - for sql, _ in saver.cursor.calls - } + tables = {"checkpoints" if "FROM checkpoints " in sql else "checkpoint_blobs" if "FROM checkpoint_blobs " in sql else "checkpoint_writes" for sql, _ in saver.cursor.calls} assert tables == {"checkpoints", "checkpoint_blobs", "checkpoint_writes"} async def test_unknown_strategy_raises(self):