Compare commits

..

No commits in common. "main" and "v2.0-m0" have entirely different histories.

1681 changed files with 16191 additions and 377446 deletions

View File

@ -1,141 +0,0 @@
---
name: blocking-io-guard
description: Ensure async-path backend code that could block the asyncio event loop is protected by a teeth-verified runtime anchor in tests/blocking_io/. Use when changing backend Python under app/, packages/harness/deerflow/, or scripts/, when running a blocking-IO triage round over the whole repo, or when a reviewer/CI asks for blocking-IO coverage. Runs a deterministic scan (changed-lines or full-repo), routes each candidate, drafts/extends an anchor, and proves it fails when the blocking IO regresses.
---
# Blocking-IO Guard Skill
Help a contributor ship backend async changes together with the runtime anchor
that lets DeerFlow's blocking-IO CI gate actually see the new code. The dynamic
detector only catches blocking IO on paths a test executes — this skill closes
that gap, either for your own diff or for a repo-wide triage round.
Read `references/good-anchor-rules.md` before writing any anchor.
Only read `references/sop-skeleton.md` when generalizing this SOP to another
detector domain — it is not needed to execute the steps below.
## When to use
- Your change touches Python under `backend/app/`,
`backend/packages/harness/deerflow/`, or `backend/scripts/` and may run on
the async event loop (Mode A). If unsure, run Step 0 — it answers
deterministically.
- You are doing a maintenance triage round over the existing codebase
(Mode B).
## SOP (router)
### Step 0 — Scope (deterministic)
**Mode A — your own diff** (default, pre-PR). From repo root:
```bash
uv run --project backend python scripts/scan_changed_blocking_io.py --base origin/main
```
Lists blocking-IO candidates your change introduces: findings on lines the
diff added, **plus** findings that are new versus the merge base — the latter
catches a new async caller exposing an old sync helper whose blocking line is
not in the diff. The diff is `<base>...HEAD`, so **commit your work first**
uncommitted lines are not selected.
If the list is empty, this change introduces no blocking-IO surface *that the
static detector can see in the changed files*. One residual blind spot
remains: reachability is same-file only, so a new async caller of a sync
helper **defined in another file** is invisible to both selections. If your
diff adds an async call into a helper that lives elsewhere, check that helper
manually (codegraph or `git grep`) before stopping.
**Mode B — full-repo triage round.** From repo root:
```bash
make detect-blocking-io
```
Prints a summary and writes the complete structured finding list to
`.deer-flow/blocking-io-findings.json`. Work HIGH priority first; do not start
MEDIUM until every HIGH is dispositioned (fixed, guarded, or recorded
NO-ACTION).
**Batching policy (PR sizing).** One **fix unit** per PR while any HIGH
remains: a fix unit is one root cause — usually a single HIGH, but two HIGHs
resolved by the same one-place fix belong together. Once no HIGH remains,
MEDIUM/LOW may be batched (about five per round, grouped by module or by
disposition) so each PR stays reviewable. A new Blockbuster rule is never
batched with anything — it always ships alone (see Step 5).
Both modes emit the same JSON shape per finding: `priority`, `location`
(path/line/function), `blocking_call` (category/operation/symbol),
`event_loop_exposure`, `reason`, `code`. Priority is a deterministic review
ordering, not proof of a bug — Step 1 makes the actual call.
### Step 1 — Judge each candidate (router)
Read the code around each candidate and route it:
- **Already offloaded** (`asyncio.to_thread`, `run_in_executor`, async client) →
**GUARD**: add/extend an anchor that locks the offload so a future edit cannot
move it back onto the loop.
- **On the loop, not offloaded****FIX+ANCHOR**: offload the production code
(your fix), then add an anchor that guards it.
- **Not actually exposed / acceptable** (rare: scanner false positive,
startup-only code) → **NO-ACTION**: record one line of why.
- **Cross-file caveat**: the scanner's async reachability is same-file only
(`ASYNC_REACHABLE_SAME_FILE`). If the candidate is a *sync helper*, check for
async callers in other files (codegraph or `git grep`) before deciding
NO-ACTION.
### Step 2 — Apply the fix, then re-scan (FIX+ANCHOR only)
Offload the blocking call in production code, then re-run the Step 0 scan and
confirm the candidate no longer appears. If the offloaded call sits in a
`finally` / cleanup path, keep it best-effort and bounded (swallow-and-log,
`asyncio.wait_for`) so a failing or hung cleanup cannot mask the primary
exception. Match by the stable key
**(path, function, symbol)** — line numbers shift after edits, so never
compare by line.
- The finding must disappear. If it still shows, the fix did not remove the
blocking pattern (e.g. the call is still a direct call, not offloaded) —
go back before touching any test.
- GUARD / NO-ACTION routes skip this step: a residual finding there is
*expected* (the raw call still exists inside a sync helper with the offload
at the caller, or the exposure was judged acceptable).
This is pattern-level feedback in seconds; it complements but never replaces
Step 5 — only the runtime gate proves the event loop is actually protected.
### Step 3 — Check existing anchors
Look in `backend/tests/blocking_io/` for a test that drives the production async
entry point reaching this candidate's branch.
- Covers this branch already → go to Step 5 (re-verify teeth).
- Covers the entry point but not this branch (e.g. happy path covered,
cleanup/404/409 not) → **extend** that anchor.
- None → create one from `templates/anchor.template.py`.
### Step 4 — Generate / extend the anchor
Follow `references/good-anchor-rules.md`. Drive the *specific* branch (e.g. force
the create failure that hits the cleanup `shutil.rmtree`). Never bypass the
blocking surface with a test-only `asyncio.to_thread` wrapper.
### Step 5 — Verify teeth (mandatory; also the anchor-vs-rule discriminator)
1. Reintroduce the block (GUARD: temporarily revert the offload; FIX+ANCHOR: run
against the pre-fix code).
2. Run `cd backend && make test-blocking-io` (or target the one test). It **must
go RED**.
3. Restore the fix. It **must go GREEN**.
A real block that stays GREEN means Blockbuster has no rule for that
primitive — that is the **RULE** route; see `references/good-anchor-rules.md`
for the admission criteria before adding one.
### Step 6 — Deliver
Commit the anchor(s) with your change; `make test-blocking-io` green. In the PR,
note: candidates found, each disposition, the re-scan result (Step 2), and
the teeth evidence (red→green). Include the reason for any NO-ACTION. A new
Blockbuster rule, if any, goes in its own commit with the evidence from Step 5.

View File

@ -1,65 +0,0 @@
# Good anchor rules + teeth (blocking-IO fill)
Distilled from `backend/docs/BLOCKING_IO_DETECTION.md`. An anchor lives in
`backend/tests/blocking_io/`; the suite's conftest runs each test under the
strict Blockbuster gate scoped to `app.*` / `deerflow.*`.
The examples in this file and in `templates/` are all filesystem-flavored.
They demonstrate how to *write* the test, not what the SOP covers: the same
rules apply to every category the detector reports (FILE_IO, HTTP,
SUBPROCESS, SLEEP), and the acceptance criterion is always the teeth check
below — never similarity to an example.
## A good anchor
- Calls the **real production async entry point** — not a low-level helper,
unless that helper *is* the entry point production executes.
- Does **not** bypass the blocking surface with a test-only
`asyncio.to_thread` / `run_in_executor` wrapper.
- Uses **real local filesystem** inputs when the bug shape is filesystem IO.
- Mocks **only** the external dependency boundary (network service, third-party
saver), never the offload being guarded.
- Drives the **specific branch** you are protecting (error / cleanup / 404 /
409), not just the happy path.
## Teeth (the acceptance test)
An anchor only counts if the gate actually fires when the code blocks:
1. Reintroduce the block (revert the offload, or run pre-fix code).
2. `cd backend && make test-blocking-io` → the anchor **must fail** (RED).
3. Restore the fix → the anchor **must pass** (GREEN).
A green-on-happy-path anchor with no proven red is fake coverage. Don't ship it.
## The RULE route (rare; strict admission criteria)
Blockbuster's built-in rules cover the common blocking primitives well. The
two deliberate openings in this SOP are:
1. **Coverage opening** (the normal case): the rules already see the
primitive — you only need an anchor so runtime detection executes the real
business path and CI prevents regression.
2. **Rule opening** (rare): you reintroduced a *real* block and the gate
stayed GREEN — Blockbuster has no rule for that primitive.
A project rule lives in `_PROJECT_BLOCKING_RULES` inside
`backend/tests/support/detectors/blocking_io_runtime.py` and changes detection
for the **entire** blocking-IO suite — global blast radius. Admission criteria
for adding one:
- You have the **fails-to-fail anchor** as evidence: a good anchor (per the
rules above) that drives a genuinely blocking path and stays green. No
evidence, no rule.
- The primitive is a real blocking call (verified against its implementation
or docs), not a false positive of the static detector.
- The rule ships in its **own commit**, naming the primitive, the anchor that
exposed the gap, and the suite-wide impact. Run the full
`make test-blocking-io` suite after adding it — a new rule can turn other
previously-green tests red, and each such red is either a real latent bug
(fix it) or rule overreach (narrow the rule).
- If you are not in a position to own that blast radius (e.g. external
contributor), escalate to a maintainer with the evidence instead.
**Never add a runtime rule just because a path is untested** — that case needs
an anchor, not a rule.

View File

@ -1,34 +0,0 @@
# SOP skeleton (generic shape — extraction seam)
This is the domain-agnostic shape the blocking-IO skill instantiates. It exists
so a second detector/gate domain can reuse the flow without copying it. Do not
add machinery for that until a second domain actually appears (YAGNI).
A domain provides:
- a **static detector** that can scan a diff (or the whole tree) and emit
located candidates,
- a **CI gate** that fails when the bad pattern executes,
- a **test location** for guard tests,
- **good-test rules** for that gate,
- a **teeth definition** (how to make the gate fire on purpose).
Steps:
1. **Scope (deterministic):** intersect the diff's added lines with the
detector's findings → candidates this change introduced/touched. (Or, in
triage mode, take the full finding list ordered by priority.)
2. **Judge (router):** per candidate — guard existing fix / fix + guard /
no-action / rule (the gate cannot see the primitive).
3. **Fix + re-scope (fixes only):** apply the fix, re-run the detector; the
fixed candidate must vanish from the findings (match by a stable key, not
line numbers). Pattern-level feedback in seconds — complements, never
replaces, step 5.
4. **Generate:** draft or extend a guard test per the good-test rules, driving
the specific branch.
5. **Verify teeth:** make the bad pattern happen → gate must fail; restore →
gate must pass. A pattern that stays green while genuinely bad is the
"rule" signal, not a coverage success.
6. **Deliver:** commit the verified guard test; any gate-rule change ships in
its own commit with the fails-to-fail evidence attached.
To add a domain: supply a new fill doc (like `good-anchor-rules.md`) + detector,
and promote this file into a parent skill the instances point at.

View File

@ -1,32 +0,0 @@
"""Template: a tests/blocking_io/ runtime anchor.
Copy into backend/tests/blocking_io/test_<area>.py and adapt. The suite's
conftest already wraps every test here in the strict Blockbuster gate, so you do
NOT import or activate the detector just drive the real async entry point.
Teeth check before you commit (see references/good-anchor-rules.md):
1. reintroduce the block -> `cd backend && make test-blocking-io` must FAIL
2. restore the fix -> it must PASS
"""
from __future__ import annotations
from pathlib import Path
import pytest
# from app.<module> import <real_async_entry_point>
pytestmark = pytest.mark.asyncio
async def test_<entry_point>_offloads_blocking_io_on_<branch>(tmp_path: Path) -> None:
# Arrange: real inputs at the boundary the code blocks on (FS -> tmp_path;
# HTTP/subprocess -> stub the external service). Mock ONLY the external
# boundary, never the offload under test.
# Act + Assert: call the REAL production async entry point and drive the
# specific branch you are guarding (e.g. force a failure to hit the cleanup
# path). If the entry point performs blocking IO on the loop, the gate fails.
# await <real_async_entry_point>(...)
raise NotImplementedError("Replace with the real async entry point call.")

View File

@ -1,293 +0,0 @@
---
name: deerflow-maintainer-orchestrator
description: "Use when a DeerFlow maintainer needs comment-only GitHub issue or PR handling: resolve issue/PR scopes with gh, analyze issues, post or draft issue comments, perform PR review comments, review PR or issue batches, compare competing PRs that target the same issue, give fix strategy, risk classification, and validation guidance. Intended for maintainers and trusted local agents, not general contributors."
---
# DeerFlow Maintainer Orchestrator
## Core Rule
This is a comment-plane skill: resolve GitHub scope, inspect evidence, and prepare or post DeerFlow issue comments and PR review comments. Keep the work comment-scoped; do not turn it into coding, branch management, release work, artifact closure, or other maintainer operations.
When the maintainer asks to process, handle, comment on, or review a bounded set of issues or PRs, proceed without asking follow-up questions. Treat that request as authorization for one public issue comment per selected non-skipped issue and one PR review comment per selected PR with high-confidence findings. If a PR has no high-confidence findings, do not post a public comment; report that result to the maintainer only. If the maintainer explicitly asks for analysis only, return comment-ready drafts without posting.
The maintainer's normal interaction should be: provide scope; receive posted comment URLs, PR review URLs, clean results, skipped items, failures, or drafts. Do not offload technical analysis to the maintainer. Make the best evidence-backed recommendation in the comment itself: describe the risk, impact, likely fix, and validation path. Ask the reporter or PR author for missing evidence only when the artifact lacks enough data to diagnose.
Output only the maintainer run result or comment draft. Do not announce the skill name, mode, or that no code was edited unless the user asks for process details.
Match the dominant language of the issue or PR unless the maintainer asks for another language. Chinese issue or PR text gets Chinese output; English issue or PR text gets English output. For mixed artifacts, use the body language, not logs or code.
## Artifact Resolution
Use GitHub tooling to resolve artifact type and scope. Do not ask the maintainer to clarify when `gh` or GitHub API can determine the answer.
1. Default repository is `bytedance/deer-flow` unless a URL or explicit repo says otherwise.
2. For URLs, route `/issues/<number>` to Issue Flow and `/pull/<number>` to PR Review Flow.
3. For typed numbers, use the typed command:
- Issue: `gh issue view <number> --repo <repo> --json number,title,url,state,body,labels,author,comments`
- PR: `gh pr view <number> --repo <repo> --json number,title,url,state,body,author,files,comments,reviews,statusCheckRollup,baseRefName,headRefName`
4. Normalize multiple explicit references such as `#123`, `# 123`, and bare `123` into a number list, preserving order and de-duplicating exact repeats.
5. For untyped numbers, try `gh pr view <number> --repo <repo> --json number,url` first. If it fails, use `gh issue view <number> --repo <repo> --json number,url`. Do not ask which type it is.
6. For issue batches, use `gh issue list`, not the mixed GitHub issues endpoint. For PR batches, use `gh pr list`.
7. Respect maintainer-provided count or time window. There is no hard five-item cap. If the scope is broad and underspecified, choose a practical recent slice, state the slice used, prioritize newest and highest-risk items, and report any unprocessed remainder.
8. For "recent/latest" wording without a count, use a small default recent slice. For "recent hours" wording without a number, use six hours. Do not ask.
9. Use `gh api` when `gh issue/pr view/list` lacks required fields such as timeline events, review threads, or precise search filters.
10. Use GitHub search only as a fallback for natural-language filters that cannot be represented by view/list/API calls. Do not use web search for artifact routing unless GitHub tooling is unavailable.
11. When an issue has more than one candidate resolving PR, gather them all before reviewing: the issue's linked/Development PRs, closing keywords (`Closes/Fixes #<issue>`) found via `gh api` timeline cross-reference events, and PRs that mention the issue. Route them into Competing PR Comparison.
12. If no artifact type, number, URL, count, time window, or searchable GitHub scope can be resolved, stop with a compact "scope unresolved" report. Do not ask a follow-up question.
Use concise repo-local references such as `#123` and `PR #123` in maintainer reports and comments. Include full GitHub URLs only for posted comment/review links returned by GitHub or when the maintainer supplied an explicit URL.
## Existing Coverage and Re-Runs
Existing comments suppress duplicate **posting**, not **analysis**. Always analyze the artifact in full, then post only the net-new delta over what is already covered.
1. Read existing maintainer/trusted-agent comments and reviews as prior coverage.
2. Analyze the artifact fully regardless of what already exists. A prior comment may be partial — catching A while missing B.
3. Keep only net-new, high-confidence items not already materially covered.
4. Non-empty delta: post one comment that explicitly builds on the prior coverage (for example `Adding to @reviewer's review:`) and states only the new items. Do not restate covered points.
5. Empty delta: post nothing public; report `Already covered` to the maintainer with the existing comment/review URL.
6. Idempotency: treat your own earlier skill-authored comments as already-covered. On a re-run, never stack a second comment that repeats an earlier one — post only genuinely new delta, or nothing.
RFC issues are the one hard skip: no analysis and no post unless the maintainer overrides.
## Issue Flow
Use Issue Flow for GitHub issues, bug reports, feature requests, support questions, and issue batches.
Start every issue with a cheap precheck:
1. Fetch issue metadata, labels, author, body, and existing comments.
2. If labels, title, or body mark the issue as RFC (`rfc`, `[RFC]`, `RFC:`, or `Request for Comments`), classify it as `rfc-no-comment`, skip deep analysis, and do not post anything public unless the maintainer explicitly overrides the RFC skip for that item.
3. Existing maintainer or trusted-agent comments are prior coverage, not an automatic skip. Analyze fully and post only the net-new delta (see Existing Coverage and Re-Runs).
4. Treat ordinary reporter replies, thanks, unrelated discussion, or incomplete guesses as non-blocking.
5. Report already-covered or skipped issues to the maintainer only as compact identifiers plus the reason or existing comment URL when available.
For non-skipped issues:
1. Read enough context to avoid guessing: issue body, comments, screenshots, logs, reproduction details, linked artifacts, and relevant DeerFlow code/docs.
2. Classify the surface:
- Frontend UI
- Backend API
- Agents / LangGraph
- Sandbox
- Skills
- MCP
- Dependencies
- Default behavior
- Docs / tests / CI only
3. Classify actionability:
- `ready-to-fix`: bounded, evidence sufficient, validation path clear.
- `needs-more-evidence`: repro, logs, environment, screenshots, exact expected behavior, or failing case missing.
- `defer-or-close`: duplicate, stale, unsupported, unactionable, or out of scope.
- `rfc-no-comment`: RFC issue; skip public comments by default.
4. Produce a public-safe comment from the analysis, not the analysis labels:
- Start with one natural opener that connects to the issue context. Prefer `Thanks @author.` for reporter-authored issues when it reads naturally; omit the mention for bots, maintainer-authored tracking issues, or cases where it would add noise.
- The opener must say something specific about the next step or boundary, not a generic assessment. Do not use generic phrases such as "This is actionable", "I would treat this as", "ready to fix", or surface/actionability/risk labels.
- Use the smallest stable template that fits:
```text
Thanks @author. <one specific sentence that frames the fix, investigation, or missing evidence.>
Recommended solution:
- ...
Validation:
- ...
```
- Add `Evidence:` only when citing concrete code, logs, reproduction details, or other proof helps the author act.
- Add `Risk:` only when architecture, security, public API, default behavior, or compatibility impact must be called out explicitly; make the risk specific.
- Add `Missing info:` only when the issue cannot be diagnosed without more evidence; ask for the smallest useful data.
- Put relevant files/components inside `Evidence:` or `Recommended solution:` bullets instead of separate metadata fields.
- Every posted issue comment should contain concrete modification guidance and validation guidance unless the only useful response is `Missing info:`.
5. Immediately before posting, refresh comments; fold any equivalent comment that appeared during analysis into prior coverage and post only the remaining delta.
6. Post one issue comment when posting is authorized; otherwise return the same text as `Reply draft`.
Do not expose private reasoning, credentials, internal-only context, or unsupported promises. Do not say a fix was made unless a separate coding workflow actually changed code.
## PR Review Flow
Use PR Review Flow for GitHub pull requests and PR batches.
Start every PR with a cheap precheck:
1. Fetch PR metadata, changed file list, checks summary, existing PR reviews, existing PR comments, and review threads when available.
2. Existing maintainer or trusted-agent reviews are prior coverage, not an automatic skip. Review fully and post only the net-new delta (see Existing Coverage and Re-Runs).
3. Read `statusCheckRollup` as signal, not verdict. Failing required checks are themselves a reportable finding (build failure = P0; failing tests or lint = P1/P2 by impact). Green checks lower risk but never excuse reading the actual changed code path — confirm suspect logic by reading the source, not by trusting green CI. Tests passing does not prove the changed branch is exercised.
4. Treat author replies, thanks, unrelated discussion, or incomplete guesses as non-blocking.
5. Report already-covered or clean PRs to the maintainer only, with the existing review/comment URL when available.
### Diff Base Rule
Before reviewing a local PR branch or local diff, fetch the base repository's target branch and compare against that fresh remote-tracking ref, not a possibly stale local `main`.
- For fork checkouts, prefer `upstream/<base-branch>` when `upstream` points to the base repository.
- For direct upstream checkouts, use the base remote's fetched branch, usually `origin/<base-branch>`.
- Prefer GitHub PR base metadata for the target branch. For non-PR local diffs, use the base repository default branch. If metadata is unavailable, default to `main` only after fetching the base remote.
- Refresh the comparison ref explicitly, for example `git fetch <base-remote> +refs/heads/<base-branch>:refs/remotes/<base-remote>/<base-branch>`, then inspect `BASE=$(git merge-base HEAD <base-remote>/<base-branch>)` and `git diff "$BASE"...HEAD`.
- If using `FETCH_HEAD` from a single-branch fetch instead, diff against that verified `FETCH_HEAD` immediately and do not later substitute a possibly stale remote-tracking ref.
- Resolve the PR head explicitly. For fork PRs whose head branch is not on the base repo, fetch the PR ref: `git fetch <base-remote> pull/<n>/head:pr-<n>`. The fork's own branch ref and `gh api .../contents?ref=<fork-branch>` will 404 against the base repo. Record the head SHA you reviewed.
- Re-check the head SHA immediately before posting. If the PR head moved during analysis, re-review the new diff or abort — never post a review against a diff the PR no longer has.
- For uncommitted local changes, review committed branch changes against the fresh base first, then include working-tree changes separately.
- If the base remote or base branch cannot be established, use the GitHub PR files/diff as the source of truth. If neither local nor GitHub diff can be read, return a compact failure report and do not post a review.
Before posting a PR review comment:
1. Review only the current diff against the fresh base and changed files. Do not comment on unrelated pre-existing code unless the diff makes it newly risky.
2. Do not report low-confidence guesses. If evidence is insufficient, omit the finding.
3. Prioritize correctness, safety, maintainability, production risk, compatibility, and missing critical tests over style.
4. Report concrete architecture, security, public API, default-behavior, and compatibility problems as findings when the diff causes or exposes them.
5. Check changed behavior, edge cases, error paths, state mutation, transactions, locks, cache invalidation, cleanup, security boundaries, missing tests, performance/reliability, and API compatibility.
6. Immediately before posting, refresh reviews/comments and fold any equivalent review that appeared during analysis into prior coverage; post only the remaining delta.
7. Apply the Posting Gate. If the gate yields public findings, post one PR review comment in the PR language. Otherwise post nothing public and report the result (`No high-confidence review findings.` or `Already covered`) plus any sub-threshold items as `Maintainer notes`.
For public PR reviews with findings, start with one short opener that fits the review context and matches the finding count. Use singular wording only for exactly one finding, for example `Thanks @author. I found one issue that should be addressed before this is ready.` Use plural wording for multiple findings, for example `Thanks @author. I found a few issues that should be addressed before this is ready.` Omit the mention for bots or when it adds noise.
For each finding, use:
```text
[P0/P1/P2] Title
- Location: file and line/range
- Problem: what can go wrong
- Evidence: why the diff causes it
- Suggested fix: concrete minimal fix
- Test: what test should cover it
```
Severity guide:
- `P0`: causes outage, data loss, security breach, or build failure.
- `P1`: likely production bug, serious regression, broken compatibility, or high-risk security/architecture issue.
- `P2`: correctness, maintainability, or test concern with lower risk.
### Posting Gate
Posting depends on BOTH confidence (is the problem real?) and severity (how bad if real). They are independent axes — "no high-confidence findings" means none across P0/P1/P2, not merely "no P0".
- Post publicly only items that are high-confidence AND at least P2.
- For a public P2, additionally require that the diff itself introduces or worsens the issue. Do not raise a public P2 for pre-existing behavior the diff only touches, or for a change that is a net improvement over the prior state.
- A high-confidence P0/P1 is always worth posting. A low-confidence P1 is not — omit it, or route it to `Maintainer notes` framed as a hypothesis to verify.
- Sub-threshold but real observations (net-improvement nits, bounded or low-risk concerns, pre-existing issues, low-confidence hypotheses) go to the `Maintainer notes` channel in the run result, never to a public comment.
Do not produce compliments, summaries, or general advice. For sensitive security issues, describe impact and remediation without exploit instructions.
## Batch Handling
When the scope has multiple artifacts, cluster before reviewing and synthesize after.
Cluster by relatedness, not by type. Group artifacts that share files, interfaces, or the same issue/feature into one cluster; same-type artifacts that touch disjoint files are independent.
- Related cluster: review in ONE shared context so cross-artifact reasoning is possible — parallel agents cannot see each other's findings. If it cannot fit one context, fan out per sub-group and reconcile in the synthesis pass; never split it blind, without that re-aggregation.
- Independent clusters: may run in parallel. Offloading a large or independent batch to one subagent per cluster keeps the main context clean — consider it for big batches, prefer offering it to the maintainer over silently spawning, and do not spawn for two or three related items or when the cold-start cost is not earned.
After per-artifact review, run one synthesis pass over the whole batch and report it to the maintainer (decision-support, not a public comment):
- Overlapping files and merge-order/conflict surface — which PRs touch the same files and will conflict pairwise.
- Duplicate or competing solutions to the same problem.
- Composition risk — changes each safe alone but interacting (for example, two PRs editing the same module or table).
## Competing PR Comparison
When several PRs target the same issue, compare them instead of reviewing each in isolation.
1. Pull the issue's acceptance criteria (reported problem and expected behavior); that is the rubric anchor.
2. Score each PR on: does it actually resolve the issue's ask; correctness and edge/error-path coverage; test quality; blast radius and compatibility; maintainability. Use the same DeerFlow Review Heuristics and Posting Gate as a single review.
3. Report a maintainer-facing comparison — strongest PR and why, what each is missing — in the run result.
4. Keep the public surface constructive and per-PR: post each PR's own gate-passing findings normally. Do not publicly rank PRs against each other or tell an author their PR is worse than a competitor's; winner selection stays in the maintainer report.
## No-Question Policy
Do not ask the maintainer routine clarification questions. The skill should save maintainer time by turning scope into comments through a fixed workflow.
Stop without asking only when:
- no issue/PR scope can be resolved through URLs, numbers, `gh` view/list, `gh api`, or GitHub search fallback;
- GitHub authentication, repository access, or comment posting fails;
- the requested action is outside comment-only scope;
- posting would require private credentials, private security details, or non-public context.
In these cases, return a compact failure report with the attempted command path and the smallest next action. Do not phrase it as a question unless the maintainer explicitly asked to be prompted.
## DeerFlow Review Heuristics
Treat these as high-signal areas for issue comments and PR findings:
- `backend/packages/harness/deerflow/` must not import `app.*`.
- App may depend on harness; harness must stay publishable and app-agnostic.
- Frontend thread/message behavior and Gateway/LangGraph-compatible SSE are contract surfaces.
- Sandbox permissions, bash/file-write tools, skill installation, and remote execution are security-sensitive.
- Default model/provider behavior, config migration, persistence schema, public API/SSE, and LangGraph thread/run lifecycle are compatibility-sensitive.
- Runtime docs should track user-facing or developer-facing behavior changes.
- Security-sensitive comments should provide proof and remediation, not vague assertions.
## Validation Guidance
Recommend the checks matching the touched surface:
| Surface | Suggested validation |
| --- | --- |
| Backend API / harness / agents / MCP / skills runtime | `cd backend && make lint && make test` |
| Blocking IO or async file/network work | `cd backend && make test-blocking-io` or a focused blocking-IO regression |
| Harness/app boundary | `cd backend && uv run pytest tests/test_harness_boundary.py` |
| Frontend UI/core | `cd frontend && pnpm format && pnpm lint && pnpm typecheck && BETTER_AUTH_SECRET=local-dev-secret pnpm build && make test` |
| Front/back thread or SSE contract | backend replay golden and full-stack replay render where feasible |
| Frontend user workflow | Playwright E2E or browser proof with screenshot/DOM assertion |
| Docker/sandbox/provisioner | focused backend tests plus Docker/provisioner smoke when feasible |
| Docs-only | targeted markdown review |
## Output
For Issue Flow:
```text
Run result:
Posted:
Skipped:
Already covered:
Failed:
Maintainer notes:
Per issue:
Issue:
Surface:
Actionability:
Risk:
Comment:
Validation:
Comment status:
```
For PR Review Flow:
```text
Run result:
Reviewed:
Skipped:
Clean:
Already covered:
Failed:
Maintainer notes:
Per PR:
PR:
Public review:
Findings:
Review status:
```
For analysis-only requests, replace `Posted`/`Reviewed` with `Drafted` and include the comment/review text without posting.
For batches, prefer a compact maintainer-facing table after the headline counts:
```text
| Artifact | Status | Public action | Notes |
| --- | --- | --- | --- |
| #123 | posted | comment URL | short reason |
| PR #456 | reviewed | review URL | P1: finding title |
| PR #789 | clean | none | No high-confidence review findings. |
| #321 | already covered | none | existing maintainer comment |
```
For multi-artifact batches, follow the table with a `Batch synthesis` block (overlapping files, merge-order/conflict surface, duplicate or competing solutions, composition risk) and, when issues had competing PRs, a `Competing PR comparison` block. Both are maintainer-only.
Omit empty categories, no-op fields, routine command output, and raw logs. Report meaningful changes, evidence, and options.

View File

@ -1,134 +0,0 @@
---
name: engineer-system-change
description: Evaluate and carry out non-trivial software-system changes from first principles. Use when assessing RFCs, issues, designs, features, refactors, migrations, dependency changes, or proposed fields, events, APIs, modules, and services whose need, consumers, system fit, validation, or rollback require scrutiny. Read the actual system, identify the concrete problem and named semantic consumers, choose the smallest sufficient solution, reject pseudo-requirements and speculative abstractions, and require evidence proportional to risk. Do not use for mechanical edits, source-code explanation, or a dedicated review of an already-complete diff.
---
# Engineer System Change
Treat every proposed change as a hypothesis about a real system, not as an implementation checklist. Establish whether the change should exist before designing or building it, then keep the solution and the process proportional to the risk.
## Preserve the Task Boundary
- If asked only to assess, review, or plan, make no project or external-state changes.
- If explicitly asked to implement, including after an assessment, pass the decision gates before editing and verify the result afterward.
- Treat implementation permission as separate from permission to commit, push, deploy, publish, or update issues and pull requests.
- If repository truth matters, inspect the current target revision and relevant discussion. Do not rely on a stale checkout, an RFC alone, or remembered architecture.
- Separate verified facts, inferences, and unknowns. Do not turn missing evidence into a confident conclusion.
## Apply the Decision Gates
### 1. Ground the Problem
- Trace the current user workflow, failure, or code path before proposing a solution.
- State the undesirable observable behavior and the invariant or outcome that should replace it.
- Identify who is affected and which concrete decision or action changes.
- Check whether the existing system, configuration, documentation, or operating procedure already solves the problem.
- Enumerate adjacent product paths and workarounds, not only the proposed target surface. Explain precisely which accepted outcome each alternative fails; do not claim “the only option” from one missing UI control or code path.
- Treat an absent field, interface, abstraction, or standard as an observation, not proof of a requirement.
Return `STOP` only when evidence affirmatively shows that no change is needed or the affected workflow already achieves the outcome. Return `NEEDS_EVIDENCE` when an unverified fact prevents the decision.
### 2. Name Semantic Consumers
For every proposed durable field, event, API, table, store, module, service, or workflow, establish:
| Question | Required answer |
| --- | --- |
| Producer or lifecycle owner | What creates, updates, or owns it? |
| Committed consumer | Which named caller, component, operator, or user reads or acts on it now or as part of this same accepted slice? |
| Semantic use | What behavior, decision, or externally visible result changes after consumption? |
| Reachable path | Where does production reach consumption in the current system or proposed slice? |
| Absence test | Which verified scenario or accepted outcome fails if the addition is removed? |
Accept a proposed consumer only when it is tied to a verified current need and committed integration in the same change. Do not accept a roadmap, possible future evaluator, generic read/debug API, storage alone, or “future flexibility” as a semantic consumer. If an addition has no such consumer, remove or defer it.
Treat a public or externally consumed contract as a compatibility boundary even when no in-repository caller is visible. Absence of a discoverable caller is uncertainty, not proof that no consumer exists.
### 3. Choose the Smallest Sufficient Change
Consider solutions in this order and stop at the first one that fully satisfies the verified outcome and invariants without shifting disproportionate recurring cost, coupling, or risk downstream:
1. No product or code change
2. Documentation, configuration, or operating procedure
3. Reuse an existing capability
4. Make a local behavior fix
5. Extend an existing abstraction
6. Introduce a new abstraction
7. Introduce a new subsystem or migration path
- Minimize concepts, states, interfaces, irreversible decisions, and maintenance surface, not literal line count.
- Require a second current consumer, a demonstrated variation, or a hard boundary before generalizing a local solution.
- Prefer independently reversible slices over a comprehensive architecture rollout.
- Distinguish a real problem from an oversized solution. A valid verdict is: “The problem is real; reduce the proposal to this smaller change.”
- Apply these gates recursively to your own recommendation. Do not propose a new field, contract, abstraction, migration, or validation system without naming its consumer, checking existing mechanisms, and showing why a smaller change is insufficient.
### 4. Map Consequences and Verification Proportionally
Inspect only relevant dimensions, but do not omit a dimension merely because the proposal omits it:
- callers and downstream consumers
- API, data, event, and UI contracts
- authorization, ownership, privacy, and trust boundaries
- persistence, migrations, replay, and side effects
- concurrency, ordering, retries, idempotency, and failure recovery
- compatibility, dependencies, performance, deployment, and operations
- observability and rollback
For state replay or retry features, explicitly distinguish restored application state from external side effects that cannot be undone.
Label material risk claims as `VERIFIED`, `INFERENCE`, or `UNKNOWN`. Use an inference to request a focused check, not to require new architecture as though the claim were already proven.
Evidence labels classify individual claims; verdicts classify the overall decision. An `UNKNOWN` requires `NEEDS_EVIDENCE` only when the unknown blocks a material decision.
Before implementation, require observed evidence for the current-system claims that justify the decision and a proportional, executable verification plan. Treat proposed checks as a verification plan, not observed evidence.
### 5. Implement Only the Justified Slice
When implementation is authorized:
- Reproduce the baseline first. Encode it as a failing behavioral test when executable; otherwise state why and record a reproducible check.
- Change only the paths required by the accepted outcome and consumers.
- Reuse existing execution paths and contracts when they preserve the required semantics.
- Avoid speculative compatibility layers, selectors, shadow systems, canaries, or dual stacks unless an irreversible or high-risk transition requires them.
- Update repository guidance only when architecture, commands, or durable conventions actually change.
### 6. Prove the Result
- Map each important result claim to observed evidence: tests, contract checks, static analysis, runtime traces, benchmarks, or a reproducible manual check.
- Do not use the agent's own summary as proof.
- Verify negative boundaries and failure behavior, not only the happy path.
- State what remains unverified and how that uncertainty affects the verdict.
- Use focused regression checks for local reversible changes.
- Add targeted integration and adversarial checks for contract, persistence, security, concurrency, replay, or cross-component changes.
- Require a production-like rehearsal plus executable containment or rollback for irreversible changes or materially high-risk external side effects.
## Use Explicit Verdicts
- `STOP`: evidence affirmatively shows that no current change is needed, or that existing capability already achieves the accepted outcome.
- `REDUCE`: the problem is real, but the proposed scope or abstraction exceeds the evidence.
- `REVISE`: the problem and approximate scope are justified, but a correctness, contract, or failure-semantics defect must change before proceeding.
- `PROCEED`: the problem, consumers, minimum solution, consequences, and proportional verification plan are sufficiently established.
- `NEEDS_EVIDENCE`: a decision would be guesswork until a specific fact, code path, incident, or consumer is verified.
Do not force a binary approve/reject judgment when evidence is incomplete.
Choose the verdict from the condition blocking the earliest gate, not from the gate number: affirmative evidence that no change is needed maps to `STOP`; a decision-blocking unknown maps to `NEEDS_EVIDENCE`; a real problem with unsupported scope or no committed consumer maps to `REDUCE`; and a confirmed correctness, contract, or failure-semantics defect maps to `REVISE`. Use `PROCEED` only when no gate is blocked.
When more than one condition applies, give one primary verdict and list the other required changes without inventing a compound status.
A verdict records the decision gate and never expands authorization. After authorized implementation, separately report the implemented slice, observed verification, and remaining uncertainty.
## Keep the Output Proportional
For a simple change, report only:
1. Problem and desired behavior
2. Named semantic consumer
3. Smallest sufficient change
4. Observed evidence and, before implementation, the verification plan
5. Verdict
For a cross-boundary change or RFC, add:
- verified current-system facts
- existing alternatives and why they do not meet the accepted outcome
- consumer ledger for proposed additions
- affected contracts and side effects
- rejected or deferred scope with reasons
- failure, observation, and rollback strategy
Do not create ceremonial documents or exhaustive matrices when a short evidence-backed answer is sufficient. The workflow itself must not become overengineering.

View File

@ -59,7 +59,7 @@ smoke-test/
2. **Check pnpm** - Package manager 2. **Check pnpm** - Package manager
3. **Check uv** - Python package manager 3. **Check uv** - Python package manager
4. **Check nginx** - Reverse proxy 4. **Check nginx** - Reverse proxy
5. **Check required ports** - Confirm that ports 2026, 3000, and 8001 are not occupied 5. **Check required ports** - Confirm that ports 2026, 3000, 8001, and 2024 are not occupied
**Docker mode environment check** (if Docker is selected): **Docker mode environment check** (if Docker is selected):
1. **Check whether Docker is installed** - Run `docker --version` 1. **Check whether Docker is installed** - Run `docker --version`
@ -82,29 +82,29 @@ smoke-test/
1. **Check dependencies** - Run `make check` 1. **Check dependencies** - Run `make check`
2. **Install dependencies** - Run `make install` 2. **Install dependencies** - Run `make install`
3. **(Optional) Pre-pull the sandbox image** - If needed, run `make setup-sandbox` 3. **(Optional) Pre-pull the sandbox image** - If needed, run `make setup-sandbox`
4. **Start services** - Run `make start` (production mode) 4. **Start services** - Run `make dev-daemon` (background mode, recommended) or `make dev` (foreground mode)
5. **Wait for startup** - Give all services enough time to start completely (90-120 seconds recommended) 5. **Wait for startup** - Give all services enough time to start completely (90-120 seconds recommended)
**Docker mode deployment** (if Docker is selected): **Docker mode deployment** (if Docker is selected):
1. **Initialize Docker environment** - Run `make docker-init` 1. **Initialize Docker environment** - Run `make docker-init`
2. **Start Docker services** - Run `make up` (production mode) 2. **Start Docker services** - Run `make docker-start`
3. **Wait for startup** - Give all containers enough time to start completely (60 seconds recommended) 3. **Wait for startup** - Give all containers enough time to start completely (60 seconds recommended)
### Phase 5: Service Health Check ### Phase 5: Service Health Check
**Local mode health check**: **Local mode health check**:
1. **Check process status** - Confirm that Gateway, Frontend, and Nginx processes are all running 1. **Check process status** - Confirm that LangGraph, Gateway, Frontend, and Nginx processes are all running
2. **Check frontend service** - Visit `http://localhost:2026` and verify that the page loads 2. **Check frontend service** - Visit `http://localhost:2026` and verify that the page loads
3. **Check API Gateway** - Verify the `http://localhost:2026/health` endpoint 3. **Check API Gateway** - Verify the `http://localhost:2026/health` endpoint
4. **Check LangGraph-compatible API** - Verify the `/api/langgraph/*` route exposed by Gateway 4. **Check LangGraph service** - Verify the availability of relevant endpoints
5. **Frontend route smoke check** - Run `bash .agent/skills/smoke-test/scripts/frontend_check.sh` to verify key routes under `/workspace`. The script auto-detects whether authentication is enabled and, if so, registers / logs in a smoke-test user so the real pages are verified rather than the login redirect. 5. **Frontend route smoke check** - Run `bash .agent/skills/smoke-test/scripts/frontend_check.sh` to verify key routes under `/workspace`
**Docker mode health check** (when using Docker): **Docker mode health check** (when using Docker):
1. **Check container status** - Run `docker ps` and confirm that all containers are running 1. **Check container status** - Run `docker ps` and confirm that all containers are running
2. **Check frontend service** - Visit `http://localhost:2026` and verify that the page loads 2. **Check frontend service** - Visit `http://localhost:2026` and verify that the page loads
3. **Check API Gateway** - Verify the `http://localhost:2026/health` endpoint 3. **Check API Gateway** - Verify the `http://localhost:2026/health` endpoint
4. **Check LangGraph-compatible API** - Verify the `/api/langgraph/*` route exposed by Gateway 4. **Check LangGraph service** - Verify the availability of relevant endpoints
5. **Frontend route smoke check** - Run `bash .agent/skills/smoke-test/scripts/frontend_check.sh` to verify key routes under `/workspace`. The script auto-detects whether authentication is enabled and, if so, registers / logs in a smoke-test user so the real pages are verified rather than the login redirect. 5. **Frontend route smoke check** - Run `bash .agent/skills/smoke-test/scripts/frontend_check.sh` to verify key routes under `/workspace`
### Optional Functional Verification ### Optional Functional Verification
@ -135,8 +135,7 @@ smoke-test/
The following warnings can appear during smoke testing and do not block a successful result: The following warnings can appear during smoke testing and do not block a successful result:
- Feishu/Lark SSL errors in Gateway logs (certificate verification failure) can be ignored if that channel is not enabled - Feishu/Lark SSL errors in Gateway logs (certificate verification failure) can be ignored if that channel is not enabled
- Warnings in Gateway logs about missing methods in the custom checkpointer, such as `adelete_for_runs` or `aprune`, do not affect the core functionality - Warnings in LangGraph logs about missing methods in the custom checkpointer, such as `adelete_for_runs` or `aprune`, do not affect the core functionality
- The `frontend_check.sh` script automatically handles authentication. When auth is enabled it registers / logs in a smoke-test user (`smoke-test@deerflow.dev` by default) to verify the real `/workspace/*` pages. The registration may produce a log entry from the auth provider, which is expected and harmless.
## Key Tools ## Key Tools
@ -166,7 +165,7 @@ Smoke test pass criteria (Docker mode):
- [x] Docker environment check passes - [x] Docker environment check passes
- [x] Configuration files are set up correctly - [x] Configuration files are set up correctly
- [x] `make docker-init` completes successfully - [x] `make docker-init` completes successfully
- [x] `make up` completes successfully - [x] `make docker-start` completes successfully
- [x] All Docker containers run normally - [x] All Docker containers run normally
- [x] Frontend page is accessible - [x] Frontend page is accessible
- [x] Frontend route smoke check passes (`/workspace` key routes) - [x] Frontend route smoke check passes (`/workspace` key routes)

View File

@ -138,6 +138,7 @@ This document describes the detailed operating steps for each phase of the DeerF
lsof -i :2026 # Main port lsof -i :2026 # Main port
lsof -i :3000 # Frontend lsof -i :3000 # Frontend
lsof -i :8001 # Gateway lsof -i :8001 # Gateway
lsof -i :2024 # LangGraph
``` ```
**Success Criteria**: All ports are free, or they are occupied only by DeerFlow-related processes. **Success Criteria**: All ports are free, or they are occupied only by DeerFlow-related processes.
@ -257,7 +258,7 @@ This document describes the detailed operating steps for each phase of the DeerF
**Steps**: **Steps**:
1. Run `make dev-daemon` (background mode) 1. Run `make dev-daemon` (background mode)
**Description**: This command starts all services (Gateway embedded runtime, Frontend, Nginx). **Description**: This command starts all services (LangGraph, Gateway, Frontend, Nginx).
**Notes**: **Notes**:
- `make dev` runs in the foreground and stops with Ctrl+C - `make dev` runs in the foreground and stops with Ctrl+C
@ -271,6 +272,7 @@ This document describes the detailed operating steps for each phase of the DeerF
**Steps**: **Steps**:
1. Wait 90-120 seconds for all services to start completely 1. Wait 90-120 seconds for all services to start completely
2. You can monitor startup progress by checking these log files: 2. You can monitor startup progress by checking these log files:
- `logs/langgraph.log`
- `logs/gateway.log` - `logs/gateway.log`
- `logs/frontend.log` - `logs/frontend.log`
- `logs/nginx.log` - `logs/nginx.log`
@ -291,9 +293,9 @@ This document describes the detailed operating steps for each phase of the DeerF
#### 4.2.2 Start Docker Services #### 4.2.2 Start Docker Services
**Steps**: **Steps**:
1. Run `make up` 1. Run `make docker-start`
**Description**: This command builds and starts all required Docker containers in production. **Description**: This command builds and starts all required Docker containers.
--- ---
@ -314,10 +316,11 @@ This document describes the detailed operating steps for each phase of the DeerF
**Steps**: **Steps**:
1. Run the following command to check processes: 1. Run the following command to check processes:
```bash ```bash
ps aux | grep -E "(uvicorn|next|nginx)" | grep -v grep ps aux | grep -E "(langgraph|uvicorn|next|nginx)" | grep -v grep
``` ```
**Success Criteria**: Confirm that the following processes are running: **Success Criteria**: Confirm that the following processes are running:
- LangGraph (`langgraph dev`)
- Gateway (`uvicorn app.gateway.app:app`) - Gateway (`uvicorn app.gateway.app:app`)
- Frontend (`next dev` or `next start`) - Frontend (`next dev` or `next start`)
- Nginx (`nginx`) - Nginx (`nginx`)
@ -353,27 +356,10 @@ curl http://localhost:2026/health
--- ---
#### 5.1.4 Check LangGraph-compatible API #### 5.1.4 Check LangGraph Service
**Steps**: **Steps**:
1. Visit `http://localhost:2026/api/langgraph/assistants/lead_agent` to verify Gateway's LangGraph-compatible API route is reachable. 1. Visit relevant LangGraph endpoints to verify availability
2. A `401` response is acceptable when authentication is enabled and no session cookie is provided.
---
#### 5.1.5 Frontend Route Smoke Check
**Objective**: Verify key `/workspace/*` frontend routes render correctly.
**Steps**:
1. Run `bash .agent/skills/smoke-test/scripts/frontend_check.sh`.
2. The script auto-detects whether authentication (`DEER_FLOW_AUTH_DISABLED`) is enabled.
3. When auth is on, the script registers / logs in a smoke-test user (`smoke-test@deerflow.dev` by default) and passes the session cookie so the real `/workspace/*` pages are verified — not the login redirect.
4. When auth is off, the routes are checked anonymously as before.
**Customisation**:
- `SMOKE_TEST_EMAIL` — email for the test account (default: `smoke-test@deerflow.dev`).
- `SMOKE_TEST_PASSWORD` — password for the test account (default: `SmokeTest123!`).
--- ---
@ -387,6 +373,7 @@ curl http://localhost:2026/health
- `deer-flow-nginx` - `deer-flow-nginx`
- `deer-flow-frontend` - `deer-flow-frontend`
- `deer-flow-gateway` - `deer-flow-gateway`
- `deer-flow-langgraph` (if not in gateway mode)
--- ---
@ -419,27 +406,10 @@ curl http://localhost:2026/health
--- ---
#### 5.2.4 Check LangGraph-compatible API #### 5.2.4 Check LangGraph Service
**Steps**: **Steps**:
1. Visit `http://localhost:2026/api/langgraph/assistants/lead_agent` to verify Gateway's LangGraph-compatible API route is reachable. 1. Visit relevant LangGraph endpoints to verify availability
2. A `401` response is acceptable when authentication is enabled and no session cookie is provided.
---
#### 5.2.5 Frontend Route Smoke Check
**Objective**: Verify key `/workspace/*` frontend routes render correctly.
**Steps**:
1. Run `bash .agent/skills/smoke-test/scripts/frontend_check.sh`.
2. The script auto-detects whether authentication (`DEER_FLOW_AUTH_DISABLED`) is enabled.
3. When auth is on, the script registers / logs in a smoke-test user (`smoke-test@deerflow.dev` by default) and passes the session cookie so the real `/workspace/*` pages are verified — not the login redirect.
4. When auth is off, the routes are checked anonymously as before.
**Customisation**:
- `SMOKE_TEST_EMAIL` — email for the test account (default: `smoke-test@deerflow.dev`).
- `SMOKE_TEST_PASSWORD` — password for the test account (default: `SmokeTest123!`).
--- ---

View File

@ -254,6 +254,7 @@ Processes exit quickly after running `make dev-daemon`.
**Solutions**: **Solutions**:
1. Check log files: 1. Check log files:
```bash ```bash
tail -f logs/langgraph.log
tail -f logs/gateway.log tail -f logs/gateway.log
tail -f logs/frontend.log tail -f logs/frontend.log
tail -f logs/nginx.log tail -f logs/nginx.log
@ -366,7 +367,24 @@ Errors appear in `gateway.log`.
uv sync uv sync
``` ```
4. Confirm that the Gateway process is running normally. 4. Confirm that the LangGraph service is running normally (if not in gateway mode)
---
### Issue: LangGraph Fails to Start
**Symptoms**:
Errors appear in `langgraph.log`.
**Solutions**:
1. Check LangGraph logs:
```bash
tail -f logs/langgraph.log
```
2. Check config.yaml
3. Check whether Python dependencies are complete
4. Confirm that port 2024 is not occupied
--- ---
@ -501,7 +519,7 @@ Accessing `/health` returns an error or times out.
2. Confirm that config.yaml exists and has valid formatting 2. Confirm that config.yaml exists and has valid formatting
3. Check whether Python dependencies are complete 3. Check whether Python dependencies are complete
4. Confirm that the Gateway process is running normally. 4. Confirm that the LangGraph service is running normally
**Solutions** (Docker mode): **Solutions** (Docker mode):
1. Check gateway container logs: 1. Check gateway container logs:
@ -511,7 +529,7 @@ Accessing `/health` returns an error or times out.
2. Confirm that config.yaml is mounted correctly 2. Confirm that config.yaml is mounted correctly
3. Check whether Python dependencies are complete 3. Check whether Python dependencies are complete
4. Confirm that the Gateway process is running normally. 4. Confirm that the LangGraph service is running normally
--- ---
@ -521,7 +539,7 @@ Accessing `/health` returns an error or times out.
#### View All Service Processes #### View All Service Processes
```bash ```bash
ps aux | grep -E "(uvicorn|next|nginx)" | grep -v grep ps aux | grep -E "(langgraph|uvicorn|next|nginx)" | grep -v grep
``` ```
#### View Service Logs #### View Service Logs
@ -530,6 +548,7 @@ ps aux | grep -E "(uvicorn|next|nginx)" | grep -v grep
tail -f logs/*.log tail -f logs/*.log
# View specific service logs # View specific service logs
tail -f logs/langgraph.log
tail -f logs/gateway.log tail -f logs/gateway.log
tail -f logs/frontend.log tail -f logs/frontend.log
tail -f logs/nginx.log tail -f logs/nginx.log

View File

@ -65,7 +65,7 @@ if ! command -v lsof >/dev/null 2>&1; then
echo " Install lsof and rerun this check" echo " Install lsof and rerun this check"
all_passed=false all_passed=false
else else
for port in 2026 3000 8001; do for port in 2026 3000 8001 2024; do
if lsof -i :$port >/dev/null 2>&1; then if lsof -i :$port >/dev/null 2>&1; then
echo "⚠ Port $port is already in use:" echo "⚠ Port $port is already in use:"
lsof -i :$port | head -2 lsof -i :$port | head -2

View File

@ -54,6 +54,7 @@ echo "=========================================="
echo "" echo ""
echo "🌐 Access URL: http://localhost:2026" echo "🌐 Access URL: http://localhost:2026"
echo "📋 View logs:" echo "📋 View logs:"
echo " - logs/langgraph.log"
echo " - logs/gateway.log" echo " - logs/gateway.log"
echo " - logs/frontend.log" echo " - logs/frontend.log"
echo " - logs/nginx.log" echo " - logs/nginx.log"

View File

@ -9,99 +9,6 @@ echo ""
BASE_URL="${BASE_URL:-http://localhost:2026}" BASE_URL="${BASE_URL:-http://localhost:2026}"
DOC_PATH="${DOC_PATH:-/en/docs}" DOC_PATH="${DOC_PATH:-/en/docs}"
# When the gateway has authentication enabled (DEER_FLOW_AUTH_DISABLED != 1),
# protected /workspace/* routes redirect anonymous requests to /login.
# We detect auth, register / log in a smoke-test user, and pass the session
# cookie to all curl calls so the real pages are verified, not the login form.
SMOKE_TEST_EMAIL="${SMOKE_TEST_EMAIL:-smoke-test@deerflow.dev}"
SMOKE_TEST_PASSWORD="${SMOKE_TEST_PASSWORD:-SmokeTest123!}"
COOKIE_JAR=$(mktemp /tmp/deerflow-smoke-cookies.XXXXXX)
trap 'rm -f "$COOKIE_JAR"' EXIT
CURL_AUTH_OPTS=""
authenticate() {
# Make sure the service is reachable before detecting auth
local health
health=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}/" 2>/dev/null)
if [ "$health" = "000" ]; then
echo "✗ Cannot reach ${BASE_URL} — is the service running?"
return 1
fi
# A protected endpoint returns 401 when auth is on
local auth_check
auth_check=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}/api/models" 2>/dev/null)
if [ "$auth_check" != "401" ]; then
echo " Auth is disabled — no login needed"
return 0
fi
echo "🔐 Auth is enabled — setting up smoke test session..."
# Check whether the system needs first-boot initialization
local needs_setup
needs_setup=$(curl -s "${BASE_URL}/api/v1/auth/setup-status" \
| grep -o '"needs_setup":[^,}]*' | grep -o 'true\|false')
if [ "$needs_setup" = "true" ]; then
echo " Initializing system with smoke test admin..."
local init_code
init_code=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "${BASE_URL}/api/v1/auth/initialize" \
-H "Content-Type: application/json" \
-d "{\"email\":\"${SMOKE_TEST_EMAIL}\",\"password\":\"${SMOKE_TEST_PASSWORD}\"}" \
-c "$COOKIE_JAR" 2>/dev/null)
if [ "$init_code" != "201" ]; then
echo "✗ Initialize failed (HTTP $init_code)"
return 1
fi
echo "✓ Admin initialized & logged in"
elif [ -z "$needs_setup" ]; then
echo "⚠ Could not determine setup status — skipping initialize, trying register/login"
else
# Register first — on success (201) it also auto-logs-in via the cookie.
# This avoids a wasted login attempt that counts toward rate-limiting.
local auth_code
auth_code=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "${BASE_URL}/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d "{\"email\":\"${SMOKE_TEST_EMAIL}\",\"password\":\"${SMOKE_TEST_PASSWORD}\"}" \
-c "$COOKIE_JAR" 2>/dev/null)
if [ "$auth_code" = "201" ]; then
echo "✓ Registered as ${SMOKE_TEST_EMAIL}"
else
# User already exists — clear stale cookies from register attempt, then log in
: > "$COOKIE_JAR"
local login_code
login_code=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "${BASE_URL}/api/v1/auth/login/local" \
--data-urlencode "username=${SMOKE_TEST_EMAIL}" \
--data-urlencode "password=${SMOKE_TEST_PASSWORD}" \
-c "$COOKIE_JAR" 2>/dev/null)
if [ "$login_code" != "200" ]; then
echo "✗ Login failed (HTTP $login_code)"
return 1
fi
echo "✓ Logged in as ${SMOKE_TEST_EMAIL}"
fi
fi
# Verify the session cookie works across all branches
local me_code
me_code=$(curl -s -o /dev/null -w "%{http_code}" \
-b "$COOKIE_JAR" "${BASE_URL}/api/v1/auth/me" 2>/dev/null)
if [ "$me_code" = "200" ]; then
echo "✓ Session verified for ${SMOKE_TEST_EMAIL}"
CURL_AUTH_OPTS="-b $COOKIE_JAR"
return 0
fi
echo "✗ Auth failed — session cookie not accepted (HTTP $me_code)"
echo " Set SMOKE_TEST_EMAIL / SMOKE_TEST_PASSWORD or check the existing account"
return 1
}
all_passed=true all_passed=true
check_status() { check_status() {
@ -110,7 +17,7 @@ check_status() {
local expected_re="$3" local expected_re="$3"
local status local status
status="$(curl -s -o /dev/null -w "%{http_code}" -L ${CURL_AUTH_OPTS} "$url")" status="$(curl -s -o /dev/null -w "%{http_code}" -L "$url")"
if echo "$status" | grep -Eq "$expected_re"; then if echo "$status" | grep -Eq "$expected_re"; then
echo "$name ($url) -> $status" echo "$name ($url) -> $status"
else else
@ -125,7 +32,7 @@ check_final_url() {
local expected_path_re="$3" local expected_path_re="$3"
local effective local effective
effective="$(curl -s -o /dev/null -w "%{url_effective}" -L ${CURL_AUTH_OPTS} "$url")" effective="$(curl -s -o /dev/null -w "%{url_effective}" -L "$url")"
if echo "$effective" | grep -Eq "$expected_path_re"; then if echo "$effective" | grep -Eq "$expected_path_re"; then
echo "$name redirect target -> $effective" echo "$name redirect target -> $effective"
else else
@ -134,10 +41,6 @@ check_final_url() {
fi fi
} }
# Authenticate before checking protected routes
authenticate || exit 1
echo ""
echo "1. Checking entry pages..." echo "1. Checking entry pages..."
check_status "Landing page" "${BASE_URL}/" "200" check_status "Landing page" "${BASE_URL}/" "200"
check_status "Workspace redirect" "${BASE_URL}/workspace" "200|301|302|307|308" check_status "Workspace redirect" "${BASE_URL}/workspace" "200|301|302|307|308"
@ -146,11 +49,8 @@ echo ""
echo "2. Checking key workspace routes..." echo "2. Checking key workspace routes..."
check_status "New chat page" "${BASE_URL}/workspace/chats/new" "200" check_status "New chat page" "${BASE_URL}/workspace/chats/new" "200"
check_final_url "New chat page" "${BASE_URL}/workspace/chats/new" "/workspace/"
check_status "Chats list page" "${BASE_URL}/workspace/chats" "200" check_status "Chats list page" "${BASE_URL}/workspace/chats" "200"
check_final_url "Chats list page" "${BASE_URL}/workspace/chats" "/workspace/"
check_status "Agents gallery page" "${BASE_URL}/workspace/agents" "200" check_status "Agents gallery page" "${BASE_URL}/workspace/agents" "200"
check_final_url "Agents gallery page" "${BASE_URL}/workspace/agents" "/workspace/agents"
echo "" echo ""
echo "3. Checking docs route (optional)..." echo "3. Checking docs route (optional)..."

View File

@ -76,11 +76,12 @@ if [ "$mode" = "docker" ]; then
all_passed=false all_passed=false
fi fi
else else
summary_hint="logs/{gateway,frontend,nginx}.log" summary_hint="logs/{langgraph,gateway,frontend,nginx}.log"
print_step "1. Checking local service ports..." print_step "1. Checking local service ports..."
check_listen_port "Nginx" 2026 check_listen_port "Nginx" 2026
check_listen_port "Frontend" 3000 check_listen_port "Frontend" 3000
check_listen_port "Gateway" 8001 check_listen_port "Gateway" 8001
check_listen_port "LangGraph" 2024
fi fi
echo "" echo ""
@ -103,8 +104,8 @@ else
fi fi
echo "" echo ""
echo "5. Checking LangGraph-compatible Gateway API..." echo "5. Checking LangGraph service..."
check_http_status "LangGraph-compatible Gateway API" "http://localhost:2026/api/langgraph/assistants/lead_agent" "200|401" check_http_status "LangGraph service" "http://localhost:2024/" "200|301|302|307|308|404"
echo "" echo ""
echo "==========================================" echo "=========================================="

View File

@ -78,7 +78,7 @@
- [x] Container status - {{status_containers}} - [x] Container status - {{status_containers}}
- [x] Frontend service - {{status_frontend}} - [x] Frontend service - {{status_frontend}}
- [x] API Gateway - {{status_api_gateway}} - [x] API Gateway - {{status_api_gateway}}
- [x] LangGraph-compatible Gateway API - {{status_langgraph}} - [x] LangGraph service - {{status_langgraph}}
**Phase Status**: {{stage5_status}} **Phase Status**: {{stage5_status}}
@ -147,6 +147,7 @@ Commit Message: {{git_commit_message}}
| deer-flow-nginx | {{nginx_status}} | {{nginx_uptime}} | | deer-flow-nginx | {{nginx_status}} | {{nginx_uptime}} |
| deer-flow-frontend | {{frontend_status}} | {{frontend_uptime}} | | deer-flow-frontend | {{frontend_status}} | {{frontend_uptime}} |
| deer-flow-gateway | {{gateway_status}} | {{gateway_uptime}} | | deer-flow-gateway | {{gateway_status}} | {{gateway_uptime}} |
| deer-flow-langgraph | {{langgraph_status}} | {{langgraph_uptime}} |
--- ---

View File

@ -80,7 +80,7 @@
- [x] Process status - {{status_processes}} - [x] Process status - {{status_processes}}
- [x] Frontend service - {{status_frontend}} - [x] Frontend service - {{status_frontend}}
- [x] API Gateway - {{status_api_gateway}} - [x] API Gateway - {{status_api_gateway}}
- [x] LangGraph-compatible Gateway API - {{status_langgraph}} - [x] LangGraph service - {{status_langgraph}}
**Phase Status**: {{stage5_status}} **Phase Status**: {{stage5_status}}
@ -152,7 +152,7 @@ Commit Message: {{git_commit_message}}
| Nginx | {{nginx_status}} | {{nginx_endpoint}} | | Nginx | {{nginx_status}} | {{nginx_endpoint}} |
| Frontend | {{frontend_status}} | {{frontend_endpoint}} | | Frontend | {{frontend_status}} | {{frontend_endpoint}} |
| Gateway | {{gateway_status}} | {{gateway_endpoint}} | | Gateway | {{gateway_status}} | {{gateway_endpoint}} |
| Gateway LangGraph API | {{langgraph_status}} | {{langgraph_endpoint}} | | LangGraph | {{langgraph_status}} | {{langgraph_endpoint}} |
--- ---
@ -166,7 +166,7 @@ Commit Message: {{git_commit_message}}
### If the Test Fails ### If the Test Fails
1. [ ] Review references/troubleshooting.md for common solutions 1. [ ] Review references/troubleshooting.md for common solutions
2. [ ] Check local logs: `logs/{gateway,frontend,nginx}.log` 2. [ ] Check local logs: `logs/{langgraph,gateway,frontend,nginx}.log`
3. [ ] Verify configuration file format and content 3. [ ] Verify configuration file format and content
4. [ ] If needed, fully reset the environment: `make stop && make clean && make install && make dev-daemon` 4. [ ] If needed, fully reset the environment: `make stop && make clean && make install && make dev-daemon`

View File

@ -1,6 +1,3 @@
# Serper API Key (Google Search) - https://serper.dev
SERPER_API_KEY=your-serper-api-key
# TAVILY API Key # TAVILY API Key
TAVILY_API_KEY=your-tavily-api-key TAVILY_API_KEY=your-tavily-api-key
@ -9,9 +6,8 @@ JINA_API_KEY=your-jina-api-key
# InfoQuest API Key # InfoQuest API Key
INFOQUEST_API_KEY=your-infoquest-api-key INFOQUEST_API_KEY=your-infoquest-api-key
# Browser CORS allowlist for split-origin or port-forwarded deployments (comma-separated exact origins). # CORS Origins (comma-separated) - e.g., http://localhost:3000,http://localhost:3001
# Leave unset when using the unified nginx endpoint, e.g. http://localhost:2026. # CORS_ORIGINS=http://localhost:3000
# GATEWAY_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Optional: # Optional:
# FIRECRAWL_API_KEY=your-firecrawl-api-key # FIRECRAWL_API_KEY=your-firecrawl-api-key
@ -21,12 +17,7 @@ INFOQUEST_API_KEY=your-infoquest-api-key
# DEEPSEEK_API_KEY=your-deepseek-api-key # DEEPSEEK_API_KEY=your-deepseek-api-key
# NOVITA_API_KEY=your-novita-api-key # OpenAI-compatible, see https://novita.ai # NOVITA_API_KEY=your-novita-api-key # OpenAI-compatible, see https://novita.ai
# MINIMAX_API_KEY=your-minimax-api-key # OpenAI-compatible, see https://platform.minimax.io # MINIMAX_API_KEY=your-minimax-api-key # OpenAI-compatible, see https://platform.minimax.io
# STEPFUN_API_KEY=your-stepfun-api-key # OpenAI-compatible, see https://platform.stepfun.com
# VLLM_API_KEY=your-vllm-api-key # OpenAI-compatible # VLLM_API_KEY=your-vllm-api-key # OpenAI-compatible
# E2B cloud sandbox API key — required only when using E2BSandboxProvider.
# Sign up at https://e2b.dev/dashboard
# E2B_API_KEY=your-e2b-api-key
# FEISHU_APP_ID=your-feishu-app-id # FEISHU_APP_ID=your-feishu-app-id
# FEISHU_APP_SECRET=your-feishu-app-secret # FEISHU_APP_SECRET=your-feishu-app-secret
@ -43,50 +34,5 @@ INFOQUEST_API_KEY=your-infoquest-api-key
# GitHub API Token # GitHub API Token
# GITHUB_TOKEN=your-github-token # GITHUB_TOKEN=your-github-token
# Database (only needed when config.yaml has database.backend: postgres)
# DATABASE_URL=postgresql://deerflow:password@localhost:5432/deerflow
#
# WECOM_BOT_ID=your-wecom-bot-id # WECOM_BOT_ID=your-wecom-bot-id
# WECOM_BOT_SECRET=your-wecom-bot-secret # WECOM_BOT_SECRET=your-wecom-bot-secret
# DINGTALK_CLIENT_ID=your-dingtalk-client-id
# DINGTALK_CLIENT_SECRET=your-dingtalk-client-secret
# Set to "false" to disable Swagger UI, ReDoc, and OpenAPI schema in production
# GATEWAY_ENABLE_DOCS=false
# Shared internal Gateway auth token for multi-worker deployments.
# `make up` generates and persists this automatically; set it manually only
# when you run Gateway workers outside the bundled deploy script.
# DEER_FLOW_INTERNAL_AUTH_TOKEN=your-shared-internal-token
# Provisioner API key for sandbox authentication (required when using provisioner/K8s sandbox mode).
# The same value must be set on the provisioner container and in config.yaml sandbox.provisioner_api_key.
# Generate: openssl rand -hex 32
# PROVISIONER_API_KEY=your-provisioner-api-key
# ── Frontend SSR → Gateway wiring ─────────────────────────────────────────────
# The Next.js server uses these to reach the Gateway during SSR (auth checks,
# /api/* rewrites). They default to localhost values that match `make dev` and
# `make start`, so most local users do not need to set them.
#
# Override only when the Gateway is not on localhost:8001 (e.g. when the
# frontend and gateway run on different hosts, in containers with a service
# alias, or behind a different port). docker-compose already sets these.
# DEER_FLOW_INTERNAL_GATEWAY_BASE_URL=http://localhost:8001
# DEER_FLOW_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:2026
# ── Claude Code / Codex CLI subscription as a model provider (optional) ───────
# If you configure a ClaudeChatModel / Codex model provider (or an ACP agent)
# that reuses your CLI subscription login, prefer passing a token via env over
# bind-mounting your whole ~/.claude / ~/.codex into the container. The Gateway
# credential loader reads these first, so no directory mount is needed.
# CLAUDE_CODE_CREDENTIALS_PATH points at a single .credentials.json (Claude)
# rather than the whole dir. docker-compose.cli-auth.yaml is the opt-in
# directory-mount fallback for adapters that need the full CLI config.
# ACP adapters often take their own env API key (e.g. ANTHROPIC_API_KEY) and
# need no mount at all — check the adapter's docs. See SECURITY.md.
# CLAUDE_CODE_OAUTH_TOKEN=your-claude-code-oauth-token
# ANTHROPIC_AUTH_TOKEN=your-anthropic-auth-token
# CLAUDE_CODE_CREDENTIALS_PATH=/path/to/.claude/.credentials.json
# CODEX_AUTH_PATH=/path/to/codex/auth.json

View File

@ -1,170 +0,0 @@
name: 🐛 Bug report
description: Report something that isn't working so maintainers can reproduce and fix it.
title: "[bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug. A clear, reproducible report is the
single biggest factor in how fast it gets fixed.
Please fill in every required field — especially **reproduction steps** and **logs**.
- type: checkboxes
id: preflight
attributes:
label: Before you start
options:
- label: I searched [existing issues](https://github.com/bytedance/deer-flow/issues?q=is%3Aissue) and this is not a duplicate.
required: true
- label: I can reproduce this on the latest `main`.
required: false
- type: input
id: summary
attributes:
label: Problem summary
description: One sentence describing the bug.
placeholder: e.g. make dev fails to start the gateway service
validations:
required: true
- type: dropdown
id: areas
attributes:
label: Affected area(s)
description: Which part of DeerFlow does this touch? Select all that apply.
multiple: true
options:
- Frontend (UI / Next.js)
- Backend API (gateway / endpoints / SSE)
- Agents / LangGraph (graph, prompts, langgraph.json)
- Sandbox / Docker
- Skills
- MCP
- Config / setup (make, config.yaml, env)
- Docs
- CI infra
- Not sure
validations:
required: true
- type: textarea
id: actual
attributes:
label: What happened?
description: The actual behavior. Include the key error lines verbatim.
placeholder: When I do X, I expected Y but I got Z.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
placeholder: What did you expect to happen instead?
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: Exact commands and sequence. Minimal steps that reliably reproduce the problem.
placeholder: |
1. make check
2. make install
3. make dev
4. ...
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs
description: Paste key lines from logs (for example `logs/gateway.log`, `logs/frontend.log`). Redact secrets.
render: shell
validations:
required: true
- type: dropdown
id: run_mode
attributes:
label: How are you running DeerFlow?
options:
- Local (make dev)
- Docker (make docker-start)
- CI
- Other
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- macOS
- Linux
- Windows
- Other
validations:
required: true
- type: input
id: platform_details
attributes:
label: Platform details
description: Architecture and shell, if relevant.
placeholder: e.g. arm64, zsh
- type: input
id: python_version
attributes:
label: Python version
placeholder: e.g. Python 3.12.9
- type: input
id: node_version
attributes:
label: Node.js version
placeholder: e.g. v22.11.0
- type: input
id: pnpm_version
attributes:
label: pnpm version
placeholder: e.g. 10.26.2
- type: input
id: uv_version
attributes:
label: uv version
placeholder: e.g. 0.7.20
- type: textarea
id: git_info
attributes:
label: Git state
description: Output of `git branch --show-current` and the latest commit SHA.
placeholder: |
branch: feature/my-branch
commit: abcdef1
- type: textarea
id: support_bundle
attributes:
label: Support bundle summary
description: For local setup, configuration, sandbox, or runtime issues, run `make support-bundle` and paste the generated `*-issue-summary.md` here. AI-assisted reports can start from `*-issue-draft.md`, but every REQUIRED placeholder must be replaced before filing. Attach the zip only if a maintainer asks for the evidence bundle or the summary is not enough.
placeholder: |
## DeerFlow support bundle summary
- Triage status: ...
- Active signals: ...
- type: textarea
id: additional
attributes:
label: Additional context
description: Screenshots, related issues, config snippets (redacted), or anything else that helps triage.

View File

@ -1,11 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Questions & usage help
url: https://github.com/bytedance/deer-flow/discussions/categories/q-a
about: "How do I use X? Why does Y behave like that? Ask in Discussions — it gets answered faster and stays searchable."
- name: 💡 Ideas & proposals
url: https://github.com/bytedance/deer-flow/discussions/categories/ideas
about: Have a half-formed idea? Float it in Discussions before opening a formal feature request.
- name: 🔒 Report a security vulnerability
url: https://github.com/bytedance/deer-flow/security/policy
about: Do not open a public issue for security problems. Follow the security policy instead.

View File

@ -1,67 +0,0 @@
name: 💡 Feature request
description: Propose a new capability or an improvement to an existing one.
title: "[feat] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for the suggestion. For non-trivial features, please open a
[Discussion](https://github.com/bytedance/deer-flow/discussions/categories/ideas)
first to align on scope before writing code.
- type: checkboxes
id: preflight
attributes:
label: Before you start
options:
- label: I searched [existing issues](https://github.com/bytedance/deer-flow/issues?q=is%3Aissue) and this is not a duplicate.
required: true
- type: textarea
id: problem
attributes:
label: Problem / motivation
description: What problem does this solve? What is painful today, or what does it unblock?
placeholder: "I'm always frustrated when ..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: Describe the change from a user's / caller's perspective.
validations:
required: true
- type: dropdown
id: areas
attributes:
label: Affected area(s)
description: Which part of DeerFlow would this touch? Select all that apply.
multiple: true
options:
- Frontend (UI / Next.js)
- Backend API (gateway / endpoints / SSE)
- Agents / LangGraph (graph, prompts, langgraph.json)
- Sandbox / Docker
- Skills
- MCP
- Config / setup
- Docs
- Not sure
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other approaches you weighed and why you discarded them.
- type: textarea
id: additional
attributes:
label: Additional context
description: Mockups, links, related issues, or anything else that helps.

View File

@ -0,0 +1,128 @@
name: Runtime Information
description: Report runtime/environment details to help reproduce an issue.
title: "[runtime] "
labels:
- needs-triage
body:
- type: markdown
attributes:
value: |
Thanks for sharing runtime details.
Complete this form so maintainers can quickly reproduce and diagnose the problem.
- type: input
id: summary
attributes:
label: Problem summary
description: Short summary of the issue.
placeholder: e.g. make dev fails to start gateway service
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
placeholder: What did you expect to happen?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
placeholder: What happened instead? Include key error lines.
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- macOS
- Linux
- Windows
- Other
validations:
required: true
- type: input
id: platform_details
attributes:
label: Platform details
description: Add architecture and shell if relevant.
placeholder: e.g. arm64, zsh
- type: input
id: python_version
attributes:
label: Python version
placeholder: e.g. Python 3.12.9
- type: input
id: node_version
attributes:
label: Node.js version
placeholder: e.g. v23.11.0
- type: input
id: pnpm_version
attributes:
label: pnpm version
placeholder: e.g. 10.26.2
- type: input
id: uv_version
attributes:
label: uv version
placeholder: e.g. 0.7.20
- type: dropdown
id: run_mode
attributes:
label: How are you running DeerFlow?
options:
- Local (make dev)
- Docker (make docker-dev)
- CI
- Other
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Reproduction steps
description: Provide exact commands and sequence.
placeholder: |
1. make check
2. make install
3. make dev
4. ...
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs
description: Paste key lines from logs (for example logs/gateway.log, logs/frontend.log).
render: shell
validations:
required: true
- type: textarea
id: git_info
attributes:
label: Git state
description: Share output of git branch and latest commit SHA.
placeholder: |
branch: feature/my-branch
commit: abcdef1
- type: textarea
id: additional
attributes:
label: Additional context
description: Add anything else that might help triage.

View File

@ -94,9 +94,9 @@ make dev
Behavior: Behavior:
- Stops existing local services first. - Stops existing local services first.
- Starts Gateway (`8001`, with the embedded LangGraph-compatible runtime), Frontend (`3000`), nginx (`2026`). There is no standalone LangGraph service. - Starts LangGraph (`2024`), Gateway (`8001`), Frontend (`3000`), nginx (`2026`).
- Unified app endpoint: `http://localhost:2026`. - Unified app endpoint: `http://localhost:2026`.
- Logs: `logs/gateway.log`, `logs/frontend.log`, `logs/nginx.log`. - Logs: `logs/langgraph.log`, `logs/gateway.log`, `logs/frontend.log`, `logs/nginx.log`.
Stop services: Stop services:

119
.github/labels.yml vendored
View File

@ -1,119 +0,0 @@
# Declarative label source of truth for DeerFlow.
#
# This file is the single source of truth for repository labels used by the
# auto-labeling workflows (.github/workflows/pr-labeler.yml, pr-triage.yml,
# issue-triage.yml). Auto-labelers can only apply labels that already exist,
# so every label referenced by a workflow MUST be declared here.
#
# Apply with: uv run --with pyyaml python scripts/sync_labels.py [--repo OWNER/NAME]
# CI keeps it in sync via .github/workflows/label-sync.yml (runs on changes here).
#
# Sync is additive/update-only: it creates or updates the labels listed below
# and never deletes labels that are not listed.
#
# Color = 6-digit hex without the leading '#'.
labels:
# ── Type ─────────────────────────────────────────────────────────────────
# Mostly GitHub defaults; declared here so colors/descriptions stay stable
# and so issue templates can rely on them existing.
- name: bug
color: d73a4a
description: Something isn't working
- name: enhancement
color: a2eeef
description: New feature or request
- name: documentation
color: 0075ca
description: Improvements or additions to documentation
- name: question
color: d876e3
description: Further information is requested
# ── Area (auto, by changed paths — see .github/labeler.yml) ───────────────
# Mirrors the "Surface area" section of the pull request template.
- name: "area:frontend"
color: c5def5
description: Next.js frontend under frontend/
- name: "area:backend"
color: c5def5
description: Gateway / runtime / core backend under backend/
- name: "area:agents"
color: c5def5
description: Agents, subagents, graph wiring, prompts, langgraph.json
- name: "area:sandbox"
color: c5def5
description: Sandboxed execution and docker/
- name: "area:skills"
color: c5def5
description: Skills under skills/ or the skills harness
- name: "area:mcp"
color: c5def5
description: Model Context Protocol integration
- name: "area:ci"
color: c5def5
description: GitHub Actions, CI config, repo tooling
- name: "area:docs"
color: c5def5
description: Documentation and Markdown only
- name: "area:deps"
color: c5def5
description: Dependency manifests / lockfiles
# ── Size (auto, by additions + deletions — see pr-triage.yml) ─────────────
- name: "size/XS"
color: "009900"
description: PR changes < 20 lines
- name: "size/S"
color: 77bb00
description: PR changes 20-100 lines
- name: "size/M"
color: eebb00
description: PR changes 100-300 lines
- name: "size/L"
color: ee9900
description: PR changes 300-700 lines
- name: "size/XL"
color: ee5500
description: PR changes 700+ lines
# ── Risk (auto, by changed paths — see pr-triage.yml) ─────────────────────
- name: "risk:low"
color: 0e8a16
description: "Low risk: docs / i18n / assets only"
- name: "risk:medium"
color: fbca04
description: "Medium risk: regular code changes"
- name: "risk:high"
color: b60205
description: "High risk: backend API, agents, sandbox, auth, deps, CI"
# ── Priority (manual) ─────────────────────────────────────────────────────
- name: P0
color: b60205
description: Critical priority
- name: P1
color: d93f0b
description: Major priority
- name: P2
color: e99695
description: Normal priority
# ── Status (auto + manual) ────────────────────────────────────────────────
- name: needs-triage
color: fef2c0
description: Awaiting maintainer triage
- name: needs-validation
color: d4c5f9
description: Touches front/back contract surface; needs real-path validation
- name: skip-validation
color: cccccc
description: "Maintainer override: do not auto-add needs-validation on this PR"
- name: reviewing
color: 5319e7
description: A maintainer is reviewing this PR
# ── Contributor ───────────────────────────────────────────────────────────
- name: first-time-contributor
color: c2e0c6
description: First contribution to this repository — be welcoming

View File

@ -1,75 +0,0 @@
<!-- Reference a related issue with #123. Use Fixes / Closes / Resolves to
auto-close it on merge. Delete this line if the PR doesn't reference an issue. -->
Fixes #
## Why
<!-- Why are you opening this PR? Cover two things:
- The trigger — what made you write this? A bug you hit, a feature you need,
tech debt, or a prod issue?
- The pain being addressed — user-facing problem, or what it unblocks.
For non-trivial features, please open an issue/discussion first to align on
scope before writing code. -->
## What changed
<!-- Describe the change from a user's / caller's perspective, not as a code diff. e.g.:
- "Settings now has a 'Custom endpoint' field, off by default"
- "Backend /api/chat gains a `stream` flag, defaults to false"
- "Default model changed from X to Y — existing users notice on first run" -->
## Surface area
<!-- Check every box that applies. Reviewers use this to scope the review. -->
- [ ] **Frontend UI** — page / component / setting / interaction under `frontend/`
- [ ] **Backend API** — endpoint / SSE event / request-response shape under `backend/app`
- [ ] **Agents / LangGraph** — agent node, graph wiring, `langgraph.json`, or prompt change
- [ ] **Sandbox**`docker/` or sandboxed execution
- [ ] **Skills** — change under `skills/`
- [ ] **Dependencies** — new/upgraded entry in `backend/pyproject.toml` or `frontend/package.json` (say what it buys us)
- [ ] **Default behavior change** — changes existing behavior without the user opting in (default model, default setting, data shape)
- [ ] **Docs / tests / CI only** — no runtime behavior change
## Screenshots / Recording
<!-- If you checked "Frontend UI", attach screenshots showing the entry point —
where users discover the change — not just the feature in isolation.
Before/after is best for behavior changes. Short GIFs welcome. -->
## Bug fix verification
<!-- Skip (delete) this section if this PR is not a bug fix.
Bugs should be encoded as a failing test that goes red before the fix.
Confirm:
- Test path that reproduces the bug:
- Did it go red on `main` and green on this branch? (yes / no)
- If a red test wasn't cheap to write, explain why and what you did instead. -->
## Validation
<!-- What you actually ran. Run at least the checks for the area you changed:
Backend: cd backend && make lint && make test
Frontend: cd frontend && pnpm format && pnpm lint && pnpm typecheck && BETTER_AUTH_SECRET=local-dev-secret pnpm build && make test
Frontend E2E (if you touched frontend/): cd frontend && make test-e2e -->
## AI assistance
<!-- DeerFlow is an AI project — most PRs here use AI coding tools, and that's
welcome. Disclosing it just helps reviewers calibrate how closely to read the
diff. Please fill all three; don't delete the section. -->
**Tool(s) used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Codex, Windsurf, or "none" -->
**How you used it:** <!-- e.g. "generated the module from a spec", "autocomplete only",
"AI wrote tests, I wrote the impl". A prompt or conversation link is great too. -->
- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

View File

@ -1,46 +0,0 @@
name: Backend Blocking IO
on:
push:
branches: ["main", "2.0.x-dev"]
paths:
- "backend/**"
- ".github/workflows/backend-blocking-io-tests.yml"
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "backend/**"
- ".github/workflows/backend-blocking-io-tests.yml"
concurrency:
group: blocking-io-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
backend-blocking-io:
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Install backend dependencies
working-directory: backend
run: uv sync --group dev
- name: Run blocking IO regression tests
working-directory: backend
run: make test-blocking-io

View File

@ -2,7 +2,7 @@ name: Unit Tests
on: on:
push: push:
branches: [ 'main', '2.0.x-dev' ] branches: [ 'main' ]
pull_request: pull_request:
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
@ -14,57 +14,11 @@ permissions:
contents: read contents: read
jobs: jobs:
default-install-collection:
# The main job installs --extra postgres; this job proves the documented
# contributor path (uv sync --group dev, no extras) still collects the
# whole suite, so optional-dependency imports must stay lazy.
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install backend dependencies (documented default)
working-directory: backend
run: uv sync --group dev
- name: Collect backend tests
working-directory: backend
run: uv run pytest --collect-only -q
backend-unit-tests: backend-unit-tests:
if: github.event.pull_request.draft == false if: github.event.pull_request.draft == false
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: deerflow
POSTGRES_PASSWORD: deerflow
POSTGRES_DB: deerflow_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
TEST_POSTGRES_URI: postgresql://deerflow:deerflow@localhost:5432/deerflow_test?sslmode=disable
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@ -79,14 +33,8 @@ jobs:
- name: Install backend dependencies - name: Install backend dependencies
working-directory: backend working-directory: backend
run: uv sync --group dev --extra postgres run: uv sync --group dev
- name: Run unit tests of backend - name: Run unit tests of backend
working-directory: backend working-directory: backend
env:
# Expose the job's Postgres service to the cross-pod dedupe integration
# tests (issue #4120), which read DEDUPE_TEST_POSTGRES_URL. Without this
# mapping those tests silently skip and the headline capability is never
# exercised in CI.
DEDUPE_TEST_POSTGRES_URL: ${{ env.TEST_POSTGRES_URI }}
run: make test run: make test

View File

@ -1,94 +0,0 @@
name: Publish Helm Chart
# Publishes the DeerFlow Helm chart as an OCI artifact to GHCR alongside the
# container images (see container.yaml). Triggers on the same `v*` tags.
#
# On pull requests touching the chart or config.example.yaml, `validate-chart`
# runs lint + template render + a sandbox Service-type gating check + a
# config_version drift check (the chart's embedded config_version must not lag
# config.example.yaml) so a broken or stale chart fails the PR, not the release.
#
# Users then install with:
# helm install deer-flow oci://ghcr.io/${{ owner }}/charts/deer-flow --version <ver>
on:
push:
tags:
- "v*"
pull_request:
paths:
- "deploy/helm/deer-flow/**"
- "config.example.yaml"
- ".github/workflows/chart.yaml"
- "scripts/check_config_version.sh"
- "scripts/check_chart_sandbox_service.sh"
jobs:
validate-chart:
# Runs on PRs and release tags: catch a broken render or a stale
# config_version before merge / publish. A broken chart published under a
# vX.Y.Z tag is an immutable OCI artifact (GHCR won't let you overwrite
# --version), so a regression must fail here, not on install.
if: github.event_name == 'pull_request' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
# ubuntu-latest ships with helm 3 preinstalled - no setup-helm action needed.
- name: Lint chart
run: helm lint deploy/helm/deer-flow
- name: Validate templates render
run: helm template deer-flow deploy/helm/deer-flow --include-crds >/dev/null
# Assert the sandbox Service-type gating: default emits
# SANDBOX_SERVICE_TYPE=ClusterIP and no NODE_HOST; the NodePort opt-in
# emits both. Guards the #3929 default flip against regressions.
- name: Validate sandbox Service-type gating
run: bash scripts/check_chart_sandbox_service.sh
# The chart's `config:` block embeds a config_version that must not fall
# behind config.example.yaml. A stale version is silent in-cluster (the
# image ships no example to compare against, so _check_config_version
# never warns) but means the chart's config is authored against an older
# schema. Bump it in values.yaml and the README example. config_version
# gates no runtime behavior - it only drives the outdated-warning - so a
# bare version bump needs no field changes. Logic lives in
# scripts/check_config_version.sh (shared with nightly.yaml).
- name: config_version drift check
run: bash scripts/check_config_version.sh
verify-versions:
# Gate the release: every version source must match the v* tag. A forgotten
# bump in Chart.yaml, pyproject.toml, or package.json fails here and skips
# the publish. See scripts/verify_versions.sh.
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/verify-versions.yml
publish-chart:
if: startsWith(github.ref, 'refs/tags/v')
needs: [verify-versions, validate-chart]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Log in to GHCR
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | \
helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Package chart
run: helm package deploy/helm/deer-flow --destination ./packages
- name: Push chart to GHCR
run: |
for pkg in ./packages/*.tgz; do
echo "--- pushing $pkg"
helm push "$pkg" oci://ghcr.io/${{ github.repository_owner }}/charts
done

View File

@ -1,162 +0,0 @@
name: Publish Containers
on:
push:
tags:
- "v*"
jobs:
verify-versions:
# Gate the release: every version source must match the v* tag. A forgotten
# bump in Chart.yaml, pyproject.toml, or package.json fails here and skips
# all image builds. See scripts/verify_versions.sh.
uses: ./.github/workflows/verify-versions.yml
backend-container:
needs: verify-versions
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-backend
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Log in to the Container registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 #v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=ref,event=branch
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0
with:
context: .
file: backend/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Bake the `postgres` extra into the published image so multi-replica
# deployments (K8s/Helm) can use shared Postgres persistence instead of
# file-based SQLite. sqlite/redis-only single-replica setups still work;
# this only adds the Postgres driver. See backend/Dockerfile `UV_EXTRAS`.
build-args: |
UV_EXTRAS=postgres
- name: Generate artifact attestation
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be #v2.4.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
frontend-container:
needs: verify-versions
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-frontend
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Log in to the Container registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 #v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=ref,event=branch
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0
with:
context: .
file: frontend/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be #v2.4.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
provisioner-container:
needs: verify-versions
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-provisioner
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Log in to the Container registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 #v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=ref,event=branch
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0
with:
context: docker/provisioner
file: docker/provisioner/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be #v2.4.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

View File

@ -1,69 +0,0 @@
name: E2E Tests
on:
push:
branches: [ 'main', '2.0.x-dev' ]
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: Run auth recovery E2E tests
working-directory: frontend
run: pnpm exec playwright test -c playwright.auth.config.ts
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

View File

@ -1,43 +0,0 @@
name: Frontend Unit Tests
on:
push:
branches: [ 'main', '2.0.x-dev' ]
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: frontend-unit-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
frontend-unit-tests:
if: 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: Run unit tests of frontend
working-directory: frontend
run: make test

View File

@ -1,38 +0,0 @@
name: Label Sync
# Keeps repository labels in sync with the declarative source of truth
# (.github/labels.yml). Runs whenever that file changes on main, and can be
# triggered manually. Additive/update-only — never deletes labels.
on:
push:
branches: [main]
paths:
- ".github/labels.yml"
- "scripts/sync_labels.py"
- ".github/workflows/label-sync.yml"
workflow_dispatch:
permissions:
contents: read
issues: write
concurrency:
group: label-sync
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Sync labels
run: uv run --with pyyaml python scripts/sync_labels.py
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}

View File

@ -2,7 +2,7 @@ name: Lint Check
on: on:
push: push:
branches: [ 'main', '2.0.x-dev' ] branches: [ 'main' ]
pull_request: pull_request:
branches: [ '*' ] branches: [ '*' ]
@ -10,7 +10,7 @@ permissions:
contents: read contents: read
jobs: jobs:
lint-backend: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@ -23,10 +23,6 @@ jobs:
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v7
- name: Check uv.lock is in sync
working-directory: backend
run: uv lock --check
- name: Install dependencies - name: Install dependencies
working-directory: backend working-directory: backend
run: | run: |

View File

@ -1,234 +0,0 @@
name: Nightly Build
# Nightly build of the three DeerFlow container images (backend, frontend,
# provisioner) and the Helm chart, published to GHCR from the default branch.
#
# This mirrors the tag-driven release workflows (container.yaml + chart.yaml)
# but trades the `v*` tag for date-based tags, since nightly ships unreleased
# `main`. The `verify-versions` gate is intentionally skipped - there is no tag
# to match - and `latest` is left untouched so it keeps tracking the last `v*`
# release. Images are amd64-only to match the release builds.
#
# Artifacts (under the running repo's owner):
# ghcr.io/<owner>/deer-flow-{backend,frontend,provisioner}:nightly
# ghcr.io/<owner>/deer-flow-{backend,frontend,provisioner}:nightly-YYYYMMDD
# oci://ghcr.io/<owner>/charts/deer-flow chart version <base>-nightly.YYYYMMDD-<sha>
#
# The nightly chart defaults image.tag=nightly and image.registry=ghcr.io/<owner>
# (patched in-workflow, never committed), so installing it pulls the matching
# nightly images with no values overrides.
#
# Restricted to the upstream repo: every job is gated on
# `github.repository == 'bytedance/deer-flow'`, so a scheduled run or manual
# dispatch on a fork skips all jobs rather than pushing to the fork's own GHCR
# namespace. Scheduled workflows also only fire on the default branch.
on:
schedule:
- cron: "0 16 * * *" # 16:00 UTC daily (adjust as needed)
workflow_dispatch:
concurrency:
group: nightly
# Don't cancel a running nightly - a mid-cancel leaves a half-pushed set.
cancel-in-progress: false
env:
REGISTRY: ghcr.io
jobs:
prepare:
if: github.repository == 'bytedance/deer-flow'
runs-on: ubuntu-latest
outputs:
date: ${{ steps.date.outputs.date }}
nightly_version: ${{ steps.nightly_version.outputs.nightly_version }}
steps:
- name: Set nightly date (UTC)
id: date
# Shared by build-images (image tag) and publish-chart (chart version)
# so the two stay in sync for a given run.
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
- name: Checkout repository
# Needed to read Chart.yaml's base version for the nightly version
# string computed below.
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Compute nightly version
id: nightly_version
# Single source of truth for the nightly version string:
# <base>-nightly.<YYYYMMDD>-<short_sha> (mirrors the chart's nightly
# scheme). build-images injects it into the frontend image (About-page
# version) and publish-chart stamps it on Chart.yaml, so the version
# users see and the chart version can't drift apart.
run: |
set -euo pipefail
BASE=$(grep -m1 '^version:' deploy/helm/deer-flow/Chart.yaml | awk '{print $2}')
# A malformed/missing Chart.yaml version would yield an empty BASE ->
# `-nightly.<date>-<sha>` (invalid semver) that now flows into both
# the chart publish and the frontend About-page version. Fail loudly.
# `pipefail` matters: without it the pipeline's exit code is awk's,
# so `set -e` alone wouldn't catch a grep miss.
test -n "$BASE" || { echo "::error::empty base version from Chart.yaml"; exit 1; }
SHORT_SHA="${GITHUB_SHA::7}"
NIGHTLY="${BASE}-nightly.${{ steps.date.outputs.date }}-${SHORT_SHA}"
echo "nightly_version=${NIGHTLY}" >> "$GITHUB_OUTPUT"
echo "Nightly version: ${NIGHTLY}"
validate-chart:
if: github.repository == 'bytedance/deer-flow'
# Catch a broken render or a stale config_version before publish. A broken
# chart published under an OCI version is immutable (GHCR won't let you
# overwrite --version), so a regression must fail here, not on install.
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Lint chart
run: helm lint deploy/helm/deer-flow
- name: Validate templates render
run: helm template deer-flow deploy/helm/deer-flow --include-crds >/dev/null
# The chart's `config:` block embeds a config_version that must not fall
# behind config.example.yaml. Shared with chart.yaml via
# scripts/check_config_version.sh so the two workflows can't drift.
- name: config_version drift check
run: bash scripts/check_config_version.sh
build-images:
needs: prepare
if: github.repository == 'bytedance/deer-flow'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
strategy:
fail-fast: false
matrix:
include:
- component: backend
context: .
file: backend/Dockerfile
# Bake the `postgres` extra so multi-replica (K8s/Helm) deployments
# can use shared Postgres. Matches container.yaml's release image.
build-args: "UV_EXTRAS=postgres"
- component: frontend
context: .
file: frontend/Dockerfile
build-args: ""
- component: provisioner
context: docker/provisioner
file: docker/provisioner/Dockerfile
build-args: ""
env:
IMAGE_NAME: ${{ github.repository }}-${{ matrix.component }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Log in to the Container registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 #v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# `nightly` rolls forward each run; `nightly-YYYYMMDD` is pinned to a
# day but mutable within it (a same-day re-dispatch overwrites it);
# `sha-<short>` is the only truly immutable tag. No `latest` (that
# stays on v* releases) and no branch/tag refs.
tags: |
type=raw,value=nightly
type=raw,value=nightly-${{ needs.prepare.outputs.date }}
type=sha
- name: Build and push Docker image
id: push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0
with:
context: ${{ matrix.context }}
file: ${{ matrix.file }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# APP_VERSION is consumed only by the frontend Dockerfile (it stamps
# the About-page version); backend/provisioner Dockerfiles don't
# declare it. Passed via build-arg because build-push-action doesn't
# forward host env into the BuildKit build.
build-args: |
${{ matrix.build-args }}
${{ matrix.component == 'frontend' && format('APP_VERSION={0}', needs.prepare.outputs.nightly_version) || '' }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be #v2.4.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
publish-chart:
# Ship the chart only after images build - it references all three, so a
# failed image build withholds the chart rather than publishing one that
# would fail to pull in-cluster.
needs: [prepare, validate-chart, build-images]
if: github.repository == 'bytedance/deer-flow'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
OWNER: ${{ github.repository_owner }}
NIGHTLY: ${{ needs.prepare.outputs.nightly_version }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Patch chart to a nightly version + nightly image defaults
# Bumps Chart.yaml version/appVersion to the nightly version computed in
# the prepare job (<base>-nightly.<date>-<sha>, a valid semver
# prerelease; the short SHA makes each dispatch's version unique, so a
# same-day re-dispatch re-publishes cleanly - OCI chart versions are
# immutable and otherwise can't be overwritten). The same string is
# injected into the frontend image, so the About-page version and the
# chart version match. Repoints the chart's default image registry/tag
# at the nightly build. Patches are in-workflow only - nothing is
# committed back.
run: |
set -eu
CHART=deploy/helm/deer-flow
echo "Nightly chart version: ${NIGHTLY}"
sed -i "s|^version:.*|version: ${NIGHTLY}|" "$CHART/Chart.yaml"
sed -i "s|^appVersion:.*|appVersion: \"${NIGHTLY}\"|" "$CHART/Chart.yaml"
sed -i "s|^ registry: \"\".*| registry: \"ghcr.io/${OWNER}\"|" "$CHART/values.yaml"
sed -i 's|^ tag: "latest"| tag: "nightly"|' "$CHART/values.yaml"
# Gate the patches: sed exits 0 even on zero matches, so a drifted
# Chart.yaml/values.yaml would otherwise ship a chart that silently
# pulls the wrong (release `latest`) images. Fail loudly if any patch
# missed.
grep -q "^version: ${NIGHTLY}$" "$CHART/Chart.yaml" || { echo "::error::Chart.yaml version sed did not apply"; exit 1; }
grep -q "^appVersion: \"${NIGHTLY}\"$" "$CHART/Chart.yaml" || { echo "::error::Chart.yaml appVersion sed did not apply"; exit 1; }
grep -q '^ registry: "ghcr.io/' "$CHART/values.yaml" || { echo "::error::values.yaml registry sed did not apply"; exit 1; }
grep -q '^ tag: "nightly"' "$CHART/values.yaml" || { echo "::error::values.yaml tag sed did not apply"; exit 1; }
echo "--- Chart.yaml (head) ---"; sed -n '1,7p' "$CHART/Chart.yaml"
echo "--- image block ---"; sed -n '11,14p' "$CHART/values.yaml"
- name: Lint patched chart
run: helm lint deploy/helm/deer-flow
- name: Log in to GHCR
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | \
helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Package and push chart
run: |
helm package deploy/helm/deer-flow --destination ./packages
for pkg in ./packages/*.tgz; do
echo "--- pushing $pkg"
helm push "$pkg" oci://ghcr.io/${{ github.repository_owner }}/charts
done

View File

@ -1,108 +0,0 @@
name: Replay E2E (front-back contract)
# Guards the front-back contract via record/replay (no API key in CI):
# Layer 1 — backend golden: replay a recorded trace through the real gateway,
# assert the SSE event sequence matches the committed golden.
# Layer 2 — full-stack render: real Next.js frontend + real gateway (replay
# model) + Chromium; assert the replayed turns render in the browser.
# Triggered by changes on EITHER side of the contract so a backend change can no
# longer pass without the frontend-facing checks running.
on:
push:
branches: ["main", "2.0.x-dev"]
paths:
- "frontend/**"
- "backend/app/gateway/**"
- "backend/packages/harness/**"
- "backend/tests/fixtures/replay/**"
- "backend/tests/replay_provider.py"
- "backend/tests/_replay_fixture.py"
- "backend/tests/seed_runs_router.py"
- "backend/tests/test_replay_golden.py"
- "backend/scripts/run_replay_gateway.py"
- ".github/workflows/replay-e2e.yml"
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "frontend/**"
- "backend/app/gateway/**"
- "backend/packages/harness/**"
- "backend/tests/fixtures/replay/**"
- "backend/tests/replay_provider.py"
- "backend/tests/_replay_fixture.py"
- "backend/tests/seed_runs_router.py"
- "backend/tests/test_replay_golden.py"
- "backend/scripts/run_replay_gateway.py"
- ".github/workflows/replay-e2e.yml"
concurrency:
group: replay-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
backend-replay-golden:
name: Layer 1 — backend golden (no API key)
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install backend dependencies
working-directory: backend
run: uv sync --group dev
- name: Replay golden (backend SSE contract)
working-directory: backend
run: PYTHONPATH=. uv run pytest tests/test_replay_golden.py -v
fullstack-replay-render:
name: Layer 2 — full-stack render (no API key)
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install backend dependencies (replay gateway)
working-directory: backend
run: uv sync --group dev
- 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: Full-stack replay render (DOM assertions are the gate)
working-directory: frontend
run: pnpm exec playwright test -c playwright.real-backend.config.ts
- name: Upload report + render artifact
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: replay-render
path: |
frontend/playwright-report/
frontend/test-results/
retention-days: 7

View File

@ -1,70 +0,0 @@
name: Skill Review CI
on:
push:
branches: ["main", "2.0.x-dev"]
paths:
- "skills/public/**"
- "backend/packages/harness/deerflow/skills/review/**"
- "contracts/skill_review/**"
- "scripts/review_changed_public_skills.py"
- "backend/pyproject.toml"
- "backend/uv.lock"
- ".github/workflows/skill-review-ci.yml"
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "skills/public/**"
- "backend/packages/harness/deerflow/skills/review/**"
- "contracts/skill_review/**"
- "scripts/review_changed_public_skills.py"
- "backend/pyproject.toml"
- "backend/uv.lock"
- ".github/workflows/skill-review-ci.yml"
concurrency:
group: skill-review-ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
skill-review:
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install backend dependencies
working-directory: backend
run: uv sync --group dev
- name: Review changed public skills (pull request)
if: github.event_name == 'pull_request'
working-directory: backend
run: |
uv run python ../scripts/review_changed_public_skills.py \
--base-ref "${{ github.event.pull_request.base.sha }}" \
--head-ref "${{ github.event.pull_request.head.sha }}"
- name: Review changed public skills (push)
if: github.event_name == 'push'
working-directory: backend
run: |
uv run python ../scripts/review_changed_public_skills.py \
--before "${{ github.event.before }}" \
--after "${{ github.event.after }}"

View File

@ -1,223 +0,0 @@
name: Triage
# One workflow for all event-driven PR/issue labeling. Replaces the former
# pr-labeler / pr-triage / issue-triage workflows (and drops actions/labeler).
#
# Design notes:
# * All jobs are pure-metadata: they read changed-file lists / PR fields / the
# review payload via the API and write labels. PR code is NEVER checked out
# or executed, so pull_request_target is safe here.
# * Each job only reconciles labels in namespaces IT owns
# (area:* / size/* / risk:* / needs-validation). It never touches labels
# applied by maintainers or other tools (bug, priority, etc.). first-time-
# contributor and reviewing are add-only.
# * State is read LIVE (listFiles + listLabelsOnIssue) at run time, not from
# the (stale) event payload, so rapid synchronize events converge instead
# of thrashing.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
pull_request_review:
types: [submitted]
issues:
types: [opened]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
# ── PR: area / size / risk / needs-validation / first-time ─────────────────
pr-labels:
if: github.event_name == 'pull_request_target' && github.event.pull_request.draft == false
runs-on: ubuntu-latest
concurrency:
group: triage-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- name: Apply PR labels from live state
uses: actions/github-script@v8
with:
script: |
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
const num = pr.number;
// ---- live changed files ----
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: num, per_page: 100,
});
const paths = files.map(f => f.filename);
const m = (re) => paths.some(p => re.test(p));
// ---- area: replaces .github/labeler.yml (path -> area) ----
const AREA_RULES = [
['area:frontend', [/^frontend\//]],
['area:backend', [/^backend\/app\//, /^backend\/packages\/harness\/deerflow\/(runtime|persistence|config|tools|guardrails|tracing|models|utils|uploads)\//]],
['area:agents', [/^backend\/packages\/harness\/deerflow\/(agents|subagents|reflection)\//, /(^|\/)langgraph\.json$/, /^backend\/.*\/prompts\//]],
['area:sandbox', [/^docker\//, /^backend\/packages\/harness\/deerflow\/sandbox\//, /(^|\/)Dockerfile$/]],
['area:skills', [/^skills\//, /^backend\/packages\/harness\/deerflow\/skills\//, /^frontend\/src\/core\/skills\//]],
['area:mcp', [/^backend\/packages\/harness\/deerflow\/mcp\//, /^frontend\/src\/core\/mcp\//]],
['area:ci', [/^\.github\//, /^scripts\//]],
['area:docs', [/^docs\//, /\.mdx?$/]],
['area:deps', [/(^|\/)(pyproject\.toml|uv\.lock|package\.json|pnpm-lock\.yaml)$/]],
];
const areaLabels = AREA_RULES
.filter(([, res]) => res.some(re => m(re)))
.map(([label]) => label);
// ---- size: additions+deletions, excluding lockfiles/snapshots ----
const EXCLUDE_SIZE = /(^|\/)(uv\.lock|pnpm-lock\.yaml|package-lock\.json)$|\.snap$/;
const churn = files
.filter(f => !EXCLUDE_SIZE.test(f.filename))
.reduce((s, f) => s + (f.additions || 0) + (f.deletions || 0), 0);
const sizeLabel =
churn < 20 ? 'size/XS' :
churn < 100 ? 'size/S' :
churn < 300 ? 'size/M' :
churn < 700 ? 'size/L' : 'size/XL';
// ---- risk ----
const docsOnly = paths.length > 0 && paths.every(p =>
/\.(md|mdx|txt)$/i.test(p) || p.startsWith('docs/') ||
/\.(png|jpe?g|gif|svg|webp|ico)$/i.test(p));
const highRisk =
m(/^backend\/app\/gateway\//) ||
m(/^backend\/packages\/harness\/deerflow\/(agents|subagents|sandbox)\//) ||
m(/(^|\/)langgraph\.json$/) ||
m(/(^|\/)(auth|authz|security)/i) ||
m(/(pyproject\.toml|uv\.lock|package\.json|pnpm-lock\.yaml)$/) ||
m(/^docker\//) ||
m(/^\.github\/workflows\//);
const riskLabel = docsOnly ? 'risk:low' : (highRisk ? 'risk:high' : 'risk:medium');
// ---- needs-validation: front/back contract surface ----
const contract =
m(/^backend\/app\/gateway\//) ||
m(/^backend\/packages\/harness\/deerflow\/(agents|subagents)\//) ||
m(/(^|\/)langgraph\.json$/) ||
m(/^frontend\/src\/core\/(api|threads|messages)\//);
// ---- live current labels (NOT the stale event payload) ----
const current = (await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number: num, per_page: 100,
})).map(l => l.name);
const hasSkip = current.includes('skip-validation');
// Reconcile ONLY namespaces we own; never touch others.
const owned = (n) =>
n.startsWith('area:') || n.startsWith('size/') ||
n.startsWith('risk:') || n === 'needs-validation';
const desired = new Set([...areaLabels, sizeLabel, riskLabel]);
if (contract && !hasSkip) desired.add('needs-validation');
const toRemove = current.filter(n => owned(n) && !desired.has(n));
const toAdd = [...desired].filter(n => !current.includes(n));
// first-time-contributor: add-only, on opened, real users only.
if (context.payload.action === 'opened' &&
pr.user.type === 'User' &&
['FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER'].includes(pr.author_association) &&
!current.includes('first-time-contributor')) {
toAdd.push('first-time-contributor');
}
for (const name of toRemove) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name });
} catch (e) {
if (e.status !== 404) throw e;
}
}
if (toAdd.length) {
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: toAdd });
}
core.info(`area=[${areaLabels.join(',')}] ${sizeLabel} ${riskLabel} churn=${churn} ` +
`validation=${desired.has('needs-validation')} ` +
`(+${toAdd.join(',') || '-'} / -${toRemove.join(',') || '-'})`);
# ── PR: reviewing label on a maintainer's human review ─────────────────────
reviewing:
if: github.event_name == 'pull_request_review'
runs-on: ubuntu-latest
concurrency:
group: triage-review-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- name: Add reviewing label for maintainer reviews
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const num = context.payload.pull_request.number;
const review = context.payload.review;
const assoc = review.author_association; // payload field; no API call
const type = review.user && review.user.type;
// author_association is NONE for every automated reviewer
// (Copilot, CodeRabbit, Codex, Sourcery, ...), so this allowlist
// drops them all without a denylist — and never calls the
// collaborators API that 404s on "Copilot is not a user".
// user.type === 'User' guards the rare bot-added-as-collaborator case.
if (!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc) || type !== 'User') {
core.info(`reviewer ${review.user && review.user.login} assoc=${assoc} type=${type}; skipping.`);
return;
}
const labels = (await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number: num, per_page: 100,
})).map(l => l.name);
if (labels.includes('reviewing')) {
core.info('Already labeled reviewing; skipping.');
return;
}
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: num, labels: ['reviewing'],
});
core.info('Added "reviewing".');
} catch (e) {
if (e.status === 403) core.info('No permission to label (expected on some fork PRs).');
else throw e;
}
# ── Issue: needs-triage on every new issue ────────────────────────────────
issue-triage:
if: github.event_name == 'issues'
runs-on: ubuntu-latest
concurrency:
group: triage-issue-${{ github.event.issue.number }}
cancel-in-progress: false
steps:
- name: Add needs-triage label
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const issue_number = context.payload.issue.number;
// Read live labels (not the event payload) so labels added at creation
// time via the API or by another automation are seen — consistent with
// the live-state reads in the PR jobs above.
const current = (await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number, per_page: 100,
})).map(l => l.name);
if (current.includes('needs-triage')) {
core.info('Issue already has needs-triage; nothing to do.');
return;
}
// Self-heal: create the label if it does not exist yet.
try {
await github.rest.issues.createLabel({
owner, repo, name: 'needs-triage', color: 'fef2c0',
description: 'Awaiting maintainer triage',
});
} catch (e) {
if (e.status !== 422) throw e; // 422 = already exists
}
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: ['needs-triage'],
});
core.info(`Added needs-triage to #${issue_number}.`);

View File

@ -1,27 +0,0 @@
name: Verify Versions
# Reusable workflow: checks that every project version source agrees with the
# git tag that triggered the release. Called by chart.yaml and container.yaml
# on v* tags so a forgotten version bump blocks the entire release (container
# images + chart), not just the chart.
#
# Sources verified: deploy/helm/deer-flow/Chart.yaml (version + appVersion),
# backend/pyproject.toml, frontend/package.json. Logic lives in
# scripts/verify_versions.sh so it can also be run locally.
on:
workflow_call:
jobs:
verify-versions:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Verify all version sources match tag
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
bash scripts/verify_versions.sh "$TAG_VERSION"

12
.gitignore vendored
View File

@ -20,12 +20,6 @@ __pycache__/
.venv .venv
venv/ venv/
# Benchmark outputs
bench_results.jsonl
bench_optimized.jsonl
results.jsonl
backend/scripts/benchmark/*.jsonl
# Environment variables # Environment variables
.env .env
@ -46,7 +40,6 @@ coverage/
skills/custom/* skills/custom/*
logs/ logs/
log/ log/
debug.log
# Local git hooks (keep only on this machine, do not push) # Local git hooks (keep only on this machine, do not push)
.githooks/ .githooks/
@ -62,10 +55,5 @@ web/
backend/Dockerfile.langgraph backend/Dockerfile.langgraph
config.yaml.bak config.yaml.bak
.playwright-mcp .playwright-mcp
/frontend/test-results/
/frontend/playwright-report/
.gstack/ .gstack/
.worktrees .worktrees
# Monocle agent-observability trace output (local file exporter)
.monocle/

View File

@ -1,39 +0,0 @@
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/
- id: uv-lock-check
name: uv lock check
entry: bash -c 'cd backend && uv lock --check'
language: system
pass_filenames: false
files: ^backend/(pyproject\.toml|uv\.lock|packages/harness/pyproject\.toml)$
# 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]

138
AGENTS.md
View File

@ -1,138 +0,0 @@
# AGENTS.md
This file provides guidance to AI coding agents (Claude Code, Codex, and others) when working with code in this repository. It is the source of truth; the sibling `CLAUDE.md` imports it via `@AGENTS.md`.
It is the **monorepo orientation layer**: it maps the whole repo and points to the
module guides that own the depth. For anything inside a module, read that module's
guide rather than expecting full detail here:
- **[backend/AGENTS.md](backend/AGENTS.md)** — backend depth: harness/app split, agent &
middleware chain, sandbox, MCP, skills, memory, IM channels, persistence/migrations,
config system, test layout.
- **[frontend/AGENTS.md](frontend/AGENTS.md)** — frontend depth: Next.js App Router layout,
thread/streaming data flow, code style, commands.
## What is DeerFlow
DeerFlow is a LangGraph-based AI super-agent system with a full-stack architecture. The
backend runs a "super agent" with sandboxed execution, persistent memory, subagent
delegation, and extensible tools (built-in, MCP, community), all per-thread isolated. The
frontend is a Next.js chat UI. External IM platforms (Feishu, Slack, Telegram, Discord,
DingTalk) bridge into the same agent through the Gateway.
## Service Topology
A single `make dev` / Docker stack runs four cooperating services:
| Service | Port | Role |
| --------------- | ------ | ------------------------------------------------------------------- |
| **Nginx** | `2026` | Unified reverse-proxy entry point — open this in the browser |
| **Gateway API** | `8001` | FastAPI REST API + embedded LangGraph-compatible agent runtime |
| **Frontend** | `3000` | Next.js web interface |
| **Provisioner** | `8002` | Optional — only when sandbox is configured for provisioner/K8s mode |
Nginx is the single public entry: it serves the frontend and proxies `/api/langgraph/*`
to the Gateway's LangGraph runtime, rewriting it to Gateway's native `/api/*` routes; all
other `/api/*` go straight to the Gateway REST routers. See
[backend/AGENTS.md](backend/AGENTS.md) for the runtime and router detail.
## Repository Map
```
deer-flow/
├── Makefile # Root orchestration: drives the full stack (dev/start/stop, docker, setup)
├── config.example.yaml # Template → copy to config.yaml (gitignored) at repo root
├── extensions_config.example.json # Template → copy to extensions_config.json (gitignored): MCP servers + skills
├── backend/ # Python backend — see backend/AGENTS.md
│ ├── Makefile # Per-module backend commands (dev, gateway, test, lint, migrate-rev)
│ ├── packages/harness/ # deerflow-harness package (import: deerflow.*) — agent framework
│ └── app/ # FastAPI Gateway + IM channels (import: app.*)
├── frontend/ # Next.js frontend (pnpm) — see frontend/AGENTS.md
├── docker/ # docker-compose files, nginx config, provisioner
├── skills/ # Agent skills: public/ (committed), custom/ (gitignored)
│ # Managed integration skill packs are installed per user under .deer-flow/users/{user_id}/skills/integrations/
├── contracts/ # Cross-component JSON contracts (e.g. subagent status, skill review)
├── scripts/ # Root orchestration scripts invoked by the Makefile (check, configure, doctor, support_bundle, serve, nginx, docker, deploy, setup_wizard)
├── tests/ # Root-level tests (currently tests/skills/ — public skill tests)
└── docs/ # Cross-cutting docs, plans, and design notes
```
Runtime config lives at the **repo root**: copy `config.example.yaml``config.yaml`
(main app config) and `extensions_config.example.json``extensions_config.json` (MCP
servers + skills). Both real files are gitignored and may be edited at runtime via the
Gateway API. Config schema and resolution order are documented in
[backend/AGENTS.md](backend/AGENTS.md).
Skill quality review note:
- `skills/public/skill-reviewer/` is the built-in read-only skill quality reviewer.
It uses the harness-layer `review_skill_package` tool and contracts in
`contracts/skill_review/`. Model-visible review data is compact and
tag-neutralized; full raw payloads stay in tool artifacts. See
[backend/AGENTS.md](backend/AGENTS.md) for the non-activation, SkillScan, and
`skill-creator` ownership boundaries.
Scheduled-task note:
- The scheduled-task MVP adds a workspace page at `/workspace/scheduled-tasks` plus a background scheduler service gated by `config.yaml -> scheduler.enabled`.
- Scheduled background runs are intentionally non-interactive: they execute through the normal run lifecycle, but the lead-agent toolset excludes `ask_clarification` when `context.non_interactive=true`. The key is honored only for internally-authenticated callers (the scheduler launch path); client-supplied `context.non_interactive` is dropped.
## Commands: Root vs. Module
**Root `make` targets drive the whole stack** (run from the repo root):
```bash
make setup # Interactive setup wizard (recommended for new users)
make doctor # Check configuration and system requirements
make support-bundle # Generate redacted troubleshooting summary, AI issue draft, and optional zip
make config # Generate local config files from the examples
make check # Check that required tools are installed
make install # Install all dependencies (frontend + backend + pre-commit hooks)
make dev # Start all services with hot-reload (Gateway + Frontend + Nginx)
make start # Start all services in production mode (local, optimized)
make stop # Stop all running services
make up / down # Build/stop the production Docker stack (browser at localhost:2026)
make docker-start / docker-stop / docker-logs # Docker development environment
```
Run `make help` for the full list.
**Per-module commands drive a single module** (run inside that module):
```bash
# Backend (see backend/AGENTS.md for the full set)
cd backend && make dev # Gateway API with reload (port 8001)
cd backend && make test # Backend test suite
cd backend && make lint # ruff check
cd backend && make format # ruff format
# Frontend (see frontend/AGENTS.md for the full set)
cd frontend && pnpm dev # Dev server with Turbopack (port 3000)
cd frontend && pnpm check # Lint + type check (run before committing)
cd frontend && pnpm test # Unit tests
```
Rule of thumb: **root `make` = the full application**; **`backend/Makefile` and `frontend/`
(`pnpm`) = per-module work.**
## Where to Go Next
- Backend work → **[backend/AGENTS.md](backend/AGENTS.md)**
- Frontend work → **[frontend/AGENTS.md](frontend/AGENTS.md)**
- Setup & install → **[Install.md](Install.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**
- Project overview & usage → **[README.md](README.md)** (translations: `README_zh.md`,
`README_ja.md`, `README_fr.md`, `README_ru.md`)
- Security policy → **[SECURITY.md](SECURITY.md)**
- Changes → **[CHANGELOG.md](CHANGELOG.md)**
- Cutting a release → **[RELEASING.md](RELEASING.md)**
## Cross-Cutting Conventions
These apply repo-wide; module guides own the module-specific detail.
- **Documentation update policy** — keep docs in sync with code: update `README.md` for
user-facing changes and the relevant `AGENTS.md` for development/architecture changes in
the same change set.
- **Test-driven development** — features and bug fixes ship with tests. Backend tests live
in `backend/tests/` (TDD is mandatory there; see [backend/AGENTS.md](backend/AGENTS.md));
frontend tests live in `frontend/tests/`.
- **Format before pushing** — run `make format` (backend) / `pnpm check` (frontend). Backend
CI enforces `ruff format --check`, so formatting must be clean before a push.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +0,0 @@
# CLAUDE.md
The repo's agent guidance lives in [AGENTS.md](AGENTS.md) so it is shared across coding agents (Claude Code, Codex, and others). Claude Code imports it below.
@AGENTS.md

View File

@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban ### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community **Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals. individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within **Consequence**: A permanent ban from any sort of public interaction within

View File

@ -46,12 +46,12 @@ Docker provides a consistent, isolated environment with all dependencies pre-con
All services will start with hot-reload enabled: All services will start with hot-reload enabled:
- Frontend changes are automatically reloaded - Frontend changes are automatically reloaded
- Backend changes trigger automatic restart - Backend changes trigger automatic restart
- Gateway-hosted LangGraph-compatible runtime supports hot-reload - LangGraph server supports hot-reload
4. **Access the application**: 4. **Access the application**:
- Web Interface: http://localhost:2026 - Web Interface: http://localhost:2026
- API Gateway: http://localhost:2026/api/* - API Gateway: http://localhost:2026/api/*
- LangGraph-compatible API: http://localhost:2026/api/langgraph/* - LangGraph: http://localhost:2026/api/langgraph/*
#### Docker Commands #### Docker Commands
@ -94,7 +94,7 @@ Use these as practical starting points for development and review environments:
If `make docker-init`, `make docker-start`, or `make docker-stop` fails on Linux with an error like below, your current user likely does not have permission to access the Docker daemon socket: If `make docker-init`, `make docker-start`, or `make docker-stop` fails on Linux with an error like below, your current user likely does not have permission to access the Docker daemon socket:
```text ```text
unable to get image 'deer-flow-gateway': permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock unable to get image 'deer-flow-dev-langgraph': permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
``` ```
Recommended fix: add your current user to the `docker` group so Docker commands work without `sudo`. Recommended fix: add your current user to the `docker` group so Docker commands work without `sudo`.
@ -131,8 +131,9 @@ Host Machine
Docker Compose (deer-flow-dev) Docker Compose (deer-flow-dev)
├→ nginx (port 2026) ← Reverse proxy ├→ nginx (port 2026) ← Reverse proxy
├→ web (port 3000) ← Frontend with hot-reload ├→ web (port 3000) ← Frontend with hot-reload
├→ gateway (port 8001) ← Gateway API + LangGraph-compatible runtime with hot-reload ├→ api (port 8001) ← Gateway API with hot-reload
└→ provisioner (optional, port 8002) ← Started only in provisioner/K8s sandbox mode ├→ langgraph (port 2024) ← LangGraph server with hot-reload
└→ provisioner (optional, port 8002) ← Started only in provisioner/K8s sandbox mode
``` ```
**Benefits of Docker Development**: **Benefits of Docker Development**:
@ -165,7 +166,7 @@ Required tools:
1. **Configure the application** (same as Docker setup above) 1. **Configure the application** (same as Docker setup above)
2. **Install dependencies** (this also sets up pre-commit hooks): 2. **Install dependencies**:
```bash ```bash
make install make install
``` ```
@ -183,24 +184,27 @@ Required tools:
If you need to start services individually: If you need to start services individually:
1. **Start backend service**: 1. **Start backend services**:
```bash ```bash
# Terminal 1: Start Gateway API + embedded agent runtime (port 8001) # Terminal 1: Start LangGraph Server (port 2024)
cd backend cd backend
make dev make dev
# Terminal 2: Start Frontend (port 3000) # Terminal 2: Start Gateway API (port 8001)
cd backend
make gateway
# Terminal 3: Start Frontend (port 3000)
cd frontend cd frontend
pnpm dev pnpm dev
``` ```
2. **Start nginx** (run from the repo root): 2. **Start nginx**:
```bash ```bash
make nginx make nginx
# or directly: nginx -c $(pwd)/docker/nginx/nginx.local.conf -g 'daemon off;'
``` ```
This runs `scripts/nginx.sh`, which launches nginx in the foreground the same way `scripts/serve.sh` (used by `make dev` / `make start`) does: it pre-creates the `logs/` and `temp/` directories and uses the local dev config at `docker/nginx/nginx.local.conf`.
3. **Access the application**: 3. **Access the application**:
- Web Interface: http://localhost:2026 - Web Interface: http://localhost:2026
@ -208,10 +212,10 @@ If you need to start services individually:
The nginx configuration provides: The nginx configuration provides:
- Unified entry point on port 2026 - Unified entry point on port 2026
- Rewrites `/api/langgraph/*` to Gateway's LangGraph-compatible API (8001) - Routes `/api/langgraph/*` to LangGraph Server (2024)
- Routes other `/api/*` endpoints to Gateway API (8001) - Routes other `/api/*` endpoints to Gateway API (8001)
- Routes non-API requests to Frontend (3000) - Routes non-API requests to Frontend (3000)
- Same-origin API routing; split-origin or port-forwarded browser clients should use the Gateway `GATEWAY_CORS_ORIGINS` allowlist - Centralized CORS handling
- SSE/streaming support for real-time agent responses - SSE/streaming support for real-time agent responses
- Optimized timeouts for long-running operations - Optimized timeouts for long-running operations
@ -230,11 +234,12 @@ deer-flow/
│ ├── nginx.conf # Nginx config for Docker │ ├── nginx.conf # Nginx config for Docker
│ └── nginx.local.conf # Nginx config for local dev │ └── nginx.local.conf # Nginx config for local dev
├── backend/ # Backend application ├── backend/ # Backend application
│ ├── packages/harness/ # deerflow-harness package (import: deerflow.*) │ ├── src/
│ │ └── deerflow/ # Agents, tools, sandbox, MCP, skills, config │ │ ├── gateway/ # Gateway API (port 8001)
│ ├── app/ # FastAPI Gateway + IM channels (import: app.*) │ │ ├── agents/ # LangGraph agents (port 2024)
│ │ ├── gateway/ # Gateway API and LangGraph-compatible runtime (port 8001) │ │ ├── mcp/ # Model Context Protocol integration
│ │ └── channels/ # IM channel integrations │ │ ├── skills/ # Skills system
│ │ └── sandbox/ # Sandbox execution
│ ├── docs/ # Backend documentation │ ├── docs/ # Backend documentation
│ └── Makefile # Backend commands │ └── Makefile # Backend commands
├── frontend/ # Frontend application ├── frontend/ # Frontend application
@ -251,7 +256,8 @@ Browser
Nginx (port 2026) ← Unified entry point Nginx (port 2026) ← Unified entry point
├→ Frontend (port 3000) ← / (non-API requests) ├→ Frontend (port 3000) ← / (non-API requests)
└→ Gateway API (port 8001) ← /api/* and /api/langgraph/* (LangGraph-compatible agent interactions) ├→ Gateway API (port 8001) ← /api/models, /api/mcp, /api/skills, /api/threads/*/artifacts
└→ LangGraph Server (port 2024) ← /api/langgraph/* (agent interactions)
``` ```
## Development Workflow ## Development Workflow
@ -287,44 +293,24 @@ Nginx (port 2026) ← Unified entry point
git push origin feature/your-feature-name git push origin feature/your-feature-name
``` ```
## AI assistance disclosure
DeerFlow is an AI project and we welcome AI-assisted contributions. To help
reviewers calibrate how closely to read a change, **every pull request must
complete the "AI assistance" section of the
[PR template](.github/pull_request_template.md)**:
- which tool(s) you used (or `none`),
- how you used them, and
- a confirmation that a human has read, understands, and takes responsibility
for the change.
Please don't delete the section. PRs that ignore it may be asked to fill it in
before review.
## Testing ## Testing
```bash ```bash
# Backend tests # Backend tests
cd backend cd backend
make test uv run pytest
# Frontend unit tests # Frontend checks
cd frontend cd frontend
make test pnpm check
# Frontend E2E tests (requires Chromium; builds and auto-starts the Next.js production server)
cd frontend
make test-e2e
``` ```
### PR Regression Checks ### PR Regression Checks
Every pull request triggers the following CI workflows: Every pull request runs the backend regression workflow at [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml), including:
- **Backend unit tests** — [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml) - `tests/test_provisioner_kubeconfig.py`
- **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml) - `tests/test_docker_sandbox_mode_detection.py`
- **Frontend E2E tests** — [.github/workflows/e2e-tests.yml](.github/workflows/e2e-tests.yml) (triggered only when `frontend/` files change)
## Code Style ## Code Style
@ -338,38 +324,6 @@ Every pull request triggers the following CI workflows:
- [Architecture Overview](backend/CLAUDE.md) - Technical architecture - [Architecture Overview](backend/CLAUDE.md) - Technical architecture
- [MCP Setup Guide](backend/docs/MCP_SERVER.md) - Model Context Protocol configuration - [MCP Setup Guide](backend/docs/MCP_SERVER.md) - Model Context Protocol configuration
## Troubleshooting Bundle
For setup, configuration, sandbox, or runtime issues, generate a redacted support
summary before filing:
```bash
make support-bundle
```
The command prints reporter next steps, writes a `*-issue-summary.md` file that
you can paste into the issue, writes a `*-issue-draft.md` file for AI-assisted
issue filing, and writes an optional evidence zip under
`.deer-flow/support-bundles/`. The zip includes toolchain versions, sanitized
`config.yaml` and `extensions_config.json` summaries, enabled tool/skill/MCP
structure, git metadata, and redacted `make doctor` output.
When filing the issue, paste the generated `*-issue-summary.md` into the issue
body. If an AI assistant files the issue, start from `*-issue-draft.md` and
replace every REQUIRED placeholder before filing; the draft intentionally does
not invent reproduction steps, expected behavior, or a problem summary. Attach
the zip only if a maintainer asks for the evidence bundle, or if the summary
alone is not enough to diagnose the issue. Maintainers and AI-assisted triage
should start with `triage.json`, which contains stable signals such as
`config_missing`, `node_version_too_old`, `doctor_failed`, and suggested next
steps. The other JSON files are evidence for follow-up inspection.
It intentionally does **not** include `.env`, raw conversation messages, or the
contents of files in thread workspaces/uploads/outputs. If you need to include a
thread, run `cd backend && uv run python ../scripts/support_bundle.py --thread-id
<thread-id> --include-doctor`; this adds file manifests only. Please still review
the generated zip before attaching it to a public issue.
## Need Help? ## Need Help?
- Check existing [Issues](https://github.com/bytedance/deer-flow/issues) - Check existing [Issues](https://github.com/bytedance/deer-flow/issues)

View File

@ -1,6 +1,6 @@
# DeerFlow - Unified Development Environment # DeerFlow - Unified Development Environment
.PHONY: help config config-upgrade check install setup doctor support-bundle detect-thread-boundaries detect-blocking-io dev dev-daemon start start-daemon nginx stop up down clean docker-init docker-start docker-stop docker-logs docker-logs-frontend docker-logs-gateway docker-logs-redis .PHONY: help config config-upgrade check install setup doctor dev dev-pro dev-daemon dev-daemon-pro start start-pro start-daemon start-daemon-pro stop up up-pro down clean docker-init docker-start docker-start-pro docker-stop docker-logs docker-logs-frontend docker-logs-gateway
BASH ?= bash BASH ?= bash
BACKEND_UV_RUN = cd backend && uv run BACKEND_UV_RUN = cd backend && uv run
@ -20,34 +20,35 @@ help:
@echo "DeerFlow Development Commands:" @echo "DeerFlow Development Commands:"
@echo " make setup - Interactive setup wizard (recommended for new users)" @echo " make setup - Interactive setup wizard (recommended for new users)"
@echo " make doctor - Check configuration and system requirements" @echo " make doctor - Check configuration and system requirements"
@echo " make support-bundle - Create a redacted issue summary, AI draft, and evidence bundle"
@echo " make config - Generate local config files (aborts if config already exists)" @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 config-upgrade - Merge new fields from config.example.yaml into config.yaml"
@echo " make check - Check if all required tools are installed" @echo " make check - Check if all required tools are installed"
@echo " make detect-thread-boundaries - Inventory async/thread boundary points" @echo " make install - Install all dependencies (frontend + backend)"
@echo " make detect-blocking-io - Inventory blocking IO that may block the backend event loop"
@echo " make install - Install all dependencies (frontend + backend + pre-commit hooks)"
@echo " make setup-sandbox - Pre-pull sandbox container image (recommended)" @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 - Start all services in development mode (with hot-reloading)"
@echo " make dev-pro - Start in dev + Gateway mode (experimental, no LangGraph server)"
@echo " make dev-daemon - Start dev services in background (daemon mode)" @echo " make dev-daemon - Start dev services in background (daemon mode)"
@echo " make dev-daemon-pro - Start dev daemon + Gateway mode (experimental)"
@echo " make start - Start all services in production mode (optimized, no hot-reloading)" @echo " make start - Start all services in production mode (optimized, no hot-reloading)"
@echo " make start-pro - Start in prod + Gateway mode (experimental)"
@echo " make start-daemon - Start prod services in background (daemon mode)" @echo " make start-daemon - Start prod services in background (daemon mode)"
@echo " make nginx - Start nginx alone in the foreground (local dev config)" @echo " make start-daemon-pro - Start prod daemon + Gateway mode (experimental)"
@echo " make stop - Stop all running services" @echo " make stop - Stop all running services"
@echo " make clean - Clean up processes and temporary files" @echo " make clean - Clean up processes and temporary files"
@echo "" @echo ""
@echo "Docker Production Commands:" @echo "Docker Production Commands:"
@echo " make up - Build and start production Docker services (localhost:2026)" @echo " make up - Build and start production Docker services (localhost:2026)"
@echo " make up-pro - Build and start production Docker in Gateway mode (experimental)"
@echo " make down - Stop and remove production Docker containers" @echo " make down - Stop and remove production Docker containers"
@echo "" @echo ""
@echo "Docker Development Commands:" @echo "Docker Development Commands:"
@echo " make docker-init - Pull the sandbox image" @echo " make docker-init - Pull the sandbox image"
@echo " make docker-start - Start Docker services (mode-aware from config.yaml, localhost:2026)" @echo " make docker-start - Start Docker services (mode-aware from config.yaml, localhost:2026)"
@echo " make docker-start-pro - Start Docker in Gateway mode (experimental, no LangGraph container)"
@echo " make docker-stop - Stop Docker development services" @echo " make docker-stop - Stop Docker development services"
@echo " make docker-logs - View Docker development logs" @echo " make docker-logs - View Docker development logs"
@echo " make docker-logs-frontend - View Docker frontend logs" @echo " make docker-logs-frontend - View Docker frontend logs"
@echo " make docker-logs-gateway - View Docker gateway logs" @echo " make docker-logs-gateway - View Docker gateway logs"
@echo " make docker-logs-redis - View Docker Redis logs"
## Setup & Diagnosis ## Setup & Diagnosis
setup: setup:
@ -56,15 +57,6 @@ setup:
doctor: doctor:
@$(BACKEND_UV_RUN) python ../scripts/doctor.py @$(BACKEND_UV_RUN) python ../scripts/doctor.py
support-bundle:
@$(BACKEND_UV_RUN) python ../scripts/support_bundle.py --include-doctor
detect-thread-boundaries:
@$(PYTHON) ./scripts/detect_thread_boundaries.py
detect-blocking-io:
@$(MAKE) -C backend detect-blocking-io
config: config:
@$(PYTHON) ./scripts/configure.py @$(PYTHON) ./scripts/configure.py
@ -81,9 +73,6 @@ install:
@cd backend && uv sync @cd backend && uv sync
@echo "Installing frontend dependencies..." @echo "Installing frontend dependencies..."
@cd frontend && pnpm install @cd frontend && pnpm install
@echo "Installing pre-commit hooks..."
@uv tool install pre-commit
@pre-commit install --overwrite
@echo "✓ All dependencies installed" @echo "✓ All dependencies installed"
@echo "" @echo ""
@echo "==========================================" @echo "=========================================="
@ -96,31 +85,76 @@ install:
# Pre-pull sandbox Docker image (optional but recommended) # Pre-pull sandbox Docker image (optional but recommended)
setup-sandbox: setup-sandbox:
@$(RUN_WITH_GIT_BASH) ./scripts/setup-sandbox.sh @echo "=========================================="
@echo " Pre-pulling Sandbox Container Image"
@echo "=========================================="
@echo ""
@IMAGE=$$(grep -A 20 "# sandbox:" config.yaml 2>/dev/null | grep "image:" | awk '{print $$2}' | head -1); \
if [ -z "$$IMAGE" ]; then \
IMAGE="enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest"; \
echo "Using default image: $$IMAGE"; \
else \
echo "Using configured image: $$IMAGE"; \
fi; \
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"; \
fi; \
if command -v docker >/dev/null 2>&1; then \
echo "Pulling image using Docker..."; \
if docker pull "$$IMAGE"; then \
echo ""; \
echo "✓ Sandbox image pulled successfully"; \
else \
echo ""; \
echo "⚠ Failed to pull sandbox image (this is OK for local sandbox mode)"; \
fi; \
else \
echo "✗ Neither Docker nor Apple Container is available"; \
echo " Please install Docker: https://docs.docker.com/get-docker/"; \
exit 1; \
fi
# Start all services in development mode (with hot-reloading) # Start all services in development mode (with hot-reloading)
dev: dev:
@$(PYTHON) ./scripts/check.py @$(PYTHON) ./scripts/check.py
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev @$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev
# Start all services in dev + Gateway mode (experimental: agent runtime embedded in Gateway)
dev-pro:
@$(PYTHON) ./scripts/check.py
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev --gateway
# Start all services in production mode (with optimizations) # Start all services in production mode (with optimizations)
start: start:
@$(PYTHON) ./scripts/check.py @$(PYTHON) ./scripts/check.py
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod @$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod
# Start all services in prod + Gateway mode (experimental)
start-pro:
@$(PYTHON) ./scripts/check.py
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod --gateway
# Start all services in daemon mode (background) # Start all services in daemon mode (background)
dev-daemon: dev-daemon:
@$(PYTHON) ./scripts/check.py @$(PYTHON) ./scripts/check.py
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev --daemon @$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev --daemon
# Start daemon + Gateway mode (experimental)
dev-daemon-pro:
@$(PYTHON) ./scripts/check.py
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --dev --gateway --daemon
# Start prod services in daemon mode (background) # Start prod services in daemon mode (background)
start-daemon: start-daemon:
@$(PYTHON) ./scripts/check.py @$(PYTHON) ./scripts/check.py
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod --daemon @$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod --daemon
# Start nginx alone in the foreground with the local dev config # Start prod daemon + Gateway mode (experimental)
nginx: start-daemon-pro:
@$(RUN_WITH_GIT_BASH) ./scripts/nginx.sh @$(PYTHON) ./scripts/check.py
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod --gateway --daemon
# Stop all services # Stop all services
stop: stop:
@ -130,6 +164,7 @@ stop:
clean: stop clean: stop
@echo "Cleaning up..." @echo "Cleaning up..."
@-rm -rf backend/.deer-flow 2>/dev/null || true @-rm -rf backend/.deer-flow 2>/dev/null || true
@-rm -rf backend/.langgraph_api 2>/dev/null || true
@-rm -rf logs/*.log 2>/dev/null || true @-rm -rf logs/*.log 2>/dev/null || true
@echo "✓ Cleanup complete" @echo "✓ Cleanup complete"
@ -145,6 +180,10 @@ docker-init:
docker-start: docker-start:
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh start @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh start
# Start Docker in Gateway mode (experimental)
docker-start-pro:
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh start --gateway
# Stop Docker development environment # Stop Docker development environment
docker-stop: docker-stop:
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh stop @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh stop
@ -158,8 +197,6 @@ docker-logs-frontend:
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs --frontend @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs --frontend
docker-logs-gateway: docker-logs-gateway:
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs --gateway @$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs --gateway
docker-logs-redis:
@$(RUN_WITH_GIT_BASH) ./scripts/docker.sh logs --redis
# ========================================== # ==========================================
# Production Docker Commands # Production Docker Commands
@ -169,6 +206,10 @@ docker-logs-redis:
up: up:
@$(RUN_WITH_GIT_BASH) ./scripts/deploy.sh @$(RUN_WITH_GIT_BASH) ./scripts/deploy.sh
# Build and start production services in Gateway mode
up-pro:
@$(RUN_WITH_GIT_BASH) ./scripts/deploy.sh --gateway
# Stop and remove production containers # Stop and remove production containers
down: down:
@$(RUN_WITH_GIT_BASH) ./scripts/deploy.sh down @$(RUN_WITH_GIT_BASH) ./scripts/deploy.sh down

446
README.md
View File

@ -18,16 +18,14 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
## Official Website ## Official Website
[<img width="2880" height="1600" alt="image" src="https://github.com/user-attachments/assets/a598c49f-3b2f-41ea-a052-05e21349188a" />](https://deerflow.tech)
Learn more and see **real demos** on our [**official website**](https://deerflow.tech). Learn more and see **real demos** on our [**official website**](https://deerflow.tech).
## Sister Projects
<img width="446" height="280" alt="image" align="middle" src="https://github.com/user-attachments/assets/077edef4-d560-41af-bb0d-d0a5f14fcc20" />
- [**LLM Space**](https://github.com/deer-flow/llm-space) - Meet our secret weapon behind DeerFlow — one desktop tool to prototype agent ideas, inspect each harness step, replay failures, and benchmark performance.
## Coding Plan from ByteDance Volcengine ## Coding Plan from ByteDance Volcengine
<img width="4808" height="2400" alt="英文方舟" src="https://github.com/user-attachments/assets/2ecc7b9d-50be-4185-b1f7-5542d222fb2d" />
- We strongly recommend using Doubao-Seed-2.0-Code, DeepSeek v3.2 and Kimi 2.5 to run DeerFlow - We strongly recommend using Doubao-Seed-2.0-Code, DeepSeek v3.2 and Kimi 2.5 to run DeerFlow
- [Learn more](https://www.byteplus.com/en/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow) - [Learn more](https://www.byteplus.com/en/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
- [中国大陆地区的开发者请点击这里](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow) - [中国大陆地区的开发者请点击这里](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
@ -64,22 +62,17 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe
- [IM Channels](#im-channels) - [IM Channels](#im-channels)
- [LangSmith Tracing](#langsmith-tracing) - [LangSmith Tracing](#langsmith-tracing)
- [Langfuse Tracing](#langfuse-tracing) - [Langfuse Tracing](#langfuse-tracing)
- [Monocle Tracing](#monocle-tracing) - [Using Both Providers](#using-both-providers)
- [Using Multiple Providers](#using-multiple-providers)
- [From Deep Research to Super Agent Harness](#from-deep-research-to-super-agent-harness) - [From Deep Research to Super Agent Harness](#from-deep-research-to-super-agent-harness)
- [Core Features](#core-features) - [Core Features](#core-features)
- [Skills \& Tools](#skills--tools) - [Skills \& Tools](#skills--tools)
- [Claude Code Integration](#claude-code-integration) - [Claude Code Integration](#claude-code-integration)
- [Session Goals](#session-goals)
- [Manual Context Compaction](#manual-context-compaction)
- [Sub-Agents](#sub-agents) - [Sub-Agents](#sub-agents)
- [Sandbox \& File System](#sandbox--file-system) - [Sandbox \& File System](#sandbox--file-system)
- [Context Engineering](#context-engineering) - [Context Engineering](#context-engineering)
- [Long-Term Memory](#long-term-memory) - [Long-Term Memory](#long-term-memory)
- [Recommended Models](#recommended-models) - [Recommended Models](#recommended-models)
- [Embedded Python Client](#embedded-python-client) - [Embedded Python Client](#embedded-python-client)
- [Scheduled Tasks](#scheduled-tasks)
- [Terminal Workbench (TUI)](#terminal-workbench-tui)
- [Documentation](#documentation) - [Documentation](#documentation)
- [⚠️ Security Notice](#-security-notice) - [⚠️ Security Notice](#-security-notice)
- [Improper Deployment May Introduce Security Risks](#improper-deployment-may-introduce-security-risks) - [Improper Deployment May Introduce Security Risks](#improper-deployment-may-introduce-security-risks)
@ -124,19 +117,8 @@ That prompt is intended for coding agents. It tells the agent to clone the repo
The wizard also lets you configure an optional web search provider, or skip it for now. The wizard also lets you configure an optional web search provider, or skip it for now.
Run `make doctor` at any time to verify your setup and get actionable fix hints. Run `make doctor` at any time to verify your setup and get actionable fix hints.
If you are opening a GitHub issue about a local setup or runtime problem, run
`make support-bundle`. The command prints reporter next steps, writes a
`*-issue-summary.md` file to paste into the issue, a `*-issue-draft.md` file
for AI-assisted issue filing, and an optional evidence zip under
`.deer-flow/support-bundles/`. If an AI assistant files the issue, start from
the draft and replace every REQUIRED placeholder instead of inventing missing
facts. Attach the zip only if a maintainer asks for it, or if the summary
alone is not enough. Maintainers and AI triage tools can start with
`triage.json`; the bundle includes redacted diagnostics and file manifests
only, and does not include `.env`, raw conversation messages, or user file
contents.
> **Advanced / manual configuration**: If you prefer to edit `config.yaml` directly, run `make config` instead to copy the full template. See `config.example.yaml` for the complete reference including CLI-backed providers (Codex CLI, Claude Code OAuth), OpenRouter, Responses API, subagent runtime caps such as `subagents.max_total_per_run`, and more. > **Advanced / manual configuration**: If you prefer to edit `config.yaml` directly, run `make config` instead to copy the full template. See `config.example.yaml` for the complete reference including CLI-backed providers (Codex CLI, Claude Code OAuth), OpenRouter, Responses API, and more.
<details> <details>
<summary>Manual model configuration examples</summary> <summary>Manual model configuration examples</summary>
@ -249,16 +231,7 @@ make docker-start # Start services (auto-detects sandbox mode from config.yaml
Docker builds use the upstream `uv` registry by default. If you need faster mirrors in restricted networks, export `UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple` and `NPM_REGISTRY=https://registry.npmmirror.com` before running `make docker-init` or `make docker-start`. Docker builds use the upstream `uv` registry by default. If you need faster mirrors in restricted networks, export `UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple` and `NPM_REGISTRY=https://registry.npmmirror.com` before running `make docker-init` or `make docker-start`.
Local AIO sandbox control traffic is always direct: loopback/private addresses,
single-label cluster hosts, and Docker/Podman internal hostnames do not inherit
`HTTP_PROXY` or `HTTPS_PROXY`. External sandbox FQDNs and public IPs still
honor environment proxy settings.
Backend processes automatically pick up `config.yaml` changes on the next config access, so model metadata updates do not require a manual restart during development. Backend processes automatically pick up `config.yaml` changes on the next config access, so model metadata updates do not require a manual restart during development.
The checkpoint storage settings `database.checkpoint_channel_mode` and
`database.checkpoint_delta_snapshot_frequency` are exceptions: both are frozen
when the process first builds an agent (including through `DeerFlowClient`) and
require a process restart to change safely.
> [!TIP] > [!TIP]
> On Linux, if Docker-based commands fail with `permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock`, add your user to the `docker` group and re-login before retrying. See [CONTRIBUTING.md](CONTRIBUTING.md#linux-docker-daemon-permission-denied) for the full fix. > On Linux, if Docker-based commands fail with `permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock`, add your user to the `docker` group and re-login before retrying. See [CONTRIBUTING.md](CONTRIBUTING.md#linux-docker-daemon-permission-denied) for the full fix.
@ -270,33 +243,18 @@ make up # Build images and start all production services
make down # Stop and remove containers make down # Stop and remove containers
``` ```
> [!NOTE]
> The LangGraph agent server currently runs via `langgraph dev` (the open-source CLI server).
Access: http://localhost:2026 Access: http://localhost:2026
For persistent deployments, configure `database.backend` as `sqlite` or
`postgres`. The selected backend is shared by the LangGraph checkpointer,
LangGraph Store, and DeerFlow application data. The deprecated `checkpointer`
section, when present, overrides the first two for backward compatibility.
The unified nginx endpoint is same-origin by default and does not emit browser CORS headers. If you run a split-origin or port-forwarded browser client, set `GATEWAY_CORS_ORIGINS` to comma-separated exact origins such as `http://localhost:3000`; the Gateway then applies the CORS allowlist and matching CSRF origin checks.
Browser login uses `HttpOnly` session cookies. The login page offers a "keep me signed in" option that extends the browser session when the request is HTTPS (including trusted `X-Forwarded-Proto: https`) or localhost HTTP. The localhost exception uses the direct request `Host` and ignores forwarded host headers. Public HTTP deployments, including many temporary sandbox URLs, fall back to session cookies by default. DeerFlow never stores the password in browser storage; the UI may remember only the email address.
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
> [!IMPORTANT]
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE and `/wait` consumers also refresh durable status on heartbeats as a fallback if terminal publication fails. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
>
> With lease heartbeat enabled, a transient RunStore renewal error is retried only until the last confirmed lease expires; the stale worker then cancels local execution and suppresses checkpoint, completion-hook, delivery-receipt, and thread-status finalization. A remote tool side effect already in flight may still be outside local cancellation.
>
> Reconciliation uses an atomic takeover claim that re-checks the lease after candidate selection, so a successful owner renewal wins over orphan recovery and only one reconciler can report a run as recovered. When multiple Gateway workers share the Docker/AIO or E2B sandbox backend, also configure `sandbox.ownership.type: redis`; E2B uses the leases during background startup and periodic reconciliation so duplicate/orphan cleanup cannot terminate a live peer's sandbox.
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide. See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed Docker development guide.
#### Option 2: Local Development #### Option 2: Local Development
If you prefer running services locally: If you prefer running services locally:
Prerequisite: complete the "Configuration" steps above first (`make setup`). `make dev` requires a valid `config.yaml` in the project root. Set `DEER_FLOW_PROJECT_ROOT` to define that root explicitly, or `DEER_FLOW_CONFIG_PATH` to point at a specific config file. Runtime state defaults to `.deer-flow` under the project root and can be moved with `DEER_FLOW_HOME`; skills default to `skills/` under the project root and can be moved with `DEER_FLOW_SKILLS_PATH`. Run `make doctor` to verify your setup before starting. Prerequisite: complete the "Configuration" steps above first (`make setup`). `make dev` requires a valid `config.yaml` in the project root (can be overridden via `DEER_FLOW_CONFIG_PATH`). Run `make doctor` to verify your setup before starting.
On Windows, run the local development flow from Git Bash. Native `cmd.exe` and PowerShell shells are not supported for the bash-based service scripts, and WSL is not guaranteed because some scripts rely on Git for Windows utilities such as `cygpath`. On Windows, run the local development flow from Git Bash. Native `cmd.exe` and PowerShell shells are not supported for the bash-based service scripts, and WSL is not guaranteed because some scripts rely on Git for Windows utilities such as `cygpath`.
1. **Check prerequisites**: 1. **Check prerequisites**:
@ -306,7 +264,7 @@ On Windows, run the local development flow from Git Bash. Native `cmd.exe` and P
2. **Install dependencies**: 2. **Install dependencies**:
```bash ```bash
make install # Install backend + frontend dependencies + pre-commit hooks make install # Install backend + frontend dependencies
``` ```
3. **(Optional) Pre-pull sandbox image**: 3. **(Optional) Pre-pull sandbox image**:
@ -331,35 +289,53 @@ On Windows, run the local development flow from Git Bash. Native `cmd.exe` and P
#### Startup Modes #### Startup Modes
DeerFlow runs the agent runtime inside the Gateway API. Development mode enables hot-reload; production mode uses a pre-built frontend. DeerFlow supports multiple startup modes across two dimensions:
- **Dev / Prod** — dev enables hot-reload; prod uses pre-built frontend
- **Standard / Gateway** — standard uses a separate LangGraph server (4 processes); Gateway mode (experimental) embeds the agent runtime in the Gateway API (3 processes)
| | **Local Foreground** | **Local Daemon** | **Docker Dev** | **Docker Prod** | | | **Local Foreground** | **Local Daemon** | **Docker Dev** | **Docker Prod** |
|---|---|---|---|---| |---|---|---|---|---|
| **Dev** | `./scripts/serve.sh --dev`<br/>`make dev` | `./scripts/serve.sh --dev --daemon`<br/>`make dev-daemon` | `./scripts/docker.sh start`<br/>`make docker-start` | — | | **Dev** | `./scripts/serve.sh --dev`<br/>`make dev` | `./scripts/serve.sh --dev --daemon`<br/>`make dev-daemon` | `./scripts/docker.sh start`<br/>`make docker-start` | — |
| **Dev + Gateway** | `./scripts/serve.sh --dev --gateway`<br/>`make dev-pro` | `./scripts/serve.sh --dev --gateway --daemon`<br/>`make dev-daemon-pro` | `./scripts/docker.sh start --gateway`<br/>`make docker-start-pro` | — |
| **Prod** | `./scripts/serve.sh --prod`<br/>`make start` | `./scripts/serve.sh --prod --daemon`<br/>`make start-daemon` | — | `./scripts/deploy.sh`<br/>`make up` | | **Prod** | `./scripts/serve.sh --prod`<br/>`make start` | `./scripts/serve.sh --prod --daemon`<br/>`make start-daemon` | — | `./scripts/deploy.sh`<br/>`make up` |
| **Prod + Gateway** | `./scripts/serve.sh --prod --gateway`<br/>`make start-pro` | `./scripts/serve.sh --prod --gateway --daemon`<br/>`make start-daemon-pro` | — | `./scripts/deploy.sh --gateway`<br/>`make up-pro` |
| Action | Local | Docker Dev | Docker Prod | | Action | Local | Docker Dev | Docker Prod |
|---|---|---|---| |---|---|---|---|
| **Stop** | `./scripts/serve.sh --stop`<br/>`make stop` | `./scripts/docker.sh stop`<br/>`make docker-stop` | `./scripts/deploy.sh down`<br/>`make down` | | **Stop** | `./scripts/serve.sh --stop`<br/>`make stop` | `./scripts/docker.sh stop`<br/>`make docker-stop` | `./scripts/deploy.sh down`<br/>`make down` |
| **Restart** | `./scripts/serve.sh --restart [flags]` | `./scripts/docker.sh restart` | — | | **Restart** | `./scripts/serve.sh --restart [flags]` | `./scripts/docker.sh restart` | — |
Gateway owns `/api/langgraph/*` and translates those public LangGraph-compatible paths to its native `/api/*` routers behind nginx. > **Gateway mode** eliminates the LangGraph server process — the Gateway API handles agent execution directly via async tasks, managing its own concurrency.
Gateway runs automatically enforce native delivery for artifacts created or modified under `/mnt/user-data/outputs`: `present_files` must present at least one output produced by the current run, and the terminal `run.delivery` receipt must be durably recorded. Runs that do not produce output artifacts keep ordinary conversational behavior. #### Why Gateway Mode?
DeerFlow's built-in custom events are available through both LangGraph streaming interfaces: native clients can continue subscribing to `stream_mode="custom"`, while callback-based integrations can consume the same payloads as `on_custom_event` records from `astream_events(version="v2")`. The callback event name matches the payload's `type` field. In standard mode, DeerFlow runs a dedicated [LangGraph Platform](https://langchain-ai.github.io/langgraph/) server alongside the Gateway API. This architecture works well but has trade-offs:
| | Standard Mode | Gateway Mode |
|---|---|---|
| **Architecture** | Gateway (REST API) + LangGraph (agent runtime) | Gateway embeds agent runtime |
| **Concurrency** | `--n-jobs-per-worker` per worker (requires license) | `--workers` × async tasks (no per-worker cap) |
| **Containers / Processes** | 4 (frontend, gateway, langgraph, nginx) | 3 (frontend, gateway, nginx) |
| **Resource usage** | Higher (two Python runtimes) | Lower (single Python runtime) |
| **LangGraph Platform license** | Required for production images | Not required |
| **Cold start** | Slower (two services to initialize) | Faster |
Both modes are functionally equivalent — the same agents, tools, and skills work in either mode.
#### Docker Production Deployment #### Docker Production Deployment
`deploy.sh` supports building and starting separately: `deploy.sh` supports building and starting separately. Images are mode-agnostic — runtime mode is selected at start time:
```bash ```bash
# One-step (build + start) # One-step (build + start)
deploy.sh deploy.sh # standard mode (default)
deploy.sh --gateway # gateway mode
# Two-step (build once, start later) # Two-step (build once, start with any mode)
deploy.sh build # build all images deploy.sh build # build all images
deploy.sh start # start pre-built images deploy.sh start # start in standard mode
deploy.sh start --gateway # start in gateway mode
# Stop # Stop
deploy.sh down deploy.sh down
@ -381,16 +357,12 @@ See the [Sandbox Configuration Guide](backend/docs/CONFIGURATION.md#sandbox) to
DeerFlow supports configurable MCP servers and skills to extend its capabilities. DeerFlow supports configurable MCP servers and skills to extend its capabilities.
For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`). For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`).
For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_timeout`.
MCP routing hints can also prefer a specific MCP tool for matching requests without forbidding other tools. When `tool_search` defers MCP schemas, matching routing metadata can auto-promote up to `tool_search.auto_promote_top_k` deferred schemas before the model call.
See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions. See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions.
#### IM Channels #### IM Channels
DeerFlow supports receiving tasks from messaging apps. Channels auto-start when configured — no public IP required for any of them. DeerFlow supports receiving tasks from messaging apps. Channels auto-start when configured — no public IP required for any of them.
DeerFlow can also expose user-owned IM channel connections in the workspace UI. When `channel_connections` is enabled, logged-in users can bind Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, or WeCom from the sidebar / Settings > Channels. It reuses the existing outbound `channels.*` transports, so no public IP or provider callback URL is required. Incoming IM messages then run under the connected DeerFlow user account. See [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md) for setup and security notes.
| Channel | Transport | Difficulty | | Channel | Transport | Difficulty |
|---------|-----------|------------| |---------|-----------|------------|
| Telegram | Bot API (long-polling) | Easy | | Telegram | Bot API (long-polling) | Easy |
@ -398,14 +370,13 @@ DeerFlow can also expose user-owned IM channel connections in the workspace UI.
| Feishu / Lark | WebSocket | Moderate | | Feishu / Lark | WebSocket | Moderate |
| WeChat | Tencent iLink (long-polling) | Moderate | | WeChat | Tencent iLink (long-polling) | Moderate |
| WeCom | WebSocket | Moderate | | WeCom | WebSocket | Moderate |
| DingTalk | Stream Push (WebSocket) | Moderate |
**Configuration in `config.yaml`:** **Configuration in `config.yaml`:**
```yaml ```yaml
channels: channels:
# LangGraph-compatible Gateway API base URL (default: http://localhost:8001/api) # LangGraph Server URL (default: http://localhost:2024)
langgraph_url: http://localhost:8001/api langgraph_url: http://localhost:2024
# Gateway API URL (default: http://localhost:8001) # Gateway API URL (default: http://localhost:8001)
gateway_url: http://localhost:8001 gateway_url: http://localhost:8001
@ -440,8 +411,6 @@ channels:
telegram: telegram:
enabled: true enabled: true
bot_token: $TELEGRAM_BOT_TOKEN bot_token: $TELEGRAM_BOT_TOKEN
# Optional: render final Markdown replies as Telegram Rich Messages.
rich_messages: false
allowed_users: [] # empty = allow all allowed_users: [] # empty = allow all
wechat: wechat:
@ -450,10 +419,7 @@ channels:
ilink_bot_id: $WECHAT_ILINK_BOT_ID ilink_bot_id: $WECHAT_ILINK_BOT_ID
qrcode_login_enabled: true # optional: allow first-time QR bootstrap when bot_token is absent qrcode_login_enabled: true # optional: allow first-time QR bootstrap when bot_token is absent
allowed_users: [] # empty = allow all allowed_users: [] # empty = allow all
polling_timeout: 35 # timing values must be positive finite seconds polling_timeout: 35
polling_retry_delay: 5
qrcode_poll_interval: 2
qrcode_poll_timeout: 180
state_dir: ./.deer-flow/wechat/state state_dir: ./.deer-flow/wechat/state
max_inbound_image_bytes: 20971520 max_inbound_image_bytes: 20971520
max_outbound_image_bytes: 20971520 max_outbound_image_bytes: 20971520
@ -473,20 +439,11 @@ channels:
context: context:
thinking_enabled: true thinking_enabled: true
subagent_enabled: true subagent_enabled: true
dingtalk:
enabled: true
client_id: $DINGTALK_CLIENT_ID # Client ID of your DingTalk application
client_secret: $DINGTALK_CLIENT_SECRET # Client Secret of your DingTalk application
allowed_users: [] # empty = allow all
card_template_id: "" # Optional: AI Card template ID for streaming typewriter effect
``` ```
Notes: Notes:
- `assistant_id: lead_agent` calls the default LangGraph assistant directly. - `assistant_id: lead_agent` calls the default LangGraph assistant directly.
- If `assistant_id` is set to a custom agent name, DeerFlow still routes through `lead_agent` and injects that value as `agent_name`, so the custom agent's SOUL/config takes effect for IM channels. - If `assistant_id` is set to a custom agent name, DeerFlow still routes through `lead_agent` and injects that value as `agent_name`, so the custom agent's SOUL/config takes effect for IM channels.
- IM channel workers call Gateway's LangGraph-compatible API internally and automatically attach process-local internal auth plus the CSRF cookie/header pair required for thread and run creation.
- Feishu/Lark now queues rapid follow-up messages per mapped DeerFlow `thread_id` instead of immediately surfacing the generic busy reply, and topic replies keep a per-message card with a compact source-message preview across queued/running/final patches.
Set the corresponding API keys in your `.env` file: Set the corresponding API keys in your `.env` file:
@ -509,17 +466,12 @@ WECHAT_ILINK_BOT_ID=your_ilink_bot_id
# WeCom # WeCom
WECOM_BOT_ID=your_bot_id WECOM_BOT_ID=your_bot_id
WECOM_BOT_SECRET=your_bot_secret WECOM_BOT_SECRET=your_bot_secret
# DingTalk
DINGTALK_CLIENT_ID=your_client_id
DINGTALK_CLIENT_SECRET=your_client_secret
``` ```
**Telegram Setup** **Telegram Setup**
1. Chat with [@BotFather](https://t.me/BotFather), send `/newbot`, and copy the HTTP API token. 1. Chat with [@BotFather](https://t.me/BotFather), send `/newbot`, and copy the HTTP API token.
2. Set `TELEGRAM_BOT_TOKEN` in `.env` and enable the channel in `config.yaml`. 2. Set `TELEGRAM_BOT_TOKEN` in `.env` and enable the channel in `config.yaml`.
3. The bot accepts inbound text, photos, and documents (with or without captions). Hosted Bot API downloads are limited to 20 MB per attachment.
**Slack Setup** **Slack Setup**
@ -552,15 +504,7 @@ DINGTALK_CLIENT_SECRET=your_client_secret
4. Make sure backend dependencies include `wecom-aibot-python-sdk`. The channel uses a WebSocket long connection and does not require a public callback URL. 4. Make sure backend dependencies include `wecom-aibot-python-sdk`. The channel uses a WebSocket long connection and does not require a public callback URL.
5. The current integration supports inbound text, image, and file messages. Final images/files generated by the agent are also sent back to the WeCom conversation. 5. The current integration supports inbound text, image, and file messages. Final images/files generated by the agent are also sent back to the WeCom conversation.
**DingTalk Setup** When DeerFlow runs in Docker Compose, IM channels execute inside the `gateway` container. In that case, do not point `channels.langgraph_url` or `channels.gateway_url` at `localhost`; use container service names such as `http://langgraph:2024` and `http://gateway:8001`, or set `DEER_FLOW_CHANNELS_LANGGRAPH_URL` and `DEER_FLOW_CHANNELS_GATEWAY_URL`.
1. Create a DingTalk application in the [DingTalk Developer Console](https://open.dingtalk.com/) and enable **Robot** capability.
2. Set the message receiving mode to **Stream Mode** in the robot configuration page.
3. Copy the `Client ID` and `Client Secret`, set `DINGTALK_CLIENT_ID` and `DINGTALK_CLIENT_SECRET` in `.env`, and enable the channel in `config.yaml`.
4. *(Optional)* To enable streaming AI Card replies (typewriter effect), create an **AI Card** template on the [DingTalk Card Platform](https://open.dingtalk.com/document/dingstart/typewriter-effect-streaming-ai-card), then set `card_template_id` in `config.yaml` to the template ID. You also need to apply for the `Card.Streaming.Write` and `Card.Instance.Write` permissions.
When DeerFlow runs in Docker Compose, IM channels execute inside the `gateway` container. In that case, do not point `channels.langgraph_url` or `channels.gateway_url` at `localhost`; use container service names such as `http://gateway:8001/api` and `http://gateway:8001`, or set `DEER_FLOW_CHANNELS_LANGGRAPH_URL` and `DEER_FLOW_CHANNELS_GATEWAY_URL`.
**Commands** **Commands**
@ -576,30 +520,6 @@ Once a channel is connected, you can interact with DeerFlow directly from the ch
> Messages without a command prefix are treated as regular chat — DeerFlow creates a thread and responds conversationally. > Messages without a command prefix are treated as regular chat — DeerFlow creates a thread and responds conversationally.
#### Request Trace Correlation
Gateway request trace correlation is disabled by default so existing HTTP responses and log formats stay unchanged. To enable it, set:
```yaml
logging:
enhance:
enabled: true
format: text
```
When enabled, every Gateway HTTP response includes `X-Trace-Id`, logs include `trace_id`, and Langfuse traces created by that request include `metadata.deerflow_trace_id` with the same value.
Gateway run history also records one terminal `run.delivery` receipt per run,
including zero-output and crash-recovered runs. The receipt is persisted before
the durable terminal run status during normal execution. Orphan recovery first
atomically claims an expired lease and then idempotently backfills the receipt,
so a stale recovery scan cannot overwrite a live run's detailed delivery facts.
Receipt persistence remains best-effort during an event-store outage. Runs that
fail checkpoint preflight (or are cancelled while waiting for prior
finalization) keep the existing completion-data behavior: they receive the
zero-delivery receipt but do not overwrite RunStore completion fields with an
empty snapshot.
#### LangSmith Tracing #### LangSmith Tracing
DeerFlow has built-in [LangSmith](https://smith.langchain.com) integration for observability. When enabled, all LLM calls, agent runs, and tool executions are traced and visible in the LangSmith dashboard. DeerFlow has built-in [LangSmith](https://smith.langchain.com) integration for observability. When enabled, all LLM calls, agent runs, and tool executions are traced and visible in the LangSmith dashboard.
@ -628,35 +548,11 @@ LANGFUSE_BASE_URL=https://cloud.langfuse.com
If you are using a self-hosted Langfuse instance, set `LANGFUSE_BASE_URL` to your deployment URL. If you are using a self-hosted Langfuse instance, set `LANGFUSE_BASE_URL` to your deployment URL.
**Trace correlation fields.** Every agent run is annotated with Langfuse's reserved trace attributes so the Sessions and Users pages light up automatically: #### Using Both Providers
- `session_id` = LangGraph `thread_id` — groups every trace of the same conversation If both LangSmith and Langfuse are enabled, DeerFlow attaches both tracing callbacks and reports the same model activity to both systems.
- `user_id` = effective user from `get_effective_user_id()` (falls back to `default` in no-auth mode)
- `trace_name` = assistant id (defaults to `lead-agent`)
- `tags` = `[env:<DEER_FLOW_ENV>, model:<model_name>]` (omitted when not set)
- `metadata.deerflow_trace_id` = DeerFlow request correlation id, matching `X-Trace-Id` when request trace correlation is enabled
These are injected into `RunnableConfig.metadata` at the graph invocation root for both the gateway path (`runtime/runs/worker.py::run_agent`) and the embedded path (`client.py::DeerFlowClient.stream`), so any LangChain-compatible callback can read them. Set `DEER_FLOW_ENV` (or `ENVIRONMENT`) to tag traces by deployment environment. If a provider is explicitly enabled but missing required credentials, or if its callback fails to initialize, DeerFlow fails fast when tracing is initialized during model creation and the error message names the provider that caused the failure.
#### Monocle Tracing
DeerFlow also supports [Monocle](https://github.com/monocle2ai/monocle), an OpenTelemetry-based tracer for agentic applications. It records each run end-to-end: LLM calls, agent steps, and tool and MCP invocations, with their inputs, outputs, timings, and token counts.
Add the following to your `.env` file:
```bash
MONOCLE_TRACING=true
MONOCLE_EXPORTERS=file # file, console, okahu, s3, blob, gcs (default: file)
OKAHU_API_KEY=okh_xxxxxxxx # required only for the `okahu` exporter
```
Each run writes one trace file to `.monocle/`; open it in the [Monocle VS Code extension](https://marketplace.visualstudio.com/items?itemName=OkahuAI.monocle-apptrace) to inspect the span timeline and token counts. Connect to [Okahu](https://www.okahu.ai), an agent-observability platform, to analyze traces across runs and run trace-based and agentic evaluations (via the `okahu` exporter).
Traces capture span inputs and outputs verbatim — prompts, tool arguments, and model responses — plus token usage and timings. The `file` exporter keeps them on local disk and never rotates or cleans them up, so prune `.monocle/` periodically; the remote exporters (`okahu`, `s3`, `blob`, `gcs`) send that same data off-box, so enable only destinations you trust. Monocle is initialized once at Gateway startup: a configuration error (unknown exporter, missing `OKAHU_API_KEY`) is logged there and tracing stays off until the Gateway restarts.
#### Using Multiple Providers
LangSmith and Langfuse attach as LangChain callbacks, so you can enable both and DeerFlow reports each run to both. If an enabled provider is missing required credentials or fails to initialize, DeerFlow fails fast and names it. Monocle uses a global OpenTelemetry provider rather than a callback; Langfuse shares that provider, so all three can run together. Because both span processors sit on the same shared provider, Monocle's exporters also see Langfuse's spans when both are enabled.
For Docker deployments, tracing is disabled by default. Set `LANGSMITH_TRACING=true` and `LANGSMITH_API_KEY` in your `.env` to enable it. For Docker deployments, tracing is disabled by default. Set `LANGSMITH_TRACING=true` and `LANGSMITH_API_KEY` in your `.env` to enable it.
@ -682,118 +578,12 @@ A standard Agent Skill is a structured capability module — a Markdown file tha
Skills are loaded progressively — only when the task needs them, not all at once. This keeps the context window lean and makes DeerFlow work well even with token-sensitive models. Skills are loaded progressively — only when the task needs them, not all at once. This keeps the context window lean and makes DeerFlow work well even with token-sensitive models.
A skill directory is a package boundary: once DeerFlow finds its `SKILL.md`, nested `SKILL.md` files under that package (for example evaluation fixtures) remain supporting data and are not registered as runtime skills. Namespace directories without their own `SKILL.md` can still group nested skills.
Users can explicitly activate an enabled skill for a single turn by starting the request with `/skill-name`, for example `/data-analysis analyze uploads/foo.csv`. DeerFlow loads that skill's `SKILL.md` as hidden current-turn context while leaving the base prompt limited to skill metadata. Slash activation respects disabled skills, custom-agent skill whitelists, and existing channel commands such as `/new` and `/help`.
An enabled skill's `allowed-tools` policy applies only after that skill is explicitly slash-activated or captured in the thread's active skill context after a `read_file` load. Merely enabling, advertising, or listing a skill in a custom agent's `skills` allowlist does not reduce the lead agent's normal toolset. During a slash-activated run, that explicit skill's policy is authoritative: reading another `SKILL.md` may provide instructions but cannot widen the slash skill's tools. Without slash activation, policies from skills actually loaded into active context retain their union semantics. Once active, the policy filters both model-visible tool schemas and tool execution. Framework discovery tools (`tool_search` and `describe_skill`) remain available so an allowed deferred tool or installed skill can still be discovered, but discovery and promotion never grant permission to execute a business tool omitted from `allowed-tools`. `task` is not framework-exempt; a restrictive skill must list it explicitly to delegate to a subagent. Per-step policy decisions are internal runtime context and are removed from observable or persisted context copies. Registry failures and an active set with no remaining valid skill fail closed to framework-safe tools; individual stale paths are ignored only when another valid active skill remains. This is best-effort behavioral scoping, not a hard security boundary: loading skill instructions through another tool is not captured, and active-skill entries can be evicted from bounded context.
When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills. When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills.
Managed integrations install shared read-only skill packs without mixing them Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
into custom skills. The Lark/Feishu CLI integration is available under
`Settings → Integrations → Lark / Feishu CLI`; an administrator installs or
upgrades the official `lark-*` pack once under
`{DEER_FLOW_HOME}/integrations/skills/lark-cli`, and every user discovers that
same pack with an independent enabled state. Each user's app configuration and
OAuth data remain isolated under
`{DEER_FLOW_HOME}/users/{user_id}/integrations/lark-cli/{config,data}`. These
secret directories are restricted to `0700`, regular credential files to
`0600`, and symlinks are rejected.
After installation, users can click **Connect Lark** to open a browser
authorization link; no terminal authorization is required. The same UI can
request additional permission domains such as Calendar, Docs, or Drive, or a
specific OAuth scope reported by `lark-cli`. A cheap status refresh only
inspects the local credential tree, so the UI reports **Credentials configured
(not live-verified)** until an explicit browser completion performs live token
verification. The action then remains **Reconnect Lark** so users can replace
or extend authorization. If an agent hits missing Lark authorization during a
conversation, the managed `lark-shared` guidance points the user back to the
same settings entry with `?settings=integrations`.
Installing the Lark skill pack resolves the latest official `larksuite/cli`
release from GitHub and downloads that version's skills at install time, so the
Gateway needs outbound internet access for that step (it falls back to a
bottom-line pinned version if the release lookup fails). The settings page shows
the installed version and, when available, the newest published version so an
admin can reinstall to upgrade. Air-gapped deployments can pre-stage the archive
and point `DEER_FLOW_LARK_CLI_SKILLS_ARCHIVE` at the local file. Integrity does
not depend on a pinned archive byte hash (GitHub does not guarantee stable
source-archive bytes); instead the download is restricted to the official GitHub
host, every archive member passes structural safety guards, and a content hash
of the effective installed skill tree (including DeerFlow's injected shared
guidance) is recorded so content changes are auditable across reinstalls.
When `sandbox.use` selects the AIO provider, the same install also downloads the
official Linux amd64 and arm64 CLI release archives, verifies their published
SHA-256 checksums, safely extracts one executable per architecture, and mounts
the resulting runtime read-only at `/mnt/integrations/lark-cli/runtime`. An
architecture-selecting launcher in that mount makes `lark-cli` available in the
sandbox `PATH`. Air-gapped AIO deployments can pre-stage a symlink-free runtime
tree containing `bin/lark-cli` plus both `linux-{amd64,arm64}/lark-cli` files and
set `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` to that directory.
> **Sandbox trust boundary:** the browser never receives the Lark app secret, but
> agent conversations run `lark-cli` inside the sandbox, so the per-user
> credential directories are mounted into it: `config` (holding the long-lived
> `appSecret`) is mounted **read-only** and `data` (refreshable OAuth tokens)
> writable. Both remain *readable* by any process the agent runs there, so code
> reached via prompt injection in a tool result could read them. Treat the
> sandbox as inside the Lark credential trust boundary until the sidecar
> credential-broker follow-up removes these mounts from sandbox execution.
For remote/Kubernetes deployments (the provisioner backend), the sandbox
`lark-cli` runtime can instead be supplied by an optional init container that
copies the binaries into a shared `emptyDir` — no install-time GitHub download and
no hostPath/PVC runtime mount. Publish the image under
[`docker/lark-cli-init`](docker/lark-cli-init/README.md) and set
`LARK_CLI_INIT_IMAGE` on the provisioner; it stays off (legacy behavior) when
unset. The Lark integration status (`GET /api/integrations/lark/status`) reports
`sandbox_runtime_mode` and `sandbox_runtime_ready` so the Settings UI shows
whether `lark-cli` will actually be present in the sandbox at chat time, rather
than a green status hiding a later `command not found`.
If a trusted operator manages the configured skills directory through an external mount such as MinIO, NFS, or CSI, an administrator can call `POST /api/skills/reload` after changing files. This invalidates skill prompt caches for the current Gateway process and waits up to the bounded refresh timeout so subsequent runs rescan the latest files; running tasks are unchanged. A loader-level filesystem failure returns a generic server error and preserves the last successfully loaded process cache rather than publishing an empty catalog. Uvicorn workers and Kubernetes Pods must each be targeted separately. Direct mount writes bypass the validation, SkillScan, and history applied by DeerFlow's install/edit APIs, so only operator-controlled systems should have write access.
Skill installs and agent-managed skill edits run through **SkillScan**, a native deterministic safety scanner before the LLM-based skill scanner. Phase 1 runs offline with no Semgrep/OpenGrep dependency, blocks high-confidence `CRITICAL` findings such as private keys or shell execution, and passes warning findings to the LLM scanner for contextual review. Python instance-client exfiltration checks follow a minimal same-scope evidence chain: a simple name bound to a known client constructor, optional name-to-name aliases, and an actual outbound method or context-manager use supported by that constructor. Constructor roots must be proven imports; bare canonical-looking names are not inferred as modules. Nested scopes do not inherit client handles and inherit only constructor import aliases that are never rebound in the enclosing scope. Comprehensions, walrus-bearing statements, annotations, complex binding targets, unsupported operations, and ambiguous branch flows produce no finding from this signal; skipped constructs conservatively invalidate every name they may bind so stale client state cannot create a finding. A deterministic work budget or recursion limit reached by this best-effort analysis does not discard findings already collected for the file. Set `skill_scan.enabled: false` in `config.yaml` to disable only the deterministic analyzers; safe archive extraction and the LLM scanner still run.
DeerFlow also ships with **skill-reviewer**, a public skill for read-only skill quality review. It uses the built-in `review_skill_package` tool to inspect installed skills, local packages, archives, or pasted `SKILL.md` content without activating the target skill, binding its secrets, executing its scripts, or installing it. The tool returns a compact, tag-neutralized JSON payload to the model context and keeps the full raw review payload in the tool artifact for programmatic consumers. The deterministic review core reuses DeerFlow parsing and SkillScan facts, emits versioned JSON contracts under `contracts/skill_review/`, and can be run from the backend CLI:
```bash
cd backend
uv run python -m deerflow.skills.review.cli ../skills/public/data-analysis --format text --fail-on error --fail-on-incomplete
```
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, rendered web capture, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
Advanced deployments can enable pluggable authorization with `authorization.enabled` in `config.yaml`. A configured `AuthorizationProvider` filters denied tools before they reach the model or deferred-tool catalog, then the same provider is checked again before every business-tool execution through the existing guardrail middleware. Gateway `threads:*` and `runs:*` route permissions are derived from the same provider, while existing owner checks and admin-only management gates remain in force. A generated `tool_search` may bypass the second tool check only when it fronts the current build's already-filtered deferred catalog. The built-in RBAC provider supports per-role `tools` and `routes` allow/deny policies and validates that `default_role` names a configured role; authorization is disabled by default. See `config.example.yaml` and the [authorization RFC](docs/plans/2026-07-10-pluggable-authorization-rfc.md).
Advanced deployments can also extend the agent runtime itself by declaring zero-argument `AgentMiddleware` classes under `extensions.middlewares` in `config.yaml` or `extensions_config.json`. DeerFlow loads the same configured class list into the lead-agent and subagent pipelines after their built-in runtime middlewares and loop/token guards, but before the terminal-response/safety/clarification tail, so enterprise forks can add domain guardrails, tool-call governance, or observability hooks without patching the built-in middleware builders. Missing packages, invalid classes, and broken modules fail loudly at agent creation. Treat `config.yaml` and `extensions_config.json` as trusted operator-controlled files: middleware paths are code execution, just like custom tool, model, sandbox, guardrail, MCP server, and MCP interceptor declarations. Gateway skill/MCP toggle endpoints preserve this field but do not expose an API write path for `extensions.middlewares`. Per-context parameterization and separate lead-only/subagent-only middleware lists are not supported yet.
Gateway-generated follow-up suggestions now normalize both plain-string model output and block/list-style rich content before parsing the JSON array response, so provider-specific content wrappers do not silently drop suggestions. Gateway-generated follow-up suggestions now normalize both plain-string model output and block/list-style rich content before parsing the JSON array response, so provider-specific content wrappers do not silently drop suggestions.
The Web UI composer can polish draft input before sending. The rewrite runs as a short Gateway LLM request using the `input_polish` model configuration, keeps slash skill prefixes such as `/data-analysis`, and only replaces the local draft after the user clicks the polish button; it does not create a thread run or persist a message.
Unsent Web UI composer drafts survive page reloads and switching between conversations within the same browser tab. Drafts are isolated by user, agent, and conversation, include a selected slash skill when present, and are cleared once a send is accepted. Attachments and quoted conversation context are intentionally not persisted.
The Web UI composer also supports browser-based voice dictation when the browser exposes the Web Speech API. The microphone button transcribes speech into the local draft only; DeerFlow receives only the transcribed text, while audio handling is delegated to the browser or operating system speech-recognition service according to that environment's policy. Users can review or edit the text before sending.
The Web UI displays a localized AI-generated-content disclaimer below the composer in both standard and custom-agent conversations, reminding users to verify important
information.
Interrupted first-turn runs still persist a fallback conversation title, so stopping a streaming response does not leave the thread as "Untitled" after refresh.
Streaming Markdown responses animate only newly arrived words; text that is already visible is not faded out and replayed when the next chunk extends the same block.
In the Web UI, completed assistant turns can be branched into a new main conversation. The new thread starts from that turn's checkpoint and keeps the preceding replay checkpoint, so the branched response can be regenerated immediately. Regenerating the latest response preserves the thread's current title, including a title you renamed manually after the original response. Legacy or imported histories without checkpoint parent links use a bounded chronological fallback; if no earlier replay checkpoint exists, branching still succeeds with the legacy single-checkpoint shape, while regeneration remains unavailable for that inherited response. Existing single-checkpoint branches are left unchanged rather than attempting an unsafe checkpoint copy. Because workspace files are not checkpointed, the branch only receives a best-effort copy of the current workspace when you branch from the latest turn; branching from an older turn keeps just the restored message history so the branch never inherits files that were created in a later part of the conversation.
The Web UI reports completed task time once per run. This is total wall-clock time—including model reasoning, tool calls, and waiting—not a per-step or model-only thinking duration. Reasoning content remains available through its own separate disclosure.
In the Web UI, the latest completed user turn can also be edited and rerun from the message toolbar. DeerFlow restores the conversation checkpoint before that user message, submits the edited text as a new user message, and hides the superseded turn once the replay is in progress or succeeds. This is a conversation-state replay only: files, memory updates, and external tool side effects are not undone.
Web UI chat links percent-encode custom thread identifiers before placing them in route segments, so reserved URL characters such as `#` and `?` do not change which conversation is opened.
``` ```
# Paths inside the sandbox container # Paths inside the sandbox container
/mnt/skills/public /mnt/skills/public
@ -805,9 +595,6 @@ Web UI chat links percent-encode custom thread identifiers before placing them i
/mnt/skills/custom /mnt/skills/custom
└── your-custom-skill/SKILL.md ← yours └── your-custom-skill/SKILL.md ← yours
/mnt/skills/integrations
└── lark-cli/lark-doc/SKILL.md ← managed, read-only
``` ```
#### Claude Code Integration #### Claude Code Integration
@ -839,67 +626,21 @@ DEERFLOW_LANGGRAPH_URL=http://localhost:2026/api/langgraph # LangGraph API
See [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md) for the full API reference. See [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md) for the full API reference.
### Session Goals
Use `/goal <completion condition>` to attach one active completion condition to the current thread. The goal is thread-scoped state, not a skill activation, so it stays active across turns until DeerFlow determines it has been satisfied or you clear it.
Supported commands:
```text
/goal finish the implementation and make all tests pass
/goal # show the active goal
/goal clear # clear it
```
After each Gateway-backed run, DeerFlow evaluates the visible conversation against the active goal with a non-thinking evaluator model. The evaluator must return a typed blocker (`missing_evidence`, `needs_user_input`, `run_failed`, `external_wait`, or `goal_not_met_yet`) plus visible evidence. DeerFlow only injects a hidden continuation when the latest assistant turn is durably checkpointed, the blocker is `goal_not_met_yet`, the thread did not change during evaluation, and the no-progress breaker has not fired. The safety cap defaults to 8 hidden continuations, and repeated identical non-progress evaluations stop after 2 attempts. `/goal clear` and any user-authored new input win over queued continuations. When the goal is satisfied, DeerFlow clears it automatically and publishes the updated thread state.
The Web UI shows the active goal above the composer. The same command is available from the TUI and supported IM channels. In the Web UI and supported IM channels, setting `/goal <completion condition>` also starts a run with the condition as the task; status and clear commands only manage goal state. Setting or clearing a goal is rejected while that thread has a run in flight, including a run owned by another Gateway worker, so the goal checkpoint cannot branch away from an active run's checkpoint lineage.
### Manual Context Compaction
Use `/compact` in the Web UI composer to summarize older context for the current thread. DeerFlow keeps the full chat visible, but future model calls use the compacted summary plus recent messages. The command is ignored when there is not enough history to compact, and it is blocked while the thread has a run in flight, including when that run is owned by another Gateway worker. If a multi-worker reservation loses its lease, DeerFlow cancels the checkpoint writer before the replacing run proceeds and returns a retryable conflict after cleanup. Thread-title edits are serialized through the same state-write boundary and show a conflict without closing the rename dialog when a run is active.
### Sub-Agents ### Sub-Agents
Complex tasks rarely fit in a single pass. DeerFlow decomposes them. Complex tasks rarely fit in a single pass. DeerFlow decomposes them.
The lead agent can spawn sub-agents on the fly — each with its own scoped context, tools, and termination conditions. Sub-agents run in parallel when possible, report back structured results, and the lead agent synthesizes everything into a coherent output. Their configured skills are resolved from the same user-scoped catalog as the lead agent, so user-owned custom skills remain available without exposing another user's version. Their internal AI and tool messages stay scoped to the delegated graph instead of entering the parent chat stream. Reloaded thread history enforces the same boundary: callback-captured sub-agent AI responses remain available in run-event diagnostics but are excluded from the parent transcript, while the parent `task` result remains attached to its subtask card. Long-running sub-agents compact older history when summarization is enabled and re-inject the summary as guarded, hidden durable context before continuing, so recent assistant/tool activity remains grounded in the task. Provider/model request failures are reported as failed sub-agent tasks rather than successful results, so the lead agent and Web UI can react to them correctly. Collapsed sub-agent cards show the effective model and, when the provider returns usage metadata, a cumulative token total that updates after each completed sub-agent LLM call and persists after a reload. When token usage tracking is enabled, completed sub-agent usage is also attributed back to the dispatching step. The lead agent can spawn sub-agents on the fly — each with its own scoped context, tools, and termination conditions. Sub-agents run in parallel when possible, report back structured results, and the lead agent synthesizes everything into a coherent output.
This is how DeerFlow handles tasks that take minutes to hours: a research task might fan out into a dozen sub-agents, each exploring a different angle, then converge into a single report — or a website — or a slide deck with generated visuals. One harness, many hands. This is how DeerFlow handles tasks that take minutes to hours: a research task might fan out into a dozen sub-agents, each exploring a different angle, then converge into a single report — or a website — or a slide deck with generated visuals. One harness, many hands.
### Sandbox & File System ### Sandbox & File System
`E2BSandboxProvider` uses `wait` as its default overflow policy. It waits for
`acquire_timeout`, then fails the agent turn. DeerFlow does not retry the turn
automatically. Clients can use the structured error to schedule a retry.
Use `burst` with `burst_limit` to permit bounded extra VMs. The `wait` and
`reject` policies use only `replicas`. The `reject` policy can remove one warm
VM before it returns an error.
`replicas` limits one Gateway process. It does not limit all Gateway processes.
E2B acquisition uses a bounded executor. Waiting acquisitions do not use the
default asyncio executor.
An E2B VM keeps its slot until E2B confirms destruction. This rule covers
create and reclaim operations. Discovery can find a VM from another Gateway.
Shutdown closes an unowned discovery client without destroying its VM.
Release stops counting a transition when the VM enters the warm pool.
Shutdown races retry remote cleanup after a transient kill failure.
Reset destroys tracked active and warm E2B VMs. The old provider instance
cannot accept new acquisitions.
DeerFlow doesn't just *talk* about doing things. It has its own computer. DeerFlow doesn't just *talk* about doing things. It has its own computer.
Each task gets its own execution environment with a full filesystem view — skills, workspace, uploads, outputs. The agent reads, writes, and edits files. It can view images and, when configured safely, execute shell commands. Each task gets its own execution environment with a full filesystem view — skills, workspace, uploads, outputs. The agent reads, writes, and edits files. It can view images and, when configured safely, execute shell commands.
The built-in `grep` tool searches either one text file or all matching text files below a directory, so an agent can search an uploaded document directly without first broadening the request to the entire uploads directory. With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows.
Image bytes loaded for a vision-model call are transient: DeerFlow removes the hidden base64 message after the model consumes it so later checkpoints do not keep duplicating that payload.
After each run, DeerFlow records a workspace change summary for the run-owned `workspace` and `outputs` directories. The Web UI shows a compact "files changed" badge on the assistant turn; opening it reveals created, modified, and deleted files with text diffs when safe to display. Uploads are excluded because they are user inputs, not agent-generated changes. Large, binary, or sensitive-looking files are shown as metadata only.
With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log.
This is the difference between a chatbot with tool access and an agent with an actual execution environment. This is the difference between a chatbot with tool access and an agent with an actual execution environment.
@ -911,30 +652,12 @@ This is the difference between a chatbot with tool access and an agent with an a
└── outputs/ ← final deliverables └── outputs/ ← final deliverables
``` ```
### Agentic Browser Control
Reading a page is not the same as *using* one. Alongside the read-only `web_fetch` and `web_capture` tools, DeerFlow ships an optional agentic browser tool group that keeps a live, per-conversation browser session so the agent can actually operate a page — navigate, read the interactive elements, click, type, submit forms, and follow multi-step flows on JavaScript-heavy sites.
Each action returns a fresh snapshot of the page's interactive elements, each addressed by a stable `[ref]` number, so the agent acts on what it just observed instead of guessing selectors. Outbound URLs are SSRF-screened by default. It is powered by Playwright and shipped as an optional extra so the core install stays lean:
```bash
cd backend
uv sync --extra browser
uv run playwright install chromium
```
Then uncomment the `group: browser` tool entries in `config.yaml` (`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_get_text`, `browser_back`, `browser_screenshot`, `browser_close`). `make dev` / Docker startup detects an enabled `browser_navigate` tool and preserves the `browser` extra on dependency syncs. The Gateway fails startup if browser control is configured but Playwright is missing, and `/api/features` hides the Browser UI unless the backend can actually serve it. Keep `headless: true` and `allow_private_addresses: false` for anything but local, trusted debugging. Attaching to an existing Chrome with `cdp_url` cannot enforce DeerFlow's subresource/redirect SSRF guard and therefore fails closed unless `allow_unguarded_cdp: true` explicitly acknowledges that risk; use it only with a trusted local browser. Browser sessions are process-local; keep `GATEWAY_WORKERS=1` while this tool group is enabled because ordinary uvicorn worker dispatch does not provide thread affinity.
### Context Engineering ### Context Engineering
**Isolated Sub-Agent Context**: Each sub-agent runs in its own isolated context. This means that the sub-agent will not be able to see the context of the main agent or other sub-agents. This is important to ensure that the sub-agent is able to focus on the task at hand and not be distracted by the context of the main agent or other sub-agents. **Isolated Sub-Agent Context**: Each sub-agent runs in its own isolated context. This means that the sub-agent will not be able to see the context of the main agent or other sub-agents. This is important to ensure that the sub-agent is able to focus on the task at hand and not be distracted by the context of the main agent or other sub-agents.
**Summarization**: Within a session, DeerFlow manages context aggressively — summarizing completed sub-tasks, offloading intermediate results to the filesystem, compressing what's no longer immediately relevant. This lets it stay sharp across long, multi-step tasks without blowing the context window. **Summarization**: Within a session, DeerFlow manages context aggressively — summarizing completed sub-tasks, offloading intermediate results to the filesystem, compressing what's no longer immediately relevant. This lets it stay sharp across long, multi-step tasks without blowing the context window.
**Strict Tool-Call Recovery**: When a provider or middleware interrupts a tool-call loop, DeerFlow now strips provider-level raw tool-call metadata on forced-stop assistant messages and injects placeholder tool results for dangling calls before the next model invocation. This keeps OpenAI-compatible reasoning models that strictly validate `tool_call_id` sequences from failing with malformed history errors.
**Visible Tool-Run Completion**: For interactive turns, DeerFlow retries an empty post-tool final response once, then surfaces a visible error instead of reporting a silent successful run.
### Long-Term Memory ### Long-Term Memory
Most agents forget everything the moment a conversation ends. DeerFlow remembers. Most agents forget everything the moment a conversation ends. DeerFlow remembers.
@ -943,23 +666,6 @@ Across sessions, DeerFlow builds a persistent memory of your profile, preference
Memory updates now skip duplicate fact entries at apply time, so repeated preferences and context do not accumulate endlessly across sessions. Memory updates now skip duplicate fact entries at apply time, so repeated preferences and context do not accumulate endlessly across sessions.
File-backed memory now separates global user context from agent facts. Each user has one `memory.json` containing only the project-independent `user` and `history` summaries; every fact is a canonical Markdown file below `agents/{agent_name}/facts/`. Existing lead-agent middleware, API, Settings, import/export, and embedded-client calls that omit `agent_name` resolve inside DeerMem to the reserved `__default__` bucket. That bucket is outside the valid custom-agent name grammar, so a real custom agent named `lead-agent` has a separate fact repository and deleting a custom agent cannot delete a memory-only directory without `config.yaml`. Public agent identifiers are case-insensitive and canonicalized to lowercase. Runtime/API readers still receive a compatibility `facts` array for the selected/default agent, so the frontend does not read agent facts from `memory.json`; structured Markdown `source` metadata is projected to the historical string field at the MemoryManager boundary. An unscoped Clear All first migrates facts from unread legacy per-agent JSON without adopting its soon-to-be-cleared summaries, then removes shared summaries and facts from every agent bucket while preserving agent configuration files, so a later read cannot resurrect skipped legacy facts; an explicitly agent-scoped clear removes only that agent's facts. On first normal read, old facts embedded in the user JSON are migrated automatically to `__default__`; facts written to the earlier implicit `lead-agent` bucket are also moved when that directory is not a real custom agent. Migration and normal writes notify the configured retrieval adapter only after durable storage locks are released. DeerMem uses a scope-aware SQLite FTS5/BM25 adapter by default, stores only rebuildable derived index data under `.retrieval/`, and rebuilds it in the background during Gateway startup or lazily on the first scoped search. A corrupt derived index is recreated automatically. Set `memory.backend_config.retrieval_adapter` to an empty string to disable it and use the local substring fallback. Chinese tokenization is optional; install the backend `memory-zh` extra (`uv sync --extra memory-zh`) for jieba-assisted sub-phrase search. Journaled writes, a shared user lock, and optimistic user-memory revisions prevent silent lost updates.
Single-fact repository operations are genuinely incremental: an upsert/delete reads, journals, writes, and re-indexes only the addressed fact files, and returns an explicit incomplete delta rather than a cache-dependent fake full document. Summary change sets merge the supplied `user`/`history` child keys over the persisted sections so a partial update cannot erase omitted siblings; full imports normalize both sections to the complete compatibility schema before applying replacement values. Manager/API compatibility methods materialize a fresh full document only when their public response contract requires one. Fact-level point operations use separate expected user-memory and fact revisions and may explicitly rebase when every addressed fact precondition still holds. Snapshot-derived operations such as scoped clear, capped create, consolidation, and trimming never replay stale delete/trim sets: a manifest conflict reloads the complete document and recomputes the operation, with a bounded retry. Fact paths use the first two hexadecimal characters of `SHA-256(fact_id)` so generated `fact_*` IDs distribute across shards. The cache token combines the shared JSON's nanosecond mtime, size, and persisted revision; this prevents coarse-mtime same-size writes from returning stale data without scanning fact files. Direct out-of-band Markdown edits require an explicit reload. Storage-specific conflicts and corruption are translated at the MemoryManager boundary; the Gateway returns conflict as HTTP 409 and a stable, non-sensitive corruption error as HTTP 500. Full-document `save()` remains a compatibility API and computes a diff before writing; malformed or missing `facts` can no longer silently erase an agent's Markdown files. Legacy migration preserves non-empty `user`/`history` before deleting an agent `memory.json`; conflicting summaries keep the legacy file and fail loudly instead of choosing a winner.
Legacy facts in `memory.json` migrate automatically into the reserved `__default__` Markdown bucket on the user's first normal memory read. Operators who prefer to audit or complete the migration before serving traffic can run the optional idempotent CLI from `backend/`:
```bash
PYTHONPATH=. python scripts/migrate_memory_markdown.py --all-users --dry-run
PYTHONPATH=. python scripts/migrate_memory_markdown.py --all-users
# A custom DeerMem root or original non-directory-safe identity can be explicit:
PYTHONPATH=. python scripts/migrate_memory_markdown.py --storage-path /path/to/deerflow-home --user-id 'test@example.com'
```
The v1-to-v2 storage migration is one-way for a running application: pre-PR code does not read Markdown facts. Before upgrading a persistent deployment, stop DeerFlow and take a filesystem snapshot or full backup of the configured memory storage root. The migration also durably retains each destructive JSON source beside the original path as `{manifest_filename}.v1.bak` before writing v2 data; an existing mismatched backup or a backup-write failure stops migration without modifying the v1 source. This local backup preserves pre-migration data but is not a substitute for a full snapshot and does not contain facts created after the upgrade.
`--user-id` may be repeated. `--all-users` discovers the existing directory-safe buckets below the selected storage root; standalone integrations that passed raw IDs containing characters such as `@` should use the original value with `--user-id`. A failed user's migration is reported without hiding the rest of the audit, and the command exits non-zero when any user fails. The automatic first-read path remains enabled, so running this CLI is not required for startup.
## Recommended Models ## Recommended Models
DeerFlow is model-agnostic — it works with any LLM that implements the OpenAI-compatible API. That said, it performs best with models that support: DeerFlow is model-agnostic — it works with any LLM that implements the OpenAI-compatible API. That said, it performs best with models that support:
@ -985,68 +691,16 @@ response = client.chat("Analyze this paper for me", thread_id="my-thread")
for event in client.stream("hello"): for event in client.stream("hello"):
if event.type == "messages-tuple" and event.data.get("type") == "ai": if event.type == "messages-tuple" and event.data.get("type") == "ai":
print(event.data["content"]) print(event.data["content"])
elif event.type == "messages-tuple" and event.data.get("type") == "tool" and "artifact" in event.data:
# Structured tool artifacts (for example, ask_clarification cards)
# are preserved when the ToolMessage provides one.
print(event.data["artifact"])
# Configuration & management — returns Gateway-aligned dicts # Configuration & management — returns Gateway-aligned dicts
models = client.list_models() # {"models": [...]} models = client.list_models() # {"models": [...]}
skills = client.list_skills() # {"skills": [...]} skills = client.list_skills() # {"skills": [...]}
client.update_skill("web-search", enabled=True) client.update_skill("web-search", enabled=True)
client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]} client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]}
client.set_goal("thread-1", "finish the implementation and make all tests pass")
client.get_goal("thread-1") # {"goal": {...}} or {"goal": None}
client.clear_goal("thread-1")
``` ```
The HTTP Gateway accepts `values`, `messages-tuple`, `updates`, `debug`, `tasks`, `checkpoints`, and `custom` stream modes. Unsupported modes such as `messages` and `events`, unsupported non-default run options such as webhooks, delayed execution, or `multitask_strategy="enqueue"`, and undeclared SDK options such as checkpoint durability overrides return `422` before execution instead of being silently ignored or downgraded.
All dict-returning methods are validated against Gateway Pydantic response models in CI (`TestGatewayConformance`), ensuring the embedded client stays in sync with the HTTP API schemas. See `backend/packages/harness/deerflow/client.py` for full API documentation. All dict-returning methods are validated against Gateway Pydantic response models in CI (`TestGatewayConformance`), ensuring the embedded client stays in sync with the HTTP API schemas. See `backend/packages/harness/deerflow/client.py` for full API documentation.
## Scheduled Tasks
DeerFlow now includes a first-class scheduled-task MVP in the workspace.
Current MVP capabilities:
- Manage tasks at `/workspace/scheduled-tasks`
- Choose whether each scheduled task reuses a thread or creates a fresh thread per run
- Support `once` and `cron` schedules
- Run background scheduled executions as non-interactive DeerFlow runs (`ask_clarification` is not exposed there)
- Use `skip` overlap behavior for due cron executions that collide with an active run on the same reused thread
- Pause, resume, trigger, inspect history, and delete tasks
- Execute scheduled work through the normal DeerFlow run lifecycle
Current MVP limits:
- No conversation-created `schedule_task` tool yet
- No text-only notification jobs
- No channel or GitHub dispatch targets
- No `interval` schedule type in this first cut
Enable background polling with `config.yaml -> scheduler.enabled`. Manual trigger uses the same scheduled-task resource and execution path.
## Terminal Workbench (TUI)
`deerflow` is a terminal-native workbench for people who live in the shell. It runs **embedded** over `DeerFlowClient` — no Gateway, frontend, nginx, or Docker required — while honoring the same `config.yaml`, checkpointer, skills, memory, MCP, and sandbox settings as the rest of DeerFlow.
![DeerFlow TUI](docs/tui/tui-preview.svg)
```bash
uv pip install 'deerflow-harness[tui]' # optional 'textual' dependency
deerflow # launch the terminal UI (TTY required)
deerflow --continue # resume the most recent thread
deerflow --resume THREAD # resume a thread by id
deerflow --print "summarize this repo" # headless one-shot answer to stdout
deerflow --json "hello" # headless newline-delimited StreamEvents
```
A keyboard-driven chat surface with a streaming transcript (Markdown-rendered answers), compact tool-activity cards, a `/` slash-command palette, display-only `/clear`, `/goal` goal management, `/model` and `/threads` pickers, input history, and `Esc` / `Ctrl+C` interrupt. `/clear` removes rows from the current terminal display without deleting the thread or its persisted conversation; `/new` and `/clear` ask you to wait during an active run instead of resetting in-flight display state. Sessions opened in the TUI also appear in the Web UI sidebar — it writes the shared thread store under the local default user, so terminal and web stay in sync **without running the Gateway**.
See [backend/docs/TUI.md](backend/docs/TUI.md) for the full guide.
## Documentation ## Documentation
- [Contributing Guide](CONTRIBUTING.md) - Development environment setup and workflow - [Contributing Guide](CONTRIBUTING.md) - Development environment setup and workflow
@ -1077,12 +731,6 @@ DeerFlow has key high-privilege capabilities including **system command executio
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, workflow, and guidelines. We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, workflow, and guidelines.
Regression coverage includes Docker sandbox mode detection and provisioner kubeconfig-path handling tests in `backend/tests/`. Regression coverage includes Docker sandbox mode detection and provisioner kubeconfig-path handling tests in `backend/tests/`.
Backend blocking-IO diagnostics are available from the repository root with
`make detect-blocking-io`: it statically scans backend business code for
blocking IO that may run on the backend event loop, prints a concise summary,
and writes complete JSON findings to `.deer-flow/blocking-io-findings.json`.
The JSON includes compact review records with `priority`, `location`,
`blocking_call`, `event_loop_exposure`, `reason`, and `code`.
Gateway artifact serving now forces active web content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) to download as attachments instead of inline rendering, reducing XSS risk for generated artifacts. Gateway artifact serving now forces active web content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) to download as attachments instead of inline rendering, reducing XSS risk for generated artifacts.
## License ## License

View File

@ -18,10 +18,14 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
## Site officiel ## Site officiel
[<img width="2880" height="1600" alt="image" src="https://github.com/user-attachments/assets/a598c49f-3b2f-41ea-a052-05e21349188a" />](https://deerflow.tech)
Découvrez-en plus et regardez des **démos réelles** sur notre [**site officiel**](https://deerflow.tech). Découvrez-en plus et regardez des **démos réelles** sur notre [**site officiel**](https://deerflow.tech).
## Coding Plan de ByteDance Volcengine ## Coding Plan de ByteDance Volcengine
<img width="4808" height="2400" alt="英文方舟" src="https://github.com/user-attachments/assets/2ecc7b9d-50be-4185-b1f7-5542d222fb2d" />
- Nous recommandons fortement d'utiliser Doubao-Seed-2.0-Code, DeepSeek v3.2 et Kimi 2.5 pour exécuter DeerFlow - Nous recommandons fortement d'utiliser Doubao-Seed-2.0-Code, DeepSeek v3.2 et Kimi 2.5 pour exécuter DeerFlow
- [En savoir plus](https://www.byteplus.com/en/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow) - [En savoir plus](https://www.byteplus.com/en/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
- [Développeurs en Chine continentale, cliquez ici](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow) - [Développeurs en Chine continentale, cliquez ici](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
@ -42,7 +46,6 @@ DeerFlow intègre désormais le toolkit de recherche et de crawling intelligent
- [🦌 DeerFlow - 2.0](#-deerflow---20) - [🦌 DeerFlow - 2.0](#-deerflow---20)
- [Site officiel](#site-officiel) - [Site officiel](#site-officiel)
- [Coding Plan de ByteDance Volcengine](#coding-plan-de-bytedance-volcengine)
- [InfoQuest](#infoquest) - [InfoQuest](#infoquest)
- [Table des matières](#table-des-matières) - [Table des matières](#table-des-matières)
- [Installation en une phrase pour un coding agent](#installation-en-une-phrase-pour-un-coding-agent) - [Installation en une phrase pour un coding agent](#installation-en-une-phrase-pour-un-coding-agent)
@ -56,21 +59,16 @@ DeerFlow intègre désormais le toolkit de recherche et de crawling intelligent
- [Serveur MCP](#serveur-mcp) - [Serveur MCP](#serveur-mcp)
- [Canaux de messagerie](#canaux-de-messagerie) - [Canaux de messagerie](#canaux-de-messagerie)
- [Traçage LangSmith](#traçage-langsmith) - [Traçage LangSmith](#traçage-langsmith)
- [Traçage Langfuse](#traçage-langfuse)
- [Utiliser les deux fournisseurs](#utiliser-les-deux-fournisseurs)
- [Du Deep Research au Super Agent Harness](#du-deep-research-au-super-agent-harness) - [Du Deep Research au Super Agent Harness](#du-deep-research-au-super-agent-harness)
- [Fonctionnalités principales](#fonctionnalités-principales) - [Fonctionnalités principales](#fonctionnalités-principales)
- [Skills et outils](#skills-et-outils) - [Skills et outils](#skills-et-outils)
- [Intégration Claude Code](#intégration-claude-code) - [Intégration Claude Code](#intégration-claude-code)
- [Objectifs de session (Session Goals)](#objectifs-de-session-session-goals)
- [Sub-Agents](#sub-agents) - [Sub-Agents](#sub-agents)
- [Sandbox et système de fichiers](#sandbox-et-système-de-fichiers) - [Sandbox et système de fichiers](#sandbox-et-système-de-fichiers)
- [Context Engineering](#context-engineering) - [Context Engineering](#context-engineering)
- [Mémoire à long terme](#mémoire-à-long-terme) - [Mémoire à long terme](#mémoire-à-long-terme)
- [Modèles recommandés](#modèles-recommandés) - [Modèles recommandés](#modèles-recommandés)
- [Client Python intégré](#client-python-intégré) - [Client Python intégré](#client-python-intégré)
- [Tâches planifiées (Scheduled Tasks)](#tâches-planifiées-scheduled-tasks)
- [Atelier terminal (TUI)](#atelier-terminal-tui)
- [Documentation](#documentation) - [Documentation](#documentation)
- [⚠️ Avertissement de sécurité](#-avertissement-de-sécurité) - [⚠️ Avertissement de sécurité](#-avertissement-de-sécurité)
- [Contribuer](#contribuer) - [Contribuer](#contribuer)
@ -100,46 +98,35 @@ Ce prompt est destiné aux coding agents. Il leur demande de cloner le dépôt s
cd deer-flow cd deer-flow
``` ```
2. **Lancer l'assistant de configuration (recommandé)** 2. **Générer les fichiers de configuration locaux**
Depuis le répertoire racine du projet (`deer-flow/`), exécutez : Depuis le répertoire racine du projet (`deer-flow/`), exécutez :
```bash ```bash
make setup make config
``` ```
Cette commande lance un assistant interactif qui vous guide dans le choix d'un fournisseur LLM, d'une recherche web optionnelle et des préférences d'exécution/sécurité (mode sandbox, accès bash, outils d'écriture de fichiers). Il génère un `config.yaml` minimal et écrit vos clés dans `.env`. Comptez environ 2 minutes. Cette commande crée les fichiers de configuration locaux à partir des templates fournis.
Exécutez `make doctor` à tout moment pour vérifier votre configuration et obtenir des pistes de correction concrètes. 3. **Configurer le(s) modèle(s) de votre choix**
Si vous ouvrez une issue GitHub à propos d'un problème de configuration ou d'exécution en local, exécutez
`make support-bundle`. La commande affiche les prochaines étapes pour le rapporteur, écrit un fichier
`*-issue-summary.md` à coller dans l'issue, un fichier `*-issue-draft.md` destiné au dépôt d'issue
assisté par IA, ainsi qu'un zip de preuves optionnel sous
`.deer-flow/support-bundles/`. Si un assistant IA dépose l'issue, partez du brouillon et remplacez
chaque placeholder REQUIRED au lieu d'inventer les informations manquantes. N'attachez le zip que si
un mainteneur le demande, ou si le résumé seul ne suffit pas. Les mainteneurs et les outils de triage
IA peuvent commencer par `triage.json` ; le bundle ne contient que des diagnostics expurgés et des
manifestes de fichiers, et n'inclut ni `.env`, ni les messages bruts des conversations, ni le contenu
des fichiers de l'utilisateur.
> **Configuration avancée / manuelle** : si vous préférez éditer `config.yaml` directement, exécutez plutôt `make config` pour copier le template complet. Voir `config.example.yaml` pour la référence complète, y compris les providers basés sur un CLI (Codex CLI, Claude Code OAuth), OpenRouter, l'API Responses, et plus encore. Éditez `config.yaml` et définissez au moins un modèle :
<details>
<summary>Exemples de configuration manuelle des modèles</summary>
```yaml ```yaml
models: models:
- name: gpt-4o - name: gpt-4 # Internal identifier
display_name: GPT-4o display_name: GPT-4 # Human-readable name
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI # LangChain class path
model: gpt-4o model: gpt-4 # Model identifier for API
api_key: $OPENAI_API_KEY api_key: $OPENAI_API_KEY # API key (recommended: use env var)
max_tokens: 4096 # Maximum tokens per request
temperature: 0.7 # Sampling temperature
- name: openrouter-gemini-2.5-flash - name: openrouter-gemini-2.5-flash
display_name: Gemini 2.5 Flash (OpenRouter) display_name: Gemini 2.5 Flash (OpenRouter)
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
model: google/gemini-2.5-flash-preview model: google/gemini-2.5-flash-preview
api_key: $OPENROUTER_API_KEY api_key: $OPENAI_API_KEY # OpenRouter still uses the OpenAI-compatible field name here
base_url: https://openrouter.ai/api/v1 base_url: https://openrouter.ai/api/v1
- name: gpt-5-responses - name: gpt-5-responses
@ -149,26 +136,12 @@ Ce prompt est destiné aux coding agents. Il leur demande de cloner le dépôt s
api_key: $OPENAI_API_KEY api_key: $OPENAI_API_KEY
use_responses_api: true use_responses_api: true
output_version: responses/v1 output_version: responses/v1
- name: qwen3-32b-vllm
display_name: Qwen3 32B (vLLM)
use: deerflow.models.vllm_provider:VllmChatModel
model: Qwen/Qwen3-32B
api_key: $VLLM_API_KEY
base_url: http://localhost:8000/v1
supports_thinking: true
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
``` ```
OpenRouter et les passerelles compatibles OpenAI similaires doivent être configurés avec `langchain_openai:ChatOpenAI` et `base_url`. Si vous préférez utiliser un nom de variable d'environnement propre au fournisseur, pointez `api_key` vers cette variable explicitement (par exemple `api_key: $OPENROUTER_API_KEY`). OpenRouter et les passerelles compatibles OpenAI similaires doivent être configurés avec `langchain_openai:ChatOpenAI` et `base_url`. Si vous préférez utiliser un nom de variable d'environnement propre au fournisseur, pointez `api_key` vers cette variable explicitement (par exemple `api_key: $OPENROUTER_API_KEY`).
Pour router les modèles OpenAI via `/v1/responses`, continuez d'utiliser `langchain_openai:ChatOpenAI` et définissez `use_responses_api: true` avec `output_version: responses/v1`. Pour router les modèles OpenAI via `/v1/responses`, continuez d'utiliser `langchain_openai:ChatOpenAI` et définissez `use_responses_api: true` avec `output_version: responses/v1`.
Pour vLLM 0.19.0, utilisez `deerflow.models.vllm_provider:VllmChatModel`. Pour les modèles de raisonnement de type Qwen, DeerFlow active le raisonnement via `extra_body.chat_template_kwargs.enable_thinking` et préserve le champ non standard `reasoning` de vLLM au fil des conversations multi-tours avec appels d'outils. Les anciennes configurations `thinking` sont normalisées automatiquement pour assurer la rétrocompatibilité. Les modèles de raisonnement peuvent aussi exiger que le serveur soit démarré avec `--reasoning-parser ...`. Si votre déploiement vLLM local accepte n'importe quelle clé API non vide, vous pouvez tout de même définir `VLLM_API_KEY` avec une valeur factice.
Exemples de providers basés sur un CLI : Exemples de providers basés sur un CLI :
```yaml ```yaml
@ -189,22 +162,46 @@ Ce prompt est destiné aux coding agents. Il leur demande de cloner le dépôt s
``` ```
- Codex CLI lit `~/.codex/auth.json` - Codex CLI lit `~/.codex/auth.json`
- Claude Code accepte `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_CREDENTIALS_PATH`, ou `~/.claude/.credentials.json` - L'endpoint Responses de Codex rejette actuellement `max_tokens` et `max_output_tokens`, donc `CodexChatModel` n'expose pas de limite de tokens par requête
- Les entrées d'agents ACP sont distinctes des providers de modèles — si vous configurez `acp_agents.codex`, pointez-le vers un adaptateur Codex ACP tel que `npx -y @zed-industries/codex-acp` - Claude Code accepte `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`, `CLAUDE_CODE_CREDENTIALS_PATH`, ou en clair `~/.claude/.credentials.json`
- Sur macOS, exportez l'auth Claude Code explicitement si nécessaire : - Sur macOS, DeerFlow ne sonde pas le Keychain automatiquement. Exportez l'auth Claude Code explicitement si nécessaire :
```bash ```bash
eval "$(python3 scripts/export_claude_code_oauth.py --print-export)" eval "$(python3 scripts/export_claude_code_oauth.py --print-export)"
``` ```
Les clés API peuvent aussi être définies manuellement dans `.env` (recommandé) ou exportées dans votre shell : 4. **Définir les clés API pour le(s) modèle(s) configuré(s)**
Choisissez l'une des méthodes suivantes :
- Option A : Éditer le fichier `.env` à la racine du projet (recommandé)
```bash ```bash
OPENAI_API_KEY=your-openai-api-key
TAVILY_API_KEY=your-tavily-api-key TAVILY_API_KEY=your-tavily-api-key
OPENAI_API_KEY=your-openai-api-key
# OpenRouter also uses OPENAI_API_KEY when your config uses langchain_openai:ChatOpenAI + base_url.
# Add other provider keys as needed
INFOQUEST_API_KEY=your-infoquest-api-key
``` ```
</details> - Option B : Exporter les variables d'environnement dans votre shell
```bash
export OPENAI_API_KEY=your-openai-api-key
```
Pour les providers basés sur un CLI :
- Codex CLI : `~/.codex/auth.json`
- Claude Code OAuth : handoff explicite via env/fichier ou `~/.claude/.credentials.json`
- Option C : Éditer `config.yaml` directement (non recommandé en production)
```yaml
models:
- name: gpt-4
api_key: your-actual-api-key-here # Replace placeholder
```
### Lancer l'application ### Lancer l'application
@ -231,7 +228,7 @@ make down # Stop and remove containers
``` ```
> [!NOTE] > [!NOTE]
> Le runtime d'agent s'exécute actuellement dans la Gateway. nginx réécrit `/api/langgraph/*` vers l'API compatible LangGraph servie par la Gateway. > Le serveur d'agents LangGraph fonctionne actuellement via `langgraph dev` (le serveur CLI open source).
Accès : http://localhost:2026 Accès : http://localhost:2026
@ -241,8 +238,7 @@ Voir [CONTRIBUTING.md](CONTRIBUTING.md) pour le guide complet de développement
Si vous préférez lancer les services en local : Si vous préférez lancer les services en local :
Prérequis : complétez d'abord les étapes de « Configuration » ci-dessus (`make setup`). `make dev` nécessite un fichier `config.yaml` valide à la racine du projet. Définissez `DEER_FLOW_PROJECT_ROOT` pour indiquer explicitement cette racine, ou `DEER_FLOW_CONFIG_PATH` pour pointer vers un fichier de configuration précis. L'état d'exécution est écrit par défaut dans `.deer-flow` sous la racine du projet et peut être déplacé avec `DEER_FLOW_HOME` ; les skills sont lus par défaut depuis `skills/` sous la racine du projet et peuvent être déplacés avec `DEER_FLOW_SKILLS_PATH`. Exécutez `make doctor` pour vérifier votre configuration avant de démarrer. Prérequis : complétez d'abord les étapes de « Configuration » ci-dessus (`make config` et clés API des modèles). `make dev` nécessite un fichier de configuration valide (par défaut `config.yaml` à la racine du projet ; modifiable via `DEER_FLOW_CONFIG_PATH`).
Sous Windows, exécutez le flux de développement local depuis Git Bash. Les shells natifs `cmd.exe` et PowerShell ne sont pas pris en charge pour les scripts de service basés sur bash, et WSL n'est pas garanti car certains scripts dépendent d'utilitaires de Git for Windows comme `cygpath`.
1. **Vérifier les prérequis** : 1. **Vérifier les prérequis** :
```bash ```bash
@ -289,23 +285,18 @@ Voir le [Guide MCP Server](backend/docs/MCP_SERVER.md) pour les instructions dé
DeerFlow peut recevoir des tâches depuis des applications de messagerie. Les canaux démarrent automatiquement une fois configurés — aucune IP publique n'est requise. DeerFlow peut recevoir des tâches depuis des applications de messagerie. Les canaux démarrent automatiquement une fois configurés — aucune IP publique n'est requise.
DeerFlow peut aussi exposer des connexions de canaux IM appartenant à l'utilisateur dans l'UI du workspace. Quand `channel_connections` est activé, les utilisateurs connectés peuvent lier Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat ou WeCom depuis la barre latérale / Settings > Channels. Cela réutilise les transports sortants `channels.*` existants, donc aucune IP publique ni URL de callback provider n'est requise. Les messages IM entrants s'exécutent ensuite sous le compte utilisateur DeerFlow connecté. Voir [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md) pour la configuration et les notes de sécurité.
| Canal | Transport | Difficulté | | Canal | Transport | Difficulté |
|---------|-----------|------------| |---------|-----------|------------|
| Telegram | Bot API (long-polling) | Facile | | Telegram | Bot API (long-polling) | Facile |
| Slack | Socket Mode | Modérée | | Slack | Socket Mode | Modérée |
| Feishu / Lark | WebSocket | Modérée | | Feishu / Lark | WebSocket | Modérée |
| WeChat | Tencent iLink (long-polling) | Modérée |
| WeCom | WebSocket | Modérée |
| DingTalk | Stream Push (WebSocket) | Modérée |
**Configuration dans `config.yaml` :** **Configuration dans `config.yaml` :**
```yaml ```yaml
channels: channels:
# LangGraph-compatible Gateway API base URL (default: http://localhost:8001/api) # LangGraph Server URL (default: http://localhost:2024)
langgraph_url: http://localhost:8001/api langgraph_url: http://localhost:2024
# Gateway API URL (default: http://localhost:8001) # Gateway API URL (default: http://localhost:8001)
gateway_url: http://localhost:8001 gateway_url: http://localhost:8001
@ -326,11 +317,6 @@ channels:
# domain: https://open.feishu.cn # China (default) # domain: https://open.feishu.cn # China (default)
# domain: https://open.larksuite.com # International # domain: https://open.larksuite.com # International
wecom:
enabled: true
bot_id: $WECOM_BOT_ID
bot_secret: $WECOM_BOT_SECRET
slack: slack:
enabled: true enabled: true
bot_token: $SLACK_BOT_TOKEN # xoxb-... bot_token: $SLACK_BOT_TOKEN # xoxb-...
@ -355,26 +341,6 @@ channels:
context: context:
thinking_enabled: true thinking_enabled: true
subagent_enabled: true subagent_enabled: true
wechat:
enabled: false
bot_token: $WECHAT_BOT_TOKEN
ilink_bot_id: $WECHAT_ILINK_BOT_ID
qrcode_login_enabled: true # optionnel : autorise le bootstrap QR à la première utilisation quand bot_token est absent
allowed_users: [] # vide = tout le monde autorisé
polling_timeout: 35
state_dir: ./.deer-flow/wechat/state
max_inbound_image_bytes: 20971520
max_outbound_image_bytes: 20971520
max_inbound_file_bytes: 52428800
max_outbound_file_bytes: 52428800
dingtalk:
enabled: true
client_id: $DINGTALK_CLIENT_ID # ClientId depuis DingTalk Open Platform
client_secret: $DINGTALK_CLIENT_SECRET # ClientSecret depuis DingTalk Open Platform
allowed_users: [] # vide = tout le monde autorisé
card_template_id: "" # Optionnel : ID de modèle AI Card pour l'effet machine à écrire en streaming
``` ```
Définissez les clés API correspondantes dans votre fichier `.env` : Définissez les clés API correspondantes dans votre fichier `.env` :
@ -390,18 +356,6 @@ SLACK_APP_TOKEN=xapp-...
# Feishu / Lark # Feishu / Lark
FEISHU_APP_ID=cli_xxxx FEISHU_APP_ID=cli_xxxx
FEISHU_APP_SECRET=your_app_secret FEISHU_APP_SECRET=your_app_secret
# WeChat iLink
WECHAT_BOT_TOKEN=your_ilink_bot_token
WECHAT_ILINK_BOT_ID=your_ilink_bot_id
# WeCom
WECOM_BOT_ID=your_bot_id
WECOM_BOT_SECRET=your_bot_secret
# DingTalk
DINGTALK_CLIENT_ID=your_client_id
DINGTALK_CLIENT_SECRET=your_client_secret
``` ```
**Configuration Telegram** **Configuration Telegram**
@ -424,29 +378,6 @@ DINGTALK_CLIENT_SECRET=your_client_secret
3. Dans **Events**, abonnez-vous à `im.message.receive_v1` et sélectionnez le mode **Long Connection**. 3. Dans **Events**, abonnez-vous à `im.message.receive_v1` et sélectionnez le mode **Long Connection**.
4. Copiez l'App ID et l'App Secret. Définissez `FEISHU_APP_ID` et `FEISHU_APP_SECRET` dans `.env` et activez le canal dans `config.yaml`. 4. Copiez l'App ID et l'App Secret. Définissez `FEISHU_APP_ID` et `FEISHU_APP_SECRET` dans `.env` et activez le canal dans `config.yaml`.
**Configuration WeChat**
1. Activez le canal `wechat` dans `config.yaml`.
2. Soit définissez `WECHAT_BOT_TOKEN` dans `.env`, soit mettez `qrcode_login_enabled: true` pour le bootstrap QR à la première utilisation.
3. Quand `bot_token` est absent et que le bootstrap QR est activé, surveillez les logs du backend pour le contenu du QR renvoyé par iLink et complétez le flux de binding.
4. Une fois le flux QR réussi, DeerFlow persiste le token acquis sous `state_dir` pour les redémarrages ultérieurs.
5. Pour les déploiements Docker Compose, gardez `state_dir` sur un volume persistant afin que le curseur `get_updates_buf` et l'état d'auth sauvegardé survivent aux redémarrages.
**Configuration WeCom**
1. Créez un bot sur la plateforme WeCom AI Bot et obtenez le `bot_id` et le `bot_secret`.
2. Activez `channels.wecom` dans `config.yaml` et renseignez `bot_id` / `bot_secret`.
3. Définissez `WECOM_BOT_ID` et `WECOM_BOT_SECRET` dans `.env`.
4. Assurez-vous que les dépendances du backend incluent `wecom-aibot-python-sdk`. Le canal utilise une connexion longue WebSocket et ne nécessite pas d'URL de callback publique.
5. L'intégration actuelle prend en charge les messages entrants texte, image et fichier. Les images/fichiers finaux générés par l'agent sont aussi renvoyés dans la conversation WeCom.
**Configuration DingTalk**
1. Créez une application sur [DingTalk Open Platform](https://open.dingtalk.com/) et activez la capacité **Robot**.
2. Dans la page de configuration du robot, définissez le mode de réception des messages sur **Stream**.
3. Copiez le `Client ID` et le `Client Secret`. Définissez `DINGTALK_CLIENT_ID` et `DINGTALK_CLIENT_SECRET` dans `.env` et activez le canal dans `config.yaml`.
4. *(Optionnel)* Pour activer les réponses en streaming AI Card (effet machine à écrire), créez un modèle **AI Card** sur la [plateforme de cartes DingTalk](https://open.dingtalk.com/document/dingstart/typewriter-effect-streaming-ai-card), puis définissez `card_template_id` dans `config.yaml` avec l'ID du modèle. Vous devez également demander les permissions `Card.Streaming.Write` et `Card.Instance.Write`.
**Commandes** **Commandes**
Une fois un canal connecté, vous pouvez interagir avec DeerFlow directement depuis le chat : Une fois un canal connecté, vous pouvez interagir avec DeerFlow directement depuis le chat :
@ -474,37 +405,6 @@ LANGSMITH_API_KEY=lsv2_pt_xxxxxxxxxxxxxxxx
LANGSMITH_PROJECT=xxx LANGSMITH_PROJECT=xxx
``` ```
#### Traçage Langfuse
DeerFlow prend également en charge l'observabilité via [Langfuse](https://langfuse.com) pour les exécutions compatibles LangChain.
Ajoutez les lignes suivantes à votre fichier `.env` :
```bash
LANGFUSE_TRACING=true
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```
Si vous utilisez une instance Langfuse auto-hébergée, définissez `LANGFUSE_BASE_URL` sur l'URL de votre déploiement.
**Champs de corrélation des traces.** Chaque exécution d'agent est annotée avec les attributs de trace réservés de Langfuse afin que les pages Sessions et Users se remplissent automatiquement :
- `session_id` = `thread_id` de LangGraph — regroupe toutes les traces d'une même conversation
- `user_id` = utilisateur effectif issu de `get_effective_user_id()` (revient à `default` en mode sans authentification)
- `trace_name` = assistant id (par défaut `lead-agent`)
- `tags` = `[env:<DEER_FLOW_ENV>, model:<model_name>]` (omis lorsqu'ils ne sont pas définis)
- `metadata.deerflow_trace_id` = id de corrélation de requête DeerFlow, identique à `X-Trace-Id` lorsque la corrélation de trace des requêtes est activée
Ces champs sont injectés dans `RunnableConfig.metadata` à la racine de l'invocation du graphe, à la fois pour le chemin gateway (`runtime/runs/worker.py::run_agent`) et le chemin embarqué (`client.py::DeerFlowClient.stream`), de sorte que tout callback compatible LangChain puisse les lire. Définissez `DEER_FLOW_ENV` (ou `ENVIRONMENT`) pour étiqueter les traces par environnement de déploiement.
#### Utiliser les deux fournisseurs
Si LangSmith et Langfuse sont tous deux activés, DeerFlow attache les deux callbacks de traçage et rapporte la même activité de modèle aux deux systèmes.
Si un fournisseur est explicitement activé mais qu'il manque les identifiants requis, ou si son callback échoue à s'initialiser, DeerFlow échoue immédiatement (fail fast) lors de l'initialisation du traçage à la création du modèle, et le message d'erreur indique le fournisseur à l'origine de l'échec.
Pour les déploiements Docker, le traçage est désactivé par défaut. Définissez `LANGSMITH_TRACING=true` et `LANGSMITH_API_KEY` dans votre `.env` pour l'activer. Pour les déploiements Docker, le traçage est désactivé par défaut. Définissez `LANGSMITH_TRACING=true` et `LANGSMITH_API_KEY` dans votre `.env` pour l'activer.
## Du Deep Research au Super Agent Harness ## Du Deep Research au Super Agent Harness
@ -577,22 +477,6 @@ DEERFLOW_LANGGRAPH_URL=http://localhost:2026/api/langgraph # LangGraph API
Voir [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md) pour la référence API complète. Voir [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md) pour la référence API complète.
### Objectifs de session (Session Goals)
Utilisez `/goal <condition de complétion>` pour attacher une condition de complétion active au thread courant. Le goal est un état de portée thread, pas une activation de skill — il reste actif entre les tours jusqu'à ce que DeerFlow détermine qu'il a été satisfait, ou jusqu'à ce que vous le supprimiez.
Commandes prises en charge :
```text
/goal finish the implementation and make all tests pass
/goal # afficher le goal actif
/goal clear # le supprimer
```
Après chaque exécution menée par la Gateway, DeerFlow évalue la conversation visible par rapport au goal actif à l'aide d'un modèle évaluateur non-thinking. L'évaluateur doit renvoyer un blocker typé (`missing_evidence`, `needs_user_input`, `run_failed`, `external_wait` ou `goal_not_met_yet`) accompagné de preuves visibles. DeerFlow n'injecte une hidden continuation que si le dernier tour assistant est durablement checkpointé, que le blocker est `goal_not_met_yet`, que le thread n'a pas changé durant l'évaluation et que le disjoncteur de non-progression n'a pas déclenché. Le plafond de sécurité est de 8 hidden continuations par défaut, et les évaluations identiques de non-progression s'arrêtent après 2 tentatives répétées. `/goal clear` ainsi que toute nouvelle saisie utilisateur ont priorité sur les continuations en file d'attente. Lorsque le goal est satisfait, DeerFlow le supprime automatiquement et publie l'état mis à jour du thread.
Le Web UI affiche le goal actif au-dessus de la zone de saisie. La même commande est disponible depuis le TUI et les canaux IM pris en charge. Dans le Web UI et les canaux IM pris en charge, définir `/goal <condition de complétion>` lance aussi une exécution avec la condition comme tâche ; les commandes de statut et de suppression ne gèrent que l'état du goal lui-même.
### Sub-Agents ### Sub-Agents
Les tâches complexes tiennent rarement en une seule passe. DeerFlow les décompose. Les tâches complexes tiennent rarement en une seule passe. DeerFlow les décompose.
@ -662,56 +546,10 @@ models = client.list_models() # {"models": [...]}
skills = client.list_skills() # {"skills": [...]} skills = client.list_skills() # {"skills": [...]}
client.update_skill("web-search", enabled=True) client.update_skill("web-search", enabled=True)
client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]} client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]}
client.set_goal("thread-1", "finish the implementation and make all tests pass")
client.get_goal("thread-1") # {"goal": {...}} or {"goal": None}
client.clear_goal("thread-1")
``` ```
Toutes les méthodes retournant des dicts sont validées en CI contre les modèles de réponse Pydantic du Gateway (`TestGatewayConformance`), garantissant que le client intégré reste synchronisé avec les schémas de l'API HTTP. Voir `backend/packages/harness/deerflow/client.py` pour la documentation API complète. Toutes les méthodes retournant des dicts sont validées en CI contre les modèles de réponse Pydantic du Gateway (`TestGatewayConformance`), garantissant que le client intégré reste synchronisé avec les schémas de l'API HTTP. Voir `backend/packages/harness/deerflow/client.py` pour la documentation API complète.
## Tâches planifiées (Scheduled Tasks)
DeerFlow inclut désormais un MVP de tâches planifiées (scheduled-task) de premier niveau dans le workspace.
Capacités actuelles du MVP :
- Gérer les tâches depuis `/workspace/scheduled-tasks`
- Choisir si chaque tâche planifiée réutilise un thread ou crée un nouveau thread à chaque exécution
- Prendre en charge les planifications `once` et `cron`
- Exécuter les tâches planifiées en arrière-plan comme des exécutions DeerFlow non interactives (`ask_clarification` n'y est pas exposé)
- Utiliser le comportement de chevauchement `skip` pour les exécutions cron dues qui entrent en collision avec une exécution active sur le même thread réutilisé
- Mettre en pause, reprendre, déclencher, inspecter l'historique et supprimer les tâches
- Exécuter le travail planifié via le cycle de vie d'exécution normal de DeerFlow
Limites actuelles du MVP :
- Pas encore d'outil `schedule_task` créable depuis la conversation
- Pas de tâches de notification en texte seul
- Pas de cibles de dispatch canal ou GitHub
- Pas de type de planification `interval` dans cette première version
Activez le polling en arrière-plan avec `config.yaml -> scheduler.enabled`. Le déclenchement manuel utilise la même ressource scheduled-task et le même chemin d'exécution.
## Atelier terminal (TUI)
`deerflow` est un atelier natif terminal pour ceux qui vivent dans le shell. Il s'exécute de manière **intégrée** sur `DeerFlowClient` — pas besoin de Gateway, de frontend, de nginx ou de Docker — tout en honorant les mêmes `config.yaml`, checkpointer, skills, mémoire, MCP et sandbox que le reste de DeerFlow.
![DeerFlow TUI](docs/tui/tui-preview.svg)
```bash
uv pip install 'deerflow-harness[tui]' # dépendance optionnelle 'textual'
deerflow # lancer l'UI terminal (TTY requis)
deerflow --continue # reprendre le thread le plus récent
deerflow --resume THREAD # reprendre un thread par id
deerflow --print "summarize this repo" # réponse one-shot headless vers stdout
deerflow --json "hello" # StreamEvents séparés par saut de ligne en mode headless
```
Une surface de chat pilotée au clavier avec un transcript en streaming (réponses rendues en Markdown), des cartes d'activité d'outils compactes, une palette de commandes slash `/`, la gestion des goal via `/goal`, des sélecteurs `/model` et `/threads`, l'historique de saisie, et l'interruption via `Esc` / `Ctrl+C`. Les sessions ouvertes dans le TUI apparaissent aussi dans la barre latérale du Web UI — elles écrivent dans le magasin de threads partagé sous l'utilisateur local par défaut, donc le terminal et le web restent synchronisés **sans lancer la Gateway**.
Voir [backend/docs/TUI.md](backend/docs/TUI.md) pour le guide complet.
## Documentation ## Documentation
- [Guide de contribution](CONTRIBUTING.md) - Mise en place de l'environnement de développement et workflow - [Guide de contribution](CONTRIBUTING.md) - Mise en place de l'environnement de développement et workflow

View File

@ -18,10 +18,14 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
## 公式ウェブサイト ## 公式ウェブサイト
[<img width="2880" height="1600" alt="image" src="https://github.com/user-attachments/assets/a598c49f-3b2f-41ea-a052-05e21349188a" />](https://deerflow.tech)
**実際のデモ**は[**公式ウェブサイト**](https://deerflow.tech)でご覧いただけます。 **実際のデモ**は[**公式ウェブサイト**](https://deerflow.tech)でご覧いただけます。
## ByteDance Volcengine のコーディングプラン ## ByteDance Volcengine のコーディングプラン
<img width="4808" height="2400" alt="英文方舟" src="https://github.com/user-attachments/assets/2ecc7b9d-50be-4185-b1f7-5542d222fb2d" />
- DeerFlowの実行には、Doubao-Seed-2.0-Code、DeepSeek v3.2、Kimi 2.5の使用を強く推奨します - DeerFlowの実行には、Doubao-Seed-2.0-Code、DeepSeek v3.2、Kimi 2.5の使用を強く推奨します
- [詳細はこちら](https://www.byteplus.com/en/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow) - [詳細はこちら](https://www.byteplus.com/en/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
- [中国大陸の開発者はこちらをクリック](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow) - [中国大陸の開発者はこちらをクリック](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
@ -42,7 +46,6 @@ DeerFlowは、BytePlusが独自に開発したインテリジェント検索・
- [🦌 DeerFlow - 2.0](#-deerflow---20) - [🦌 DeerFlow - 2.0](#-deerflow---20)
- [公式ウェブサイト](#公式ウェブサイト) - [公式ウェブサイト](#公式ウェブサイト)
- [ByteDance Volcengine のコーディングプラン](#bytedance-volcengine-のコーディングプラン)
- [InfoQuest](#infoquest) - [InfoQuest](#infoquest)
- [目次](#目次) - [目次](#目次)
- [Coding Agent に一文でセットアップを依頼](#coding-agent-に一文でセットアップを依頼) - [Coding Agent に一文でセットアップを依頼](#coding-agent-に一文でセットアップを依頼)
@ -56,21 +59,16 @@ DeerFlowは、BytePlusが独自に開発したインテリジェント検索・
- [MCPサーバー](#mcpサーバー) - [MCPサーバー](#mcpサーバー)
- [IMチャネル](#imチャネル) - [IMチャネル](#imチャネル)
- [LangSmithトレーシング](#langsmithトレーシング) - [LangSmithトレーシング](#langsmithトレーシング)
- [Langfuseトレーシング](#langfuseトレーシング)
- [両方のプロバイダーを使用する](#両方のプロバイダーを使用する)
- [Deep Researchからスーパーエージェントハーネスへ](#deep-researchからスーパーエージェントハーネスへ) - [Deep Researchからスーパーエージェントハーネスへ](#deep-researchからスーパーエージェントハーネスへ)
- [コア機能](#コア機能) - [コア機能](#コア機能)
- [スキルとツール](#スキルとツール) - [スキルとツール](#スキルとツール)
- [Claude Code連携](#claude-code連携) - [Claude Code連携](#claude-code連携)
- [セッションゴール (Session Goals)](#セッションゴール-session-goals)
- [サブエージェント](#サブエージェント) - [サブエージェント](#サブエージェント)
- [サンドボックスとファイルシステム](#サンドボックスとファイルシステム) - [サンドボックスとファイルシステム](#サンドボックスとファイルシステム)
- [コンテキストエンジニアリング](#コンテキストエンジニアリング) - [コンテキストエンジニアリング](#コンテキストエンジニアリング)
- [長期メモリ](#長期メモリ) - [長期メモリ](#長期メモリ)
- [推奨モデル](#推奨モデル) - [推奨モデル](#推奨モデル)
- [組み込みPythonクライアント](#組み込みpythonクライアント) - [組み込みPythonクライアント](#組み込みpythonクライアント)
- [スケジュールタスク (Scheduled Tasks)](#スケジュールタスク-scheduled-tasks)
- [ターミナルワークベンチ (TUI)](#ターミナルワークベンチ-tui)
- [ドキュメント](#ドキュメント) - [ドキュメント](#ドキュメント)
- [⚠️ セキュリティに関する注意](#-セキュリティに関する注意) - [⚠️ セキュリティに関する注意](#-セキュリティに関する注意)
- [コントリビュート](#コントリビュート) - [コントリビュート](#コントリビュート)
@ -100,103 +98,68 @@ DeerFlow がまだ clone されていなければ先に clone してから、htt
cd deer-flow cd deer-flow
``` ```
2. **セットアップウィザードの実行(推奨)** 2. **ローカル設定ファイルの生成**
プロジェクトルートディレクトリ(`deer-flow/`)から以下を実行します: プロジェクトルートディレクトリ(`deer-flow/`)から以下を実行します:
```bash ```bash
make setup make config
``` ```
対話式ウィザードが起動し、LLMプロバイダーの選択、オプションのWeb検索、そしてサンドボックスモード・bash権限・ファイル書き込みツールなどの実行/安全設定を順に案内します。最小構成の`config.yaml`を生成し、APIキーを`.env`に書き込みます。所要時間は約2分です。 このコマンドは、提供されたテンプレートに基づいてローカル設定ファイルを作成します。
いつでも`make doctor`を実行して、設定を確認し、具体的な修正ヒントを得られます。 3. **使用するモデルの設定**
ローカルセットアップや実行時の問題についてGitHub issueを起票する場合は、`make support-bundle`を実行してください。このコマンドは報告者向けの次のステップを表示し、issueに貼り付けるための`*-issue-summary.md`ファイルと、AI支援でissueを起票するための`*-issue-draft.md`ファイルを書き出し、オプションで証跡zipを`.deer-flow/support-bundles/`以下に作成します。AIアシスタントがissueを起票する場合は、ドラフトを起点にして、不足している事実を創作するのではなく、すべてのREQUIREDプレースホルダーを置き換えてください。zipは、メンテナーから求められた場合、またはサマリーだけでは不十分な場合にのみ添付してください。メンテナーやAIトリアージツールは`triage.json`から確認を始められます。バンドルに含まれるのはリダクト済みの診断情報とファイルマニフェストのみで、`.env`、生の会話メッセージ、ユーザーファイルの内容は含まれません。
> **上級者向け / 手動設定**`config.yaml`を直接編集したい場合は、代わりに`make config`を実行して完全なテンプレートをコピーしてください。CLI連携プロバイダーCodex CLI、Claude Code OAuth、OpenRouter、Responses APIなどを含む完全なリファレンスは`config.example.yaml`を参照してください。 `config.yaml`を編集し、少なくとも1つのモデルを定義します
<details>
<summary>手動モデル設定の例</summary>
```yaml ```yaml
models: models:
- name: gpt-4o - name: gpt-4 # 内部識別子
display_name: GPT-4o display_name: GPT-4 # 表示名
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI # LangChainクラスパス
model: gpt-4o model: gpt-4 # API用モデル識別子
api_key: $OPENAI_API_KEY api_key: $OPENAI_API_KEY # APIキー推奨環境変数を使用
max_tokens: 4096 # リクエストあたりの最大トークン数
temperature: 0.7 # サンプリング温度
- name: openrouter-gemini-2.5-flash - name: openrouter-gemini-2.5-flash
display_name: Gemini 2.5 Flash (OpenRouter) display_name: Gemini 2.5 Flash (OpenRouter)
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
model: google/gemini-2.5-flash-preview model: google/gemini-2.5-flash-preview
api_key: $OPENROUTER_API_KEY api_key: $OPENAI_API_KEY # OpenRouterもここではOpenAI互換のフィールド名を使用
base_url: https://openrouter.ai/api/v1 base_url: https://openrouter.ai/api/v1
- name: gpt-5-responses
display_name: GPT-5 (Responses API)
use: langchain_openai:ChatOpenAI
model: gpt-5
api_key: $OPENAI_API_KEY
use_responses_api: true
output_version: responses/v1
- name: qwen3-32b-vllm
display_name: Qwen3 32B (vLLM)
use: deerflow.models.vllm_provider:VllmChatModel
model: Qwen/Qwen3-32B
api_key: $VLLM_API_KEY
base_url: http://localhost:8000/v1
supports_thinking: true
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
``` ```
OpenRouterやOpenAI互換のゲートウェイは、`langchain_openai:ChatOpenAI``base_url`で設定します。プロバイダー固有の環境変数名を使用したい場合は、`api_key`でその変数を明示的に指定してください(例:`api_key: $OPENROUTER_API_KEY`)。 OpenRouterやOpenAI互換のゲートウェイは、`langchain_openai:ChatOpenAI``base_url`で設定します。プロバイダー固有の環境変数名を使用したい場合は、`api_key`でその変数を明示的に指定してください(例:`api_key: $OPENROUTER_API_KEY`)。
OpenAIモデルを`/v1/responses`経由でルーティングするには、引き続き`langchain_openai:ChatOpenAI`を使用し、`use_responses_api: true``output_version: responses/v1`を設定してください。 4. **設定したモデルのAPIキーを設定**
vLLM 0.19.0では`deerflow.models.vllm_provider:VllmChatModel`を使用してください。Qwen系のreasoningモデルでは、DeerFlowは`extra_body.chat_template_kwargs.enable_thinking`でreasoningを切り替え、マルチターンのツールコール会話にわたってvLLM独自の非標準`reasoning`フィールドを保持します。従来の`thinking`設定は後方互換性のため自動的に正規化されます。reasoningモデルはサーバー側で`--reasoning-parser ...`を付けて起動する必要がある場合もあります。ローカルのvLLMデプロイメントが空でない任意のAPIキーを受け付ける場合でも、`VLLM_API_KEY`にはプレースホルダー値を設定しておけます。 以下のいずれかの方法を選択してください:
CLI連携プロバイダーの例 - オプションAプロジェクトルートの`.env`ファイルを編集(推奨)
```bash
TAVILY_API_KEY=your-tavily-api-key
OPENAI_API_KEY=your-openai-api-key
# OpenRouterもlangchain_openai:ChatOpenAI + base_url使用時はOPENAI_API_KEYを使用します。
# 必要に応じて他のプロバイダーキーを追加
INFOQUEST_API_KEY=your-infoquest-api-key
```
- オプションBシェルで環境変数をエクスポート
```bash
export OPENAI_API_KEY=your-openai-api-key
```
- オプションC`config.yaml`を直接編集(本番環境には非推奨)
```yaml ```yaml
models: models:
- name: gpt-5.4 - name: gpt-4
display_name: GPT-5.4 (Codex CLI) api_key: your-actual-api-key-here # プレースホルダーを置換
use: deerflow.models.openai_codex_provider:CodexChatModel
model: gpt-5.4
supports_thinking: true
supports_reasoning_effort: true
- name: claude-sonnet-4.6
display_name: Claude Sonnet 4.6 (Claude Code OAuth)
use: deerflow.models.claude_provider:ClaudeChatModel
model: claude-sonnet-4-6
max_tokens: 4096
supports_thinking: true
``` ```
- Codex CLIは`~/.codex/auth.json`を読み取ります
- Claude Codeは`CLAUDE_CODE_OAUTH_TOKEN``ANTHROPIC_AUTH_TOKEN``CLAUDE_CODE_CREDENTIALS_PATH`、または`~/.claude/.credentials.json`を受け付けます
- ACPエージェントのエントリはモデルプロバイダーとは別物です。`acp_agents.codex`を設定する場合は、`npx -y @zed-industries/codex-acp`のようなCodex ACPアダプターを指定してください
- macOSでは、必要に応じてClaude Codeの認証情報を明示的にエクスポートしてください
```bash
eval "$(python3 scripts/export_claude_code_oauth.py --print-export)"
```
APIキーは`.env`で手動設定する(推奨)ことも、シェルでエクスポートすることもできます:
```bash
OPENAI_API_KEY=your-openai-api-key
TAVILY_API_KEY=your-tavily-api-key
```
</details>
### アプリケーションの実行 ### アプリケーションの実行
#### オプション1: Docker推奨 #### オプション1: Docker推奨
@ -218,7 +181,7 @@ make down # コンテナを停止して削除
``` ```
> [!NOTE] > [!NOTE]
> Agentランタイムは現在Gateway内で実行されます。`/api/langgraph/*`はnginxによってGatewayのLangGraph-compatible APIへ書き換えられます。 > LangGraphエージェントサーバーは現在`langgraph dev`オープンソースCLIサーバー経由で実行されます。
アクセス: http://localhost:2026 アクセス: http://localhost:2026
@ -228,8 +191,7 @@ make down # コンテナを停止して削除
サービスをローカルで実行する場合: サービスをローカルで実行する場合:
前提条件:上記の「設定」手順を先に完了してください(`make setup`)。`make dev`にはプロジェクトルートに有効な`config.yaml`が必要です。`DEER_FLOW_PROJECT_ROOT`でプロジェクトルートを明示的に指定するか、`DEER_FLOW_CONFIG_PATH`で特定の設定ファイルを指定できます。実行時の状態はデフォルトでプロジェクトルート直下の`.deer-flow`に書き込まれ、`DEER_FLOW_HOME`で移動できます。skillsはデフォルトでプロジェクトルート直下の`skills/`から読み込まれ、`DEER_FLOW_SKILLS_PATH`で移動できます。起動前に`make doctor`を実行して設定を確認してください。 前提条件:上記の「設定」手順を先に完了してください(`make config`とモデルAPIキー`make dev`には有効な設定ファイルが必要です(デフォルトはプロジェクトルートの`config.yaml``DEER_FLOW_CONFIG_PATH`で上書き可能)。
Windowsでは、ローカル開発フローはGit Bashから実行してください。bashベースのサービススクリプトはネイティブの`cmd.exe`やPowerShellではサポートされておらず、一部のスクリプトがGit for Windowsの`cygpath`などのユーティリティに依存しているため、WSLでの動作も保証されません。
1. **前提条件の確認** 1. **前提条件の確認**
```bash ```bash
@ -276,23 +238,18 @@ HTTP/SSE MCPサーバーでは、OAuthトークンフロー`client_credential
DeerFlowはメッセージングアプリからのタスク受信をサポートしています。チャネルは設定時に自動的に開始されます。いずれもパブリックIPは不要です。 DeerFlowはメッセージングアプリからのタスク受信をサポートしています。チャネルは設定時に自動的に開始されます。いずれもパブリックIPは不要です。
DeerFlowはワークスペースUIでユーザー所有のIMチャネル接続を公開することもできます。`channel_connections`を有効にすると、ログイン済みユーザーはサイドバー / Settings > ChannelsからTelegram、Slack、Discord、Feishu/Lark、DingTalk、WeChat、WeComをバインドできます。これは既存の`channels.*`送信トランスポートを再利用するため、パブリックIPやプロバイダーのコールバックURLは不要です。受信したIMメッセージは接続したDeerFlowユーザーアカウントの下で実行されます。セットアップとセキュリティ上の注意は[IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md)をご覧ください。
| チャネル | トランスポート | 難易度 | | チャネル | トランスポート | 難易度 |
|---------|-----------|------------| |---------|-----------|------------|
| Telegram | Bot APIロングポーリング | 簡単 | | Telegram | Bot APIロングポーリング | 簡単 |
| Slack | Socket Mode | 中程度 | | Slack | Socket Mode | 中程度 |
| Feishu / Lark | WebSocket | 中程度 | | Feishu / Lark | WebSocket | 中程度 |
| WeChat | Tencent iLinkロングポーリング | 中程度 |
| WeCom | WebSocket | 中程度 |
| DingTalk | Stream PushWebSocket | 中程度 |
**`config.yaml`での設定:** **`config.yaml`での設定:**
```yaml ```yaml
channels: channels:
# LangGraph-compatible Gateway API base URLデフォルト: http://localhost:8001/api # LangGraphサーバーURLデフォルト: http://localhost:2024
langgraph_url: http://localhost:8001/api langgraph_url: http://localhost:2024
# Gateway API URLデフォルト: http://localhost:8001 # Gateway API URLデフォルト: http://localhost:8001
gateway_url: http://localhost:8001 gateway_url: http://localhost:8001
@ -313,11 +270,6 @@ channels:
# domain: https://open.feishu.cn # China (default) # domain: https://open.feishu.cn # China (default)
# domain: https://open.larksuite.com # International # domain: https://open.larksuite.com # International
wecom:
enabled: true
bot_id: $WECOM_BOT_ID
bot_secret: $WECOM_BOT_SECRET
slack: slack:
enabled: true enabled: true
bot_token: $SLACK_BOT_TOKEN # xoxb-... bot_token: $SLACK_BOT_TOKEN # xoxb-...
@ -342,26 +294,6 @@ channels:
context: context:
thinking_enabled: true thinking_enabled: true
subagent_enabled: true subagent_enabled: true
wechat:
enabled: false
bot_token: $WECHAT_BOT_TOKEN
ilink_bot_id: $WECHAT_ILINK_BOT_ID
qrcode_login_enabled: true # オプションbot_tokenがない場合に初回のQRブートストラップを許可
allowed_users: [] # 空 = 全員許可
polling_timeout: 35
state_dir: ./.deer-flow/wechat/state
max_inbound_image_bytes: 20971520
max_outbound_image_bytes: 20971520
max_inbound_file_bytes: 52428800
max_outbound_file_bytes: 52428800
dingtalk:
enabled: true
client_id: $DINGTALK_CLIENT_ID # DingTalk Open PlatformのClientId
client_secret: $DINGTALK_CLIENT_SECRET # DingTalk Open PlatformのClientSecret
allowed_users: [] # 空 = 全員許可
card_template_id: "" # オプションストリーミングタイプライター効果用のAIカードテンプレートID
``` ```
対応するAPIキーを`.env`ファイルに設定します: 対応するAPIキーを`.env`ファイルに設定します:
@ -377,18 +309,6 @@ SLACK_APP_TOKEN=xapp-...
# Feishu / Lark # Feishu / Lark
FEISHU_APP_ID=cli_xxxx FEISHU_APP_ID=cli_xxxx
FEISHU_APP_SECRET=your_app_secret FEISHU_APP_SECRET=your_app_secret
# WeChat iLink
WECHAT_BOT_TOKEN=your_ilink_bot_token
WECHAT_ILINK_BOT_ID=your_ilink_bot_id
# WeCom
WECOM_BOT_ID=your_bot_id
WECOM_BOT_SECRET=your_bot_secret
# DingTalk
DINGTALK_CLIENT_ID=your_client_id
DINGTALK_CLIENT_SECRET=your_client_secret
``` ```
**Telegramのセットアップ** **Telegramのセットアップ**
@ -411,29 +331,6 @@ DINGTALK_CLIENT_SECRET=your_client_secret
3. **イベント**で`im.message.receive_v1`を購読し、**ロングコネクション**モードを選択。 3. **イベント**で`im.message.receive_v1`を購読し、**ロングコネクション**モードを選択。
4. App IDとApp Secretをコピー。`.env``FEISHU_APP_ID``FEISHU_APP_SECRET`を設定し、`config.yaml`でチャネルを有効にします。 4. App IDとApp Secretをコピー。`.env``FEISHU_APP_ID``FEISHU_APP_SECRET`を設定し、`config.yaml`でチャネルを有効にします。
**WeChatのセットアップ**
1. `config.yaml``wechat`チャネルを有効にします。
2. `.env``WECHAT_BOT_TOKEN`を設定するか、初回のQRブートストラップのために`qrcode_login_enabled: true`を設定します。
3. `bot_token`がなくQRブートストラップが有効な場合は、バックエンドログでiLinkが返したQRコンテンツを監視し、バインドフローを完了します。
4. QRフローが成功した後、DeerFlowは取得したトークンを`state_dir`に永続化し、以降の再起動で再利用します。
5. Docker Composeデプロイでは、`state_dir`を永続ボリュームに置き、`get_updates_buf`カーソルと保存済みの認証ステートが再起動後も保持されるようにしてください。
**WeComのセットアップ**
1. WeCom AI Botプラットフォームでボットを作成し、`bot_id``bot_secret`を取得します。
2. `config.yaml``channels.wecom`を有効にし、`bot_id` / `bot_secret`を入力します。
3. `.env``WECOM_BOT_ID``WECOM_BOT_SECRET`を設定します。
4. バックエンドの依存関係に`wecom-aibot-python-sdk`が含まれていることを確認してください。このチャネルはWebSocketロングコネクションを使用し、パブリックなコールバックURLは不要です。
5. 現在の統合では、受信テキスト、画像、ファイルメッセージをサポートしています。エージェントが生成した最終的な画像/ファイルもWeComの会話に送り返されます。
**DingTalkのセットアップ**
1. [DingTalk Open Platform](https://open.dingtalk.com/)でアプリを作成し、**ロボット**機能を有効化します。
2. ロボット設定ページでメッセージ受信モードを**Streamモード**に設定します。
3. `Client ID``Client Secret`をコピー。`.env``DINGTALK_CLIENT_ID``DINGTALK_CLIENT_SECRET`を設定し、`config.yaml`でチャネルを有効にします。
4. *(オプション)* ストリーミングAIカード返信タイプライター効果を有効にするには、[DingTalkカードプラットフォーム](https://open.dingtalk.com/document/dingstart/typewriter-effect-streaming-ai-card)で**AIカード**テンプレートを作成し、`config.yaml``card_template_id`にテンプレートIDを設定します。`Card.Streaming.Write` および `Card.Instance.Write` 権限の申請も必要です。
**コマンド** **コマンド**
チャネル接続後、チャットから直接DeerFlowと対話できます チャネル接続後、チャットから直接DeerFlowと対話できます
@ -461,37 +358,6 @@ LANGSMITH_API_KEY=lsv2_pt_xxxxxxxxxxxxxxxx
LANGSMITH_PROJECT=xxx LANGSMITH_PROJECT=xxx
``` ```
#### Langfuseトレーシング
DeerFlowは、LangChain互換の実行に対して[Langfuse](https://langfuse.com)による可観測性もサポートしています。
`.env`ファイルに以下を追加します:
```bash
LANGFUSE_TRACING=true
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```
セルフホストのLangfuseインスタンスを使用している場合は、`LANGFUSE_BASE_URL`をデプロイ先のURLに設定します。
**トレース関連付けフィールド。** 各エージェント実行には、Langfuseの予約済みトレース属性が付与されるため、SessionsページとUsersページが自動的に表示されます
- `session_id` = LangGraphの`thread_id`——同一会話のすべてのトレースをグループ化します
- `user_id` = `get_effective_user_id()`から取得した有効なユーザー(認証なしモードでは`default`にフォールバック)
- `trace_name` = assistant idデフォルトは`lead-agent`
- `tags` = `[env:<DEER_FLOW_ENV>, model:<model_name>]`(未設定の場合は省略)
- `metadata.deerflow_trace_id` = DeerFlowのリクエスト関連付けid。リクエストトレース関連付けが有効な場合は`X-Trace-Id`と一致します
これらは、gatewayパス`runtime/runs/worker.py::run_agent`)と埋め込みパス(`client.py::DeerFlowClient.stream`)の両方で、グラフ呼び出しのルートで`RunnableConfig.metadata`に注入されるため、LangChain互換の任意のcallbackから読み取れます。`DEER_FLOW_ENV`(または`ENVIRONMENT`)を設定すると、デプロイ環境ごとにトレースにタグを付けられます。
#### 両方のプロバイダーを使用する
LangSmithとLangfuseの両方を有効にすると、DeerFlowは両方のトレーシングcallbackを取り付け、同じモデルアクティビティを両方のシステムに報告します。
あるプロバイダーが明示的に有効化されているにもかかわらず必要な認証情報が欠けている場合、またはそのcallbackの初期化に失敗した場合、DeerFlowはモデル作成時のトレーシング初期化中に早期に失敗fail fastし、エラーメッセージには失敗の原因となったプロバイダー名が示されます。
Dockerデプロイでは、トレーシングはデフォルトで無効です。`.env``LANGSMITH_TRACING=true``LANGSMITH_API_KEY`を設定して有効にします。 Dockerデプロイでは、トレーシングはデフォルトで無効です。`.env``LANGSMITH_TRACING=true``LANGSMITH_API_KEY`を設定して有効にします。
## Deep Researchからスーパーエージェントハーネスへ ## Deep Researchからスーパーエージェントハーネスへ
@ -564,22 +430,6 @@ DEERFLOW_LANGGRAPH_URL=http://localhost:2026/api/langgraph # LangGraph API
完全なAPIリファレンスは[`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md)をご覧ください。 完全なAPIリファレンスは[`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md)をご覧ください。
### セッションゴール (Session Goals)
`/goal <完了条件>`を使うと、現在のスレッドに1つのアクティブな完了条件を紐付けられます。このゴールはスレッドスコープのステートであり、スキルの有効化ではないため、DeerFlowが満たされたと判定するか、あなたがクリアするまでターンをまたいで有効なまま維持されます。
対応するコマンド:
```text
/goal finish the implementation and make all tests pass
/goal # アクティブなゴールを表示
/goal clear # クリアする
```
各Gateway駆動のrunの後に、DeerFlowはnon-thinkingな評価モデルを使って、可視の会話をアクティブなゴールと照らし合わせます。評価モデルは型付きblocker`missing_evidence``needs_user_input``run_failed``external_wait``goal_not_met_yet`と可視の証拠を返さなければなりません。DeerFlowがhidden continuationを注入するのは、直近のassistantターンが耐久性のあるチェックポイントに保存され、blockerが`goal_not_met_yet`であり、評価中にスレッドが変化せず、no-progressブレーカーが発火していない場合のみです。安全上限はデフォルトで8回のhidden continuationで、同一の非進行評価が繰り返されると2回で停止します。`/goal clear`と、ユーザーが手書きした新規入力はすべて、キュー内のcontinuationより優先されます。ゴールが満たされると、DeerFlowは自動的にクリアし、更新されたスレッドステートを公開します。
Web UIは入力欄の上にアクティブなゴールを表示します。同じコマンドはTUIとサポート対象のIMチャネルからも利用できます。Web UIとサポート対象のIMチャネルでは、`/goal <完了条件>`を設定するとその条件をタスクとしてrunを開始します。ステータス確認やクリアのコマンドはゴールステートの管理のみを行います。
### サブエージェント ### サブエージェント
複雑なタスクは単一のパスに収まりません。DeerFlowはそれを分解します。 複雑なタスクは単一のパスに収まりません。DeerFlowはそれを分解します。
@ -629,7 +479,7 @@ DeerFlowはモデルに依存しません——OpenAI互換APIを実装する任
## 組み込みPythonクライアント ## 組み込みPythonクライアント
DeerFlowは、完全なHTTPサービスを実行せずに組み込みPythonライブラリとして使用できます。`DeerFlowClient`は、すべてのエージェントとGateway機能へのプロセス内直接アクセスを提供し、HTTP Gateway APIと同じレスポンススキーマを返します。HTTP Gatewayは、LangGraphスレッド自体が削除された後にDeerFlow管理下のローカルスレッドデータを削除するための`DELETE /api/threads/{thread_id}`も公開しています DeerFlowは、完全なHTTPサービスを実行せずに組み込みPythonライブラリとして使用できます。`DeerFlowClient`は、すべてのエージェントとGateway機能へのプロセス内直接アクセスを提供し、HTTP Gateway APIと同じレスポンススキーマを返します
```python ```python
from deerflow.client import DeerFlowClient from deerflow.client import DeerFlowClient
@ -649,56 +499,10 @@ models = client.list_models() # {"models": [...]}
skills = client.list_skills() # {"skills": [...]} skills = client.list_skills() # {"skills": [...]}
client.update_skill("web-search", enabled=True) client.update_skill("web-search", enabled=True)
client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]} client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]}
client.set_goal("thread-1", "finish the implementation and make all tests pass")
client.get_goal("thread-1") # {"goal": {...}} or {"goal": None}
client.clear_goal("thread-1")
``` ```
すべてのdict返却メソッドはCIでGateway Pydanticレスポンスモデルに対して検証されており`TestGatewayConformance`、組み込みクライアントがHTTP APIスキーマと同期していることを保証します。完全なAPIドキュメントは`backend/packages/harness/deerflow/client.py`をご覧ください。 すべてのdict返却メソッドはCIでGateway Pydanticレスポンスモデルに対して検証されており`TestGatewayConformance`、組み込みクライアントがHTTP APIスキーマと同期していることを保証します。完全なAPIドキュメントは`backend/packages/harness/deerflow/client.py`をご覧ください。
## スケジュールタスク (Scheduled Tasks)
DeerFlowには現在、ワークスペース内でファーストクラスのスケジュールタスクMVPが組み込まれています。
現在のMVPの機能
- `/workspace/scheduled-tasks`でタスクを管理
- 各スケジュールタスクがスレッドを再利用するか、実行ごとに新しいスレッドを作成するかを選択可能
- `once``cron`のスケジュールをサポート
- バックグラウンドのスケジュール実行を非対話型のDeerFlow runとして実行`ask_clarification`はここでは公開されません)
- 再利用された同じスレッド上でアクティブなrunと衝突する期限到来のcron実行に対して`skip`オーバーラップ挙動を使用
- タスクの一時停止、再開、トリガー、履歴確認、削除
- スケジュールされた作業を通常のDeerFlow runライフサイクルを通じて実行
現在のMVPの制限
- 会話で`schedule_task`ツールを作成する機能はまだありません
- テキストのみの通知ジョブはありません
- チャネルやGitHubのディスパッチターゲットはありません
- この最初のバージョンでは`interval`スケジュールタイプはありません
`config.yaml -> scheduler.enabled`でバックグラウンドポーリングを有効にします。手動トリガーは同じスケジュールタスクリソースと実行パスを使用します。
## ターミナルワークベンチ (TUI)
`deerflow`は、シェルに暮らす人々のためのターミナルネイティブなワークベンチです。**組み込み**で`DeerFlowClient`上で実行され、Gateway、フロントエンド、nginx、Dockerは不要ですが、DeerFlowの他の部分と同じ`config.yaml`、checkpointer、スキル、メモリ、MCP、サンドボックス設定を尊重します。
![DeerFlow TUI](docs/tui/tui-preview.svg)
```bash
uv pip install 'deerflow-harness[tui]' # オプションの'textual'依存関係
deerflow # ターミナルUIを起動TTYが必要
deerflow --continue # 直近のスレッドを再開
deerflow --resume THREAD # IDでスレッドを再開
deerflow --print "summarize this repo" # ヘッドレスでstdoutにワンショットの回答を出力
deerflow --json "hello" # ヘッドレスで改行区切りのStreamEventを出力
```
ストリーミング文字起こしMarkdownでレンダリングされた回答、コンパクトなツールアクティビティカード、`/`スラッシュコマンドパレット、`/goal`ゴール管理、`/model``/threads`ピッカー、入力履歴、`Esc` / `Ctrl+C`割り込みを備えた、キーボード駆動のチャット画面。TUIで開いたセッションはWeb UIのサイドバーにも表示されます。ローカルのデフォルトユーザーの下で共有スレッドストアに書き込むため、**Gatewayを実行せずに**ターミナルとウェブが同期します。
完全なガイドは[backend/docs/TUI.md](backend/docs/TUI.md)をご覧ください。
## ドキュメント ## ドキュメント
- [コントリビュートガイド](CONTRIBUTING.md) - 開発環境のセットアップとワークフロー - [コントリビュートガイド](CONTRIBUTING.md) - 開発環境のセットアップとワークフロー

View File

@ -19,10 +19,14 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
## Официальный сайт ## Официальный сайт
[<img width="2880" height="1600" alt="image" src="https://github.com/user-attachments/assets/a598c49f-3b2f-41ea-a052-05e21349188a" />](https://deerflow.tech)
Больше информации и живые демо на [**официальном сайте**](https://deerflow.tech). Больше информации и живые демо на [**официальном сайте**](https://deerflow.tech).
## Coding Plan от ByteDance Volcengine ## Coding Plan от ByteDance Volcengine
<img width="4808" height="2400" alt="英文方舟" src="https://github.com/user-attachments/assets/2ecc7b9d-50be-4185-b1f7-5542d222fb2d" />
- Рекомендуем Doubao-Seed-2.0-Code, DeepSeek v3.2 и Kimi 2.5 для запуска DeerFlow - Рекомендуем Doubao-Seed-2.0-Code, DeepSeek v3.2 и Kimi 2.5 для запуска DeerFlow
- [Подробнее](https://www.byteplus.com/en/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow) - [Подробнее](https://www.byteplus.com/en/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
- [Для разработчиков из материкового Китая](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow) - [Для разработчиков из материкового Китая](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
@ -44,7 +48,6 @@ DeerFlow интегрирован с инструментарием для ум
- [🦌 DeerFlow - 2.0](#-deerflow---20) - [🦌 DeerFlow - 2.0](#-deerflow---20)
- [Официальный сайт](#официальный-сайт) - [Официальный сайт](#официальный-сайт)
- [Coding Plan от ByteDance Volcengine](#coding-plan-от-bytedance-volcengine)
- [InfoQuest](#infoquest) - [InfoQuest](#infoquest)
- [Содержание](#содержание) - [Содержание](#содержание)
- [Установка одной фразой для coding agent](#установка-одной-фразой-для-coding-agent) - [Установка одной фразой для coding agent](#установка-одной-фразой-для-coding-agent)
@ -58,21 +61,16 @@ DeerFlow интегрирован с инструментарием для ум
- [MCP-сервер](#mcp-сервер) - [MCP-сервер](#mcp-сервер)
- [Мессенджеры](#мессенджеры) - [Мессенджеры](#мессенджеры)
- [Трассировка LangSmith](#трассировка-langsmith) - [Трассировка LangSmith](#трассировка-langsmith)
- [Трассировка Langfuse](#трассировка-langfuse)
- [Использование обоих провайдеров](#использование-обоих-провайдеров)
- [От Deep Research к Super Agent Harness](#от-deep-research-к-super-agent-harness) - [От Deep Research к Super Agent Harness](#от-deep-research-к-super-agent-harness)
- [Core Features](#core-features) - [Core Features](#core-features)
- [Skills & Tools](#skills--tools) - [Skills & Tools](#skills--tools)
- [Интеграция с Claude Code](#интеграция-с-claude-code) - [Интеграция с Claude Code](#интеграция-с-claude-code)
- [Цели сессии (Session Goals)](#цели-сессии-session-goals)
- [Sub-Agents](#sub-agents) - [Sub-Agents](#sub-agents)
- [Sandbox & файловая система](#sandbox--файловая-система) - [Sandbox & файловая система](#sandbox--файловая-система)
- [Context Engineering](#context-engineering) - [Context Engineering](#context-engineering)
- [Long-Term Memory](#long-term-memory) - [Long-Term Memory](#long-term-memory)
- [Рекомендуемые модели](#рекомендуемые-модели) - [Рекомендуемые модели](#рекомендуемые-модели)
- [Встроенный Python-клиент](#встроенный-python-клиент) - [Встроенный Python-клиент](#встроенный-python-клиент)
- [Запланированные задачи (Scheduled Tasks)](#запланированные-задачи-scheduled-tasks)
- [Терминальная панель (TUI)](#терминальная-панель-tui)
- [Документация](#документация) - [Документация](#документация)
- [⚠️ Безопасность](#-безопасность) - [⚠️ Безопасность](#-безопасность)
- [Участие в разработке](#участие-в-разработке) - [Участие в разработке](#участие-в-разработке)
@ -102,47 +100,35 @@ DeerFlow интегрирован с инструментарием для ум
cd deer-flow cd deer-flow
``` ```
2. **Запустить мастер настройки (рекомендуется)** 2. **Сгенерировать локальные конфиги**
Из корня проекта (`deer-flow/`) запустите: Из корня проекта (`deer-flow/`) запустите:
```bash ```bash
make setup make config
``` ```
Запустится интерактивный мастер, который поможет выбрать LLM-провайдера, опциональный веб-поиск и настройки выполнения/безопасности (режим sandbox, доступ к bash, инструменты записи файлов). Он сгенерирует минимальный `config.yaml` и запишет ключи в `.env`. Это занимает около 2 минут. Команда создаёт локальные конфиги на основе шаблонов.
В любой момент запускайте `make doctor`, чтобы проверить конфигурацию и получить конкретные подсказки по исправлению. 3. **Настроить модель**
Если вы открываете GitHub issue о проблеме с локальной установкой или работой системы, выполните
`make support-bundle`. Команда выводит дальнейшие шаги для автора отчёта, создаёт файл
`*-issue-summary.md`, который нужно вставить в issue, файл `*-issue-draft.md`
для оформления issue с помощью AI и, опционально, zip-архив с диагностикой в
`.deer-flow/support-bundles/`. Если issue оформляет AI-ассистент, он должен начать
с черновика и заменить каждый плейсхолдер REQUIRED, а не выдумывать недостающие
факты. Прикладывайте zip-архив только если его запросит мейнтейнер или если одной
сводки недостаточно. Мейнтейнеры и AI-инструменты триажа могут начинать с
`triage.json`; архив содержит только очищенную от чувствительных данных диагностику
и манифесты файлов и не включает `.env`, исходные сообщения диалогов или содержимое
пользовательских файлов.
> **Продвинутая / ручная настройка**: если вы предпочитаете редактировать `config.yaml` напрямую, выполните вместо этого `make config`, чтобы скопировать полный шаблон. Полный справочник — `config.example.yaml`, включая CLI-провайдеров (Codex CLI, Claude Code OAuth), OpenRouter, Responses API и многое другое. Отредактируйте `config.yaml` и задайте хотя бы одну модель:
<details>
<summary>Примеры ручной настройки моделей</summary>
```yaml ```yaml
models: models:
- name: gpt-4o - name: gpt-4 # Внутренний идентификатор
display_name: GPT-4o display_name: GPT-4 # Отображаемое имя
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI # Путь к классу LangChain
model: gpt-4o model: gpt-4 # Идентификатор модели для API
api_key: $OPENAI_API_KEY api_key: $OPENAI_API_KEY # API-ключ (рекомендуется: переменная окружения)
max_tokens: 4096 # Максимальное количество токенов на запрос
temperature: 0.7 # Температура сэмплирования
- name: openrouter-gemini-2.5-flash - name: openrouter-gemini-2.5-flash
display_name: Gemini 2.5 Flash (OpenRouter) display_name: Gemini 2.5 Flash (OpenRouter)
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
model: google/gemini-2.5-flash-preview model: google/gemini-2.5-flash-preview
api_key: $OPENROUTER_API_KEY api_key: $OPENAI_API_KEY
base_url: https://openrouter.ai/api/v1 base_url: https://openrouter.ai/api/v1
- name: gpt-5-responses - name: gpt-5-responses
@ -152,27 +138,9 @@ DeerFlow интегрирован с инструментарием для ум
api_key: $OPENAI_API_KEY api_key: $OPENAI_API_KEY
use_responses_api: true use_responses_api: true
output_version: responses/v1 output_version: responses/v1
- name: qwen3-32b-vllm
display_name: Qwen3 32B (vLLM)
use: deerflow.models.vllm_provider:VllmChatModel
model: Qwen/Qwen3-32B
api_key: $VLLM_API_KEY
base_url: http://localhost:8000/v1
supports_thinking: true
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
``` ```
OpenRouter и аналогичные OpenAI-совместимые шлюзы настраиваются через `langchain_openai:ChatOpenAI` с параметром `base_url`. Если вы предпочитаете имя переменной окружения, специфичное для провайдера, укажите его в `api_key` явно (например, `api_key: $OPENROUTER_API_KEY`). OpenRouter и аналогичные OpenAI-совместимые шлюзы настраиваются через `langchain_openai:ChatOpenAI` с параметром `base_url`. Для CLI-провайдеров:
Чтобы направить модели OpenAI через `/v1/responses`, продолжайте использовать `langchain_openai:ChatOpenAI` и задайте `use_responses_api: true` вместе с `output_version: responses/v1`.
Для vLLM 0.19.0 используйте `deerflow.models.vllm_provider:VllmChatModel`. Для reasoning-моделей в стиле Qwen DeerFlow переключает режим рассуждений через `extra_body.chat_template_kwargs.enable_thinking` и сохраняет нестандартное поле `reasoning` vLLM в многоходовых диалогах с вызовами инструментов. Устаревшие конфигурации `thinking` автоматически нормализуются для обратной совместимости. Reasoning-моделям также может потребоваться запуск сервера с флагом `--reasoning-parser ...`. Если ваш локальный vLLM принимает любой непустой API-ключ, всё равно задайте `VLLM_API_KEY` со значением-заглушкой.
Примеры CLI-провайдеров:
```yaml ```yaml
models: models:
@ -192,22 +160,30 @@ DeerFlow интегрирован с инструментарием для ум
``` ```
- Codex CLI читает `~/.codex/auth.json` - Codex CLI читает `~/.codex/auth.json`
- Claude Code принимает `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_CREDENTIALS_PATH` или `~/.claude/.credentials.json` - Claude Code принимает `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN` или `~/.claude/.credentials.json`
- Записи ACP-агентов настраиваются отдельно от провайдеров моделей — если вы настраиваете `acp_agents.codex`, укажите в нём Codex ACP-адаптер, например `npx -y @zed-industries/codex-acp`
- На macOS при необходимости экспортируйте аутентификацию Claude Code явно: - На macOS при необходимости экспортируйте аутентификацию Claude Code явно:
```bash ```bash
eval "$(python3 scripts/export_claude_code_oauth.py --print-export)" eval "$(python3 scripts/export_claude_code_oauth.py --print-export)"
``` ```
API-ключи также можно задать вручную в `.env` (рекомендуется) или экспортировать в оболочке: 4. **Указать API-ключи**
- **Вариант А**: файл `.env` в корне проекта (рекомендуется)
```bash ```bash
OPENAI_API_KEY=your-openai-api-key
TAVILY_API_KEY=your-tavily-api-key TAVILY_API_KEY=your-tavily-api-key
OPENAI_API_KEY=your-openai-api-key
INFOQUEST_API_KEY=your-infoquest-api-key
``` ```
</details> - **Вариант Б**: переменные окружения в терминале
```bash
export OPENAI_API_KEY=your-openai-api-key
```
- **Вариант В**: напрямую в `config.yaml` (не рекомендуется для продакшена)
### Запуск ### Запуск
@ -234,9 +210,6 @@ make down # Остановить и удалить контейнеры
#### Вариант 2: Локальная разработка #### Вариант 2: Локальная разработка
Предварительное условие: сначала выполните шаги раздела «Конфигурация» выше (`make setup`). Для `make dev` нужен корректный `config.yaml` в корне проекта. Задайте `DEER_FLOW_PROJECT_ROOT`, чтобы явно указать корень проекта, или `DEER_FLOW_CONFIG_PATH`, чтобы указать конкретный файл конфигурации. Состояние времени выполнения по умолчанию записывается в `.deer-flow` в корне проекта и может быть перенесено через `DEER_FLOW_HOME`; skills по умолчанию читаются из `skills/` в корне проекта, путь можно переопределить через `DEER_FLOW_SKILLS_PATH`. Перед запуском выполните `make doctor`, чтобы проверить настройку.
В Windows запускайте локальный процесс разработки из Git Bash. Нативные оболочки `cmd.exe` и PowerShell не поддерживаются для сервисных скриптов на bash, а работа в WSL не гарантируется, поскольку некоторые скрипты зависят от утилит Git for Windows, таких как `cygpath`.
1. **Проверить зависимости**: 1. **Проверить зависимости**:
```bash ```bash
make check # Проверяет Node.js 22+, pnpm, uv, nginx make check # Проверяет Node.js 22+, pnpm, uv, nginx
@ -278,16 +251,11 @@ DeerFlow поддерживает настраиваемые MCP-серверы
DeerFlow принимает задачи прямо из мессенджеров. Каналы запускаются автоматически при настройке, публичный IP не нужен. DeerFlow принимает задачи прямо из мессенджеров. Каналы запускаются автоматически при настройке, публичный IP не нужен.
DeerFlow может также предоставлять в workspace UI пользовательские подключения IM-каналов. Когда включён `channel_connections`, вошедшие в систему пользователи могут привязать Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat или WeCom из боковой панели / Settings > Channels. Это переиспользует существующие исходящие транспорты `channels.*`, поэтому публичный IP или URL обратного вызова провайдера не требуются. Входящие IM-сообщения выполняются от имени подключённого пользователя DeerFlow. Настройки и вопросы безопасности описаны в [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md).
| Канал | Транспорт | Сложность | | Канал | Транспорт | Сложность |
|-------|-----------|-----------| |-------|-----------|-----------|
| Telegram | Bot API (long-polling) | Просто | | Telegram | Bot API (long-polling) | Просто |
| Slack | Socket Mode | Средне | | Slack | Socket Mode | Средне |
| Feishu / Lark | WebSocket | Средне | | Feishu / Lark | WebSocket | Средне |
| WeChat | Tencent iLink (long-polling) | Средне |
| WeCom | WebSocket | Средне |
| DingTalk | Stream Push (WebSocket) | Средне |
**Конфигурация в `config.yaml`:** **Конфигурация в `config.yaml`:**
@ -300,11 +268,6 @@ channels:
# domain: https://open.feishu.cn # China (default) # domain: https://open.feishu.cn # China (default)
# domain: https://open.larksuite.com # International # domain: https://open.larksuite.com # International
wecom:
enabled: true
bot_id: $WECOM_BOT_ID
bot_secret: $WECOM_BOT_SECRET
slack: slack:
enabled: true enabled: true
bot_token: $SLACK_BOT_TOKEN bot_token: $SLACK_BOT_TOKEN
@ -315,53 +278,6 @@ channels:
enabled: true enabled: true
bot_token: $TELEGRAM_BOT_TOKEN bot_token: $TELEGRAM_BOT_TOKEN
allowed_users: [] allowed_users: []
wechat:
enabled: false
bot_token: $WECHAT_BOT_TOKEN
ilink_bot_id: $WECHAT_ILINK_BOT_ID
qrcode_login_enabled: true # опционально: разрешить первичную загрузку через QR-код при отсутствии bot_token
allowed_users: [] # пусто = разрешить всем
polling_timeout: 35
state_dir: ./.deer-flow/wechat/state
max_inbound_image_bytes: 20971520
max_outbound_image_bytes: 20971520
max_inbound_file_bytes: 52428800
max_outbound_file_bytes: 52428800
dingtalk:
enabled: true
client_id: $DINGTALK_CLIENT_ID # ClientId с DingTalk Open Platform
client_secret: $DINGTALK_CLIENT_SECRET # ClientSecret с DingTalk Open Platform
allowed_users: [] # пусто = разрешить всем
card_template_id: "" # Опционально: ID шаблона AI Card для потокового эффекта печатной машинки
```
**Ключи API в `.env`:**
```bash
# Telegram
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ
# Slack
SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...
# Feishu / Lark
FEISHU_APP_ID=cli_xxxx
FEISHU_APP_SECRET=your_app_secret
# WeChat iLink
WECHAT_BOT_TOKEN=your_ilink_bot_token
WECHAT_ILINK_BOT_ID=your_ilink_bot_id
# WeCom
WECOM_BOT_ID=your_bot_id
WECOM_BOT_SECRET=your_bot_secret
# DingTalk
DINGTALK_CLIENT_ID=your_client_id
DINGTALK_CLIENT_SECRET=your_client_secret
``` ```
**Настройка Telegram** **Настройка Telegram**
@ -369,29 +285,6 @@ DINGTALK_CLIENT_SECRET=your_client_secret
1. Напишите [@BotFather](https://t.me/BotFather), отправьте `/newbot` и скопируйте HTTP API-токен. 1. Напишите [@BotFather](https://t.me/BotFather), отправьте `/newbot` и скопируйте HTTP API-токен.
2. Укажите `TELEGRAM_BOT_TOKEN` в `.env` и включите канал в `config.yaml`. 2. Укажите `TELEGRAM_BOT_TOKEN` в `.env` и включите канал в `config.yaml`.
**Настройка WeChat**
1. Включите канал `wechat` в `config.yaml`.
2. Либо задайте `WECHAT_BOT_TOKEN` в `.env`, либо установите `qrcode_login_enabled: true` для первичной загрузки через QR-код.
3. Когда `bot_token` отсутствует и загрузка через QR включена, следите за логами бэкенда — там появится QR-контент, возвращённый iLink, — и завершите процесс привязки.
4. После успешного прохождения QR-процесса DeerFlow сохраняет полученный токен в `state_dir` для последующих перезапусков.
5. Для развёртываний Docker Compose держите `state_dir` на постоянном томе, чтобы курсор `get_updates_buf` и сохранённое состояние аутентификации переживали перезапуски.
**Настройка WeCom**
1. Создайте бота на платформе WeCom AI Bot и получите `bot_id` и `bot_secret`.
2. Включите `channels.wecom` в `config.yaml` и заполните `bot_id` / `bot_secret`.
3. Задайте `WECOM_BOT_ID` и `WECOM_BOT_SECRET` в `.env`.
4. Убедитесь, что зависимости бэкенда включают `wecom-aibot-python-sdk`. Канал использует долговременное WebSocket-соединение и не требует публичного URL обратного вызова.
5. Текущая интеграция поддерживает входящие текстовые сообщения, изображения и файлы. Итоговые изображения/файлы, сгенерированные агентом, также отправляются обратно в диалог WeCom.
**Настройка DingTalk**
1. Создайте приложение на [DingTalk Open Platform](https://open.dingtalk.com/) и включите возможность **Робот**.
2. На странице настроек робота установите режим приёма сообщений на **Stream**.
3. Скопируйте `Client ID` и `Client Secret`. Укажите `DINGTALK_CLIENT_ID` и `DINGTALK_CLIENT_SECRET` в `.env` и включите канал в `config.yaml`.
4. *(Опционально)* Для включения потоковых ответов AI Card (эффект печатной машинки) создайте шаблон **AI Card** на [платформе карточек DingTalk](https://open.dingtalk.com/document/dingstart/typewriter-effect-streaming-ai-card), затем укажите `card_template_id` в `config.yaml` с ID шаблона. Также необходимо запросить разрешения `Card.Streaming.Write` и `Card.Instance.Write`.
**Доступные команды** **Доступные команды**
| Команда | Описание | | Команда | Описание |
@ -418,37 +311,6 @@ LANGSMITH_PROJECT=deer-flow
`LANGSMITH_ENDPOINT` по умолчанию `https://api.smith.langchain.com` и может быть переопределён при необходимости. Устаревшие переменные `LANGCHAIN_*` (`LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY` и т.д.) также поддерживаются для обратной совместимости; `LANGSMITH_*` имеет приоритет, когда заданы обе. `LANGSMITH_ENDPOINT` по умолчанию `https://api.smith.langchain.com` и может быть переопределён при необходимости. Устаревшие переменные `LANGCHAIN_*` (`LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY` и т.д.) также поддерживаются для обратной совместимости; `LANGSMITH_*` имеет приоритет, когда заданы обе.
#### Трассировка Langfuse
DeerFlow также поддерживает наблюдаемость через [Langfuse](https://langfuse.com) для запусков, совместимых с LangChain.
Добавьте в файл `.env`:
```bash
LANGFUSE_TRACING=true
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```
Если вы используете собственный экземпляр Langfuse, укажите `LANGFUSE_BASE_URL` в качестве URL вашего развёртывания.
**Поля корреляции трасс.** Каждый запуск агента аннотируется зарезервированными атрибутами трассировки Langfuse, поэтому страницы Sessions и Users заполняются автоматически:
- `session_id` = `thread_id` LangGraph — группирует все трассы одного диалога
- `user_id` = эффективный пользователь из `get_effective_user_id()` (возвращается к `default` в режиме без аутентификации)
- `trace_name` = assistant id (по умолчанию `lead-agent`)
- `tags` = `[env:<DEER_FLOW_ENV>, model:<model_name>]` (опускается, если не заданы)
- `metadata.deerflow_trace_id` = идентификатор корреляции запросов DeerFlow, совпадающий с `X-Trace-Id`, когда корреляция трассировки запросов включена
Эти поля внедряются в `RunnableConfig.metadata` в корне вызова графа как для gateway-пути (`runtime/runs/worker.py::run_agent`), так и для встроенного пути (`client.py::DeerFlowClient.stream`), поэтому любой LangChain-совместимый callback может их прочитать. Установите `DEER_FLOW_ENV` (или `ENVIRONMENT`) для тегирования трасс по среде развёртывания.
#### Использование обоих провайдеров
Если и LangSmith, и Langfuse включены, DeerFlow подключает оба callback'а трассировки и отправляет одну и ту же активность модели в обе системы.
Если провайдер явно включён, но отсутствуют необходимые учётные данные, или если его callback не может инициализироваться, DeerFlow завершает работу с ошибкой (fail fast) при инициализации трассировки во время создания модели, а сообщение об ошибке указывает провайдера, вызвавшего сбой.
В Docker-развёртываниях трассировка отключена по умолчанию. Установите `LANGSMITH_TRACING=true` и `LANGSMITH_API_KEY` в `.env` для включения. В Docker-развёртываниях трассировка отключена по умолчанию. Установите `LANGSMITH_TRACING=true` и `LANGSMITH_API_KEY` в `.env` для включения.
## От Deep Research к Super Agent Harness ## От Deep Research к Super Agent Harness
@ -505,22 +367,6 @@ npx skills add https://github.com/bytedance/deer-flow --skill claude-to-deerflow
Полный справочник API в [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md). Полный справочник API в [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md).
### Цели сессии (Session Goals)
Используйте `/goal <условие завершения>`, чтобы привязать к текущему треду одно активное условие завершения. Цель — это состояние уровня треда, а не активация навыка, поэтому она остаётся активной между ходами, пока DeerFlow не сочтёт её выполненной или пока вы её не очистите.
Поддерживаемые команды:
```text
/goal finish the implementation and make all tests pass
/goal # показать активную цель
/goal clear # очистить её
```
После каждого запуска, выполненного через Gateway, DeerFlow оценивает видимый диалог относительно активной цели с помощью non-thinking модели-оценщика. Оценщик должен вернуть типизированный блокер (`missing_evidence`, `needs_user_input`, `run_failed`, `external_wait` или `goal_not_met_yet`) с видимыми доказательствами. DeerFlow добавляет hidden continuation только тогда, когда последний ход assistant сохранён в чекпоинте, блокер имеет тип `goal_not_met_yet`, тред не изменился во время оценки и счётчик отсутствия прогресса не сработал. Предел безопасности по умолчанию — 8 hidden continuation, а повторяющиеся одинаковые оценки без прогресса останавливаются после 2 попыток. `/goal clear` и любой новый ввод от пользователя имеют приоритет над continuation в очереди. Когда цель выполнена, DeerFlow очищает её автоматически и публикует обновлённое состояние треда.
Веб-интерфейс показывает активную цель над полем ввода. Та же команда доступна из TUI и поддерживаемых IM-каналов. В веб-интерфейсе и поддерживаемых IM-каналах установка `/goal <условие завершения>` также запускает выполнение с условием в качестве задачи; команды статуса и очистки только управляют состоянием цели.
### Sub-Agents ### Sub-Agents
Сложные задачи редко решаются за один проход. DeerFlow их декомпозирует. Сложные задачи редко решаются за один проход. DeerFlow их декомпозирует.
@ -568,7 +414,7 @@ DeerFlow работает с любым LLM через OpenAI-совместим
## Встроенный Python-клиент ## Встроенный Python-клиент
DeerFlow можно использовать как Python-библиотеку прямо в коде — без запуска HTTP-сервисов. `DeerFlowClient` даёт доступ ко всем возможностям агента и Gateway, возвращает те же схемы ответов, что и HTTP Gateway API. HTTP Gateway также предоставляет `DELETE /api/threads/{thread_id}` для удаления локальных данных треда, управляемых DeerFlow, после того как сам LangGraph thread был удалён: DeerFlow можно использовать как Python-библиотеку прямо в коде — без запуска HTTP-сервисов. `DeerFlowClient` даёт доступ ко всем возможностям агента и Gateway, возвращает те же схемы ответов, что и HTTP Gateway API:
```python ```python
from deerflow.client import DeerFlowClient from deerflow.client import DeerFlowClient
@ -588,54 +434,8 @@ models = client.list_models() # {"models": [...]}
skills = client.list_skills() # {"skills": [...]} skills = client.list_skills() # {"skills": [...]}
client.update_skill("web-search", enabled=True) client.update_skill("web-search", enabled=True)
client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]} client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]}
client.set_goal("thread-1", "finish the implementation and make all tests pass")
client.get_goal("thread-1") # {"goal": {...}} or {"goal": None}
client.clear_goal("thread-1")
``` ```
## Запланированные задачи (Scheduled Tasks)
Теперь в DeerFlow есть первоклассный MVP запланированных задач (scheduled-task) в workspace.
Текущие возможности MVP:
- Управление задачами на `/workspace/scheduled-tasks`
- Выбор: каждая запланированная задача переиспользует тред или создаёт новый тред для каждого запуска
- Поддержка расписаний `once` и `cron`
- Фоновые запланированные запуски выполняются как неинтерактивные запуски DeerFlow (`ask_clarification` там не предоставляется)
- При совпадении наступившего cron-запуска с активным запуском на том же переиспользуемом треде применяется поведение перекрытия `skip`
- Приостановка, возобновление, ручной запуск, просмотр истории и удаление задач
- Запланированные задачи выполняются через стандартный жизненный цикл запуска DeerFlow
Текущие ограничения MVP:
- Пока нет инструмента `schedule_task`, создающего задачи в диалоге
- Нет заданий с текстовыми уведомлениями
- Нет каналов или целей отправки GitHub
- В этой первой версии нет типа расписания `interval`
Включите фоновый опрос через `config.yaml -> scheduler.enabled`. Ручной запуск использует тот же ресурс и путь выполнения scheduled-task.
## Терминальная панель (TUI)
`deerflow` — это нативная терминальная панель для тех, кто живёт в шелле. Она работает **встроенной** поверх `DeerFlowClient` — без Gateway, фронтенда, nginx или Docker — и при этом учитывает те же настройки `config.yaml`, checkpointer, skills, memory, MCP и sandbox, что и остальной DeerFlow.
![DeerFlow TUI](docs/tui/tui-preview.svg)
```bash
uv pip install 'deerflow-harness[tui]' # опциональная зависимость 'textual'
deerflow # запустить терминальный UI (требуется TTY)
deerflow --continue # возобновить последний тред
deerflow --resume THREAD # возобновить тред по id
deerflow --print "summarize this repo" # автономный разовый ответ в stdout
deerflow --json "hello" # автономный режим, StreamEvents с разделением новой строкой
```
Интерфейс чата с управлением с клавиатуры: потоковый транскрипт (ответы рендерятся в Markdown), компактные карточки активности инструментов, палитра слэш-команд `/`, управление целями `/goal`, селекторы `/model` и `/threads`, история ввода, а также прерывание через `Esc` / `Ctrl+C`. Сессии, открытые в TUI, также появляются в боковой панели веб-интерфейса — TUI пишет в общее хранилище тредов под локальным пользователем по умолчанию, поэтому терминал и веб остаются синхронизированными **без запуска Gateway**.
Полное руководство — в [backend/docs/TUI.md](backend/docs/TUI.md).
## Документация ## Документация
- [Руководство по участию](CONTRIBUTING.md) — настройка среды разработки, воркфлоу и гайдлайны - [Руководство по участию](CONTRIBUTING.md) — настройка среды разработки, воркфлоу и гайдлайны

View File

@ -18,29 +18,22 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
## 官网 ## 官网
[<img width="2880" height="1600" alt="image" src="https://github.com/user-attachments/assets/a598c49f-3b2f-41ea-a052-05e21349188a" />](https://deerflow.tech)
想了解更多,或者直接看**真实演示**,可以访问[**官网**](https://deerflow.tech)。 想了解更多,或者直接看**真实演示**,可以访问[**官网**](https://deerflow.tech)。
## 字节跳动火山引擎方舟 Coding Plan ## 字节跳动火山引擎方舟 Coding Plan
[<img width="4808" height="2400" alt="codingplan -banner 素材" src="https://github.com/user-attachments/assets/d30dae52-84f2-4021-b32f-6d281252b9ea" />](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
- 我们推荐使用 Doubao-Seed-2.0-Code、DeepSeek v3.2 和 Kimi 2.5 运行 DeerFlow - 我们推荐使用 Doubao-Seed-2.0-Code、DeepSeek v3.2 和 Kimi 2.5 运行 DeerFlow
- [现在就加入 Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow) - [现在就加入 Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
- [海外地区的开发者请点击这里](https://www.byteplus.com/en/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow) - [海外地区的开发者请点击这里](https://www.byteplus.com/en/activity/codingplan?utm_campaign=deer_flow&utm_content=deer_flow&utm_medium=devrel&utm_source=OWO&utm_term=deer_flow)
## InfoQuest
DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[InfoQuest支持免费在线体验](https://docs.byteplus.com/en/docs/InfoQuest/What_is_Info_Quest)
<a href="https://docs.byteplus.com/en/docs/InfoQuest/What_is_Info_Quest" target="_blank">
<img
src="https://sf16-sg.tiktokcdn.com/obj/eden-sg/hubseh7bsbps/20251208-160108.png" alt="InfoQuest_banner"
/>
</a>
## 目录 ## 目录
- [🦌 DeerFlow - 2.0](#-deerflow---20) - [🦌 DeerFlow - 2.0](#-deerflow---20)
- [官网](#官网) - [官网](#官网)
- [字节跳动火山引擎方舟 Coding Plan](#字节跳动火山引擎方舟-coding-plan)
- [InfoQuest](#infoquest) - [InfoQuest](#infoquest)
- [目录](#目录) - [目录](#目录)
- [一句话交给 Coding Agent 安装](#一句话交给-coding-agent-安装) - [一句话交给 Coding Agent 安装](#一句话交给-coding-agent-安装)
@ -55,22 +48,16 @@ DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[
- [MCP Server](#mcp-server) - [MCP Server](#mcp-server)
- [IM 渠道](#im-渠道) - [IM 渠道](#im-渠道)
- [LangSmith 链路追踪](#langsmith-链路追踪) - [LangSmith 链路追踪](#langsmith-链路追踪)
- [Langfuse 链路追踪](#langfuse-链路追踪)
- [同时使用两种追踪服务](#同时使用两种追踪服务)
- [从 Deep Research 到 Super Agent Harness](#从-deep-research-到-super-agent-harness) - [从 Deep Research 到 Super Agent Harness](#从-deep-research-到-super-agent-harness)
- [核心特性](#核心特性) - [核心特性](#核心特性)
- [Skills 与 Tools](#skills-与-tools) - [Skills 与 Tools](#skills-与-tools)
- [Claude Code 集成](#claude-code-集成) - [Claude Code 集成](#claude-code-集成)
- [Session Goals](#session-goals)
- [手动上下文压缩](#手动上下文压缩)
- [Sub-Agents](#sub-agents) - [Sub-Agents](#sub-agents)
- [Sandbox 与文件系统](#sandbox-与文件系统) - [Sandbox 与文件系统](#sandbox-与文件系统)
- [Context Engineering](#context-engineering) - [Context Engineering](#context-engineering)
- [长期记忆](#长期记忆) - [长期记忆](#长期记忆)
- [推荐模型](#推荐模型) - [推荐模型](#推荐模型)
- [内嵌 Python Client](#内嵌-python-client) - [内嵌 Python Client](#内嵌-python-client)
- [定时任务 (Scheduled Tasks)](#定时任务-scheduled-tasks)
- [终端工作台 (TUI)](#终端工作台-tui)
- [文档](#文档) - [文档](#文档)
- [⚠️ 安全使用](#-安全使用) - [⚠️ 安全使用](#-安全使用)
- [参与贡献](#参与贡献) - [参与贡献](#参与贡献)
@ -100,111 +87,68 @@ DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[
cd deer-flow cd deer-flow
``` ```
2. **运行安装向导(推荐)** 2. **生成本地配置文件**
在项目根目录(`deer-flow/`)执行: 在项目根目录(`deer-flow/`)执行:
```bash ```bash
make setup make config
``` ```
会启动一个交互式向导,引导你选择 LLM provider、可选的 web 搜索工具,以及 sandbox 模式、bash 权限、文件写入等执行/安全偏好。它会生成一份最小化的 `config.yaml`,并把 API key 写入 `.env`,大约 2 分钟完成 个命令会基于示例模板生成本地配置文件
随时可以运行 `make doctor` 检查配置和系统环境,并获得可执行的修复建议。 3. **配置你要使用的模型**
如果你要提交本地安装、配置或运行问题,可以执行 `make support-bundle`
命令会直接打印 reporter 下一步建议,并在 `.deer-flow/support-bundles/` 下生成
`*-issue-summary.md`、面向 AI 辅助提 issue 的 `*-issue-draft.md`,以及可选证据
zip。提交 GitHub issue 时,先把 `*-issue-summary.md` 粘贴到 issue 正文;如果由
AI 助手代填 issue就从 `*-issue-draft.md` 开始,并先替换所有 REQUIRED 占位符,
不要编造未知事实。只有维护者要求证据包,或摘要不足以诊断时,再附上 zip。维护者
或 AI 辅助 triage 可以优先读取 `triage.json`bundle 只包含脱敏后的诊断信息和
文件 manifest不包含 `.env`、原始对话消息或用户文件内容;提交前仍建议自己快速
检查一遍。
> **进阶 / 手动配置**:如果你更想直接编辑 `config.yaml`,可以改用 `make config` 复制完整的示例模板。完整参考见 `config.example.yaml`,其中包含 CLI-backed providerCodex CLI、Claude Code OAuth、OpenRouter、Responses API 等更多配置。 编辑 `config.yaml`,至少定义一个模型:
<details>
<summary>手动模型配置示例</summary>
```yaml ```yaml
models: models:
- name: gpt-4o - name: gpt-4 # 内部标识
display_name: GPT-4o display_name: GPT-4 # 展示名称
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI # LangChain 类路径
model: gpt-4o model: gpt-4 # API 使用的模型标识
api_key: $OPENAI_API_KEY api_key: $OPENAI_API_KEY # API key推荐使用环境变量
max_tokens: 4096 # 单次请求最大 tokens
temperature: 0.7 # 采样温度
- name: openrouter-gemini-2.5-flash - name: openrouter-gemini-2.5-flash
display_name: Gemini 2.5 Flash (OpenRouter) display_name: Gemini 2.5 Flash (OpenRouter)
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
model: google/gemini-2.5-flash-preview model: google/gemini-2.5-flash-preview
api_key: $OPENROUTER_API_KEY api_key: $OPENAI_API_KEY # 这里 OpenRouter 依然沿用 OpenAI 兼容字段名
base_url: https://openrouter.ai/api/v1 base_url: https://openrouter.ai/api/v1
- name: gpt-5-responses
display_name: GPT-5 (Responses API)
use: langchain_openai:ChatOpenAI
model: gpt-5
api_key: $OPENAI_API_KEY
use_responses_api: true
output_version: responses/v1
- name: qwen3-32b-vllm
display_name: Qwen3 32B (vLLM)
use: deerflow.models.vllm_provider:VllmChatModel
model: Qwen/Qwen3-32B
api_key: $VLLM_API_KEY
base_url: http://localhost:8000/v1
supports_thinking: true
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
``` ```
OpenRouter 以及类似的 OpenAI 兼容网关,建议通过 `langchain_openai:ChatOpenAI` 配合 `base_url` 来配置。如果你更想用 provider 自己的环境变量名,也可以直接把 `api_key` 指向对应变量,例如 `api_key: $OPENROUTER_API_KEY` OpenRouter 以及类似的 OpenAI 兼容网关,建议通过 `langchain_openai:ChatOpenAI` 配合 `base_url` 来配置。如果你更想用 provider 自己的环境变量名,也可以直接把 `api_key` 指向对应变量,例如 `api_key: $OPENROUTER_API_KEY`
如果要让 OpenAI 模型走 `/v1/responses`,继续使用 `langchain_openai:ChatOpenAI`,并设置 `use_responses_api: true``output_version: responses/v1` 4. **为已配置的模型设置 API key**
对于 vLLM 0.19.0,请使用 `deerflow.models.vllm_provider:VllmChatModel`。对于 Qwen 风格的推理模型DeerFlow 通过 `extra_body.chat_template_kwargs.enable_thinking` 开关推理,并在多轮 tool-call 对话中保留 vLLM 非标准的 `reasoning` 字段。旧版 `thinking` 配置会自动规范化以保持向后兼容。推理模型可能还需要在启动 vLLM 服务时加上 `--reasoning-parser ...` 参数。如果你的本地 vLLM 部署接受任意非空 API key可以把 `VLLM_API_KEY` 设为一个占位值。 可任选以下一种方式:
CLI-backed provider 配置示例: - 方式 A编辑项目根目录下的 `.env` 文件(推荐)
```bash
TAVILY_API_KEY=your-tavily-api-key
OPENAI_API_KEY=your-openai-api-key
# 如果配置使用的是 langchain_openai:ChatOpenAI + base_urlOpenRouter 也会读取 OPENAI_API_KEY
# 其他 provider 的 key 按需补充
INFOQUEST_API_KEY=your-infoquest-api-key
```
- 方式 B在 shell 中导出环境变量
```bash
export OPENAI_API_KEY=your-openai-api-key
```
- 方式 C直接编辑 `config.yaml`(不建议用于生产环境)
```yaml ```yaml
models: models:
- name: gpt-5.4 - name: gpt-4
display_name: GPT-5.4 (Codex CLI) api_key: your-actual-api-key-here # 替换为真实 key
use: deerflow.models.openai_codex_provider:CodexChatModel
model: gpt-5.4
supports_thinking: true
supports_reasoning_effort: true
- name: claude-sonnet-4.6
display_name: Claude Sonnet 4.6 (Claude Code OAuth)
use: deerflow.models.claude_provider:ClaudeChatModel
model: claude-sonnet-4-6
max_tokens: 4096
supports_thinking: true
``` ```
- Codex CLI 会读取 `~/.codex/auth.json`
- Claude Code 支持 `CLAUDE_CODE_OAUTH_TOKEN``ANTHROPIC_AUTH_TOKEN``CLAUDE_CODE_CREDENTIALS_PATH`,或 `~/.claude/.credentials.json`
- ACP agent 条目与 model provider 是分开配置的——如果你配置了 `acp_agents.codex`,请把它指向一个 Codex ACP 适配器,例如 `npx -y @zed-industries/codex-acp`
- 在 macOS 上,如有需要可显式导出 Claude Code 的认证信息:
```bash
eval "$(python3 scripts/export_claude_code_oauth.py --print-export)"
```
API key 也可以手动写入 `.env` 文件(推荐)或在 shell 中导出:
```bash
OPENAI_API_KEY=your-openai-api-key
TAVILY_API_KEY=your-tavily-api-key
```
</details>
### 运行应用 ### 运行应用
#### 部署建议与资源规划 #### 部署建议与资源规划
@ -240,7 +184,7 @@ make down # 停止并移除容器
``` ```
> [!NOTE] > [!NOTE]
> 当前 Agent 运行时嵌入在 Gateway 中运行,`/api/langgraph/*` 会由 nginx 重写到 Gateway 的 LangGraph-compatible API > 当前 LangGraph agent server 通过开源 CLI 服务 `langgraph dev` 运行
访问地址http://localhost:2026 访问地址http://localhost:2026
@ -250,7 +194,7 @@ make down # 停止并移除容器
如果你更希望直接在本地启动各个服务: 如果你更希望直接在本地启动各个服务:
前提:先完成上面的“配置”步骤(`make setup`)。`make dev` 需要有效配置文件,默认读取项目根目录下的 `config.yaml`。可以用 `DEER_FLOW_PROJECT_ROOT` 显式指定项目根目录,也可以用 `DEER_FLOW_CONFIG_PATH` 指向某个具体配置文件。运行期状态默认写到项目根目录下的 `.deer-flow`,可用 `DEER_FLOW_HOME` 覆盖skills 默认读取项目根目录下的 `skills/`,可用 `DEER_FLOW_SKILLS_PATH` 覆盖。启动前先运行 `make doctor` 校验配置 前提:先完成上面的“配置”步骤(`make config` 和模型 API key 配置)。`make dev` 需要有效配置文件,默认读取项目根目录下的 `config.yaml`,也可以通过 `DEER_FLOW_CONFIG_PATH` 覆盖
在 Windows 上,请使用 Git Bash 运行本地开发流程。基于 bash 的服务脚本不支持直接在原生 `cmd.exe` 或 PowerShell 中执行,且 WSL 也不保证可用,因为部分脚本依赖 Git for Windows 的 `cygpath` 等工具。 在 Windows 上,请使用 Git Bash 运行本地开发流程。基于 bash 的服务脚本不支持直接在原生 `cmd.exe` 或 PowerShell 中执行,且 WSL 也不保证可用,因为部分脚本依赖 Git for Windows 的 `cygpath` 等工具。
1. **检查依赖环境** 1. **检查依赖环境**
@ -298,23 +242,19 @@ DeerFlow 支持可配置的 MCP Server 和 skills用来扩展能力。
DeerFlow 支持从即时通讯应用接收任务。只要配置完成,对应渠道会自动启动,而且都不需要公网 IP。 DeerFlow 支持从即时通讯应用接收任务。只要配置完成,对应渠道会自动启动,而且都不需要公网 IP。
DeerFlow 还可以在 workspace UI 里暴露用户自有的 IM 渠道连接。启用 `channel_connections` 后,已登录用户可以从侧边栏 / Settings > Channels 绑定 Telegram、Slack、Discord、Feishu/Lark、DingTalk、WeChat 或 WeCom。它复用现有的 `channels.*` 出站传输,因此不需要公网 IP 或 provider 回调地址。入站 IM 消息会以所连接的 DeerFlow 用户身份运行。设置和安全注意事项参见 [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md)。
| 渠道 | 传输方式 | 上手难度 | | 渠道 | 传输方式 | 上手难度 |
|---------|-----------|------------| |---------|-----------|------------|
| Telegram | Bot APIlong-polling | 简单 | | Telegram | Bot APIlong-polling | 简单 |
| Slack | Socket Mode | 中等 | | Slack | Socket Mode | 中等 |
| Feishu / Lark | WebSocket | 中等 | | Feishu / Lark | WebSocket | 中等 |
| WeChat | Tencent iLinklong-polling | 中等 |
| 企业微信智能机器人 | WebSocket | 中等 | | 企业微信智能机器人 | WebSocket | 中等 |
| 钉钉 | Stream PushWebSocket | 中等 |
**`config.yaml` 中的配置示例:** **`config.yaml` 中的配置示例:**
```yaml ```yaml
channels: channels:
# LangGraph-compatible Gateway API base URL默认http://localhost:8001/api # LangGraph Server URL默认http://localhost:2024
langgraph_url: http://localhost:8001/api langgraph_url: http://localhost:2024
# Gateway API URL默认http://localhost:8001 # Gateway API URL默认http://localhost:8001
gateway_url: http://localhost:8001 gateway_url: http://localhost:8001
@ -364,26 +304,6 @@ channels:
context: context:
thinking_enabled: true thinking_enabled: true
subagent_enabled: true subagent_enabled: true
wechat:
enabled: false
bot_token: $WECHAT_BOT_TOKEN
ilink_bot_id: $WECHAT_ILINK_BOT_ID
qrcode_login_enabled: true # 可选bot_token 缺失时允许首次扫码登录引导
allowed_users: [] # 留空表示允许所有人
polling_timeout: 35
state_dir: ./.deer-flow/wechat/state
max_inbound_image_bytes: 20971520
max_outbound_image_bytes: 20971520
max_inbound_file_bytes: 52428800
max_outbound_file_bytes: 52428800
dingtalk:
enabled: true
client_id: $DINGTALK_CLIENT_ID # 钉钉开放平台 ClientId
client_secret: $DINGTALK_CLIENT_SECRET # 钉钉开放平台 ClientSecret
allowed_users: [] # 留空表示允许所有人
card_template_id: "" # 可选AI 卡片模板 ID用于流式打字机效果
``` ```
说明: 说明:
@ -404,24 +324,15 @@ SLACK_APP_TOKEN=xapp-...
FEISHU_APP_ID=cli_xxxx FEISHU_APP_ID=cli_xxxx
FEISHU_APP_SECRET=your_app_secret FEISHU_APP_SECRET=your_app_secret
# WeChat iLink
WECHAT_BOT_TOKEN=your_ilink_bot_token
WECHAT_ILINK_BOT_ID=your_ilink_bot_id
# 企业微信智能机器人 # 企业微信智能机器人
WECOM_BOT_ID=your_bot_id WECOM_BOT_ID=your_bot_id
WECOM_BOT_SECRET=your_bot_secret WECOM_BOT_SECRET=your_bot_secret
# 钉钉
DINGTALK_CLIENT_ID=your_client_id
DINGTALK_CLIENT_SECRET=your_client_secret
``` ```
**Telegram 配置** **Telegram 配置**
1. 打开 [@BotFather](https://t.me/BotFather),发送 `/newbot`,复制生成的 HTTP API token。 1. 打开 [@BotFather](https://t.me/BotFather),发送 `/newbot`,复制生成的 HTTP API token。
2. 在 `.env` 中设置 `TELEGRAM_BOT_TOKEN`,并在 `config.yaml` 里启用该渠道。 2. 在 `.env` 中设置 `TELEGRAM_BOT_TOKEN`,并在 `config.yaml` 里启用该渠道。
3. 机器人支持接收入站文本、图片和文档(可带说明文字,也可不带);托管版 Bot API 的单个附件下载上限为 20 MB。
**Slack 配置** **Slack 配置**
@ -438,14 +349,6 @@ DINGTALK_CLIENT_SECRET=your_client_secret
3. 在 **事件订阅** 中订阅 `im.message.receive_v1`,连接方式选择 **长连接** 3. 在 **事件订阅** 中订阅 `im.message.receive_v1`,连接方式选择 **长连接**
4. 复制 App ID 和 App Secret`.env` 中设置 `FEISHU_APP_ID``FEISHU_APP_SECRET`,并在 `config.yaml` 中启用该渠道。 4. 复制 App ID 和 App Secret`.env` 中设置 `FEISHU_APP_ID``FEISHU_APP_SECRET`,并在 `config.yaml` 中启用该渠道。
**WeChat 配置**
1. 在 `config.yaml` 中启用 `wechat` 渠道。
2. 在 `.env` 中设置 `WECHAT_BOT_TOKEN`,或者把 `qrcode_login_enabled` 设为 `true` 以便首次扫码登录引导。
3. 当 `bot_token` 缺失且启用了扫码引导时,留意后端日志里 iLink 返回的二维码内容,并完成绑定流程。
4. 扫码流程成功后DeerFlow 会把获取到的 token 持久化到 `state_dir`,便于后续重启复用。
5. Docker Compose 部署时,请把 `state_dir` 放在持久化卷上,这样 `get_updates_buf` 游标和已保存的登录状态才能在重启后保留。
**企业微信智能机器人配置** **企业微信智能机器人配置**
1. 在企业微信智能机器人平台创建机器人,获取 `bot_id``bot_secret` 1. 在企业微信智能机器人平台创建机器人,获取 `bot_id``bot_secret`
@ -454,13 +357,6 @@ DINGTALK_CLIENT_SECRET=your_client_secret
4. 安装后端依赖时确保包含 `wecom-aibot-python-sdk`,渠道会通过 WebSocket 长连接接收消息,无需公网回调地址。 4. 安装后端依赖时确保包含 `wecom-aibot-python-sdk`,渠道会通过 WebSocket 长连接接收消息,无需公网回调地址。
5. 当前支持文本、图片和文件入站消息agent 生成的最终图片/文件也会回传到企业微信会话中。 5. 当前支持文本、图片和文件入站消息agent 生成的最终图片/文件也会回传到企业微信会话中。
**钉钉配置**
1. 在 [钉钉开放平台](https://open.dingtalk.com/) 创建应用,并启用 **机器人** 能力。
2. 在机器人配置页面设置消息接收模式为 **Stream模式**
3. 复制 `Client ID``Client Secret`,在 `.env` 中设置 `DINGTALK_CLIENT_ID``DINGTALK_CLIENT_SECRET`,并在 `config.yaml` 中启用该渠道。
4. *(可选)* 如需开启流式 AI 卡片回复(打字机效果),请在[钉钉卡片平台](https://open.dingtalk.com/document/dingstart/typewriter-effect-streaming-ai-card)创建 **AI 卡片**模板,然后在 `config.yaml` 中将 `card_template_id` 设为该模板 ID。同时需要申请 `Card.Streaming.Write``Card.Instance.Write` 权限。
**命令** **命令**
渠道连接完成后,你可以直接在聊天窗口里和 DeerFlow 交互: 渠道连接完成后,你可以直接在聊天窗口里和 DeerFlow 交互:
@ -488,37 +384,6 @@ LANGSMITH_API_KEY=lsv2_pt_xxxxxxxxxxxxxxxx
LANGSMITH_PROJECT=xxx LANGSMITH_PROJECT=xxx
``` ```
#### Langfuse 链路追踪
DeerFlow 同样支持 [Langfuse](https://langfuse.com) 可观测性,适用于兼容 LangChain 的运行。
`.env` 文件中添加以下配置:
```bash
LANGFUSE_TRACING=true
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxxxxxxxxxx
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```
如果你使用自托管的 Langfuse 实例,请将 `LANGFUSE_BASE_URL` 设置为你的部署地址。
**链路关联字段。** 每次 agent 运行都会标注 Langfuse 的保留追踪属性,这样 Sessions 和 Users 页面就能自动填充数据:
- `session_id` = LangGraph 的 `thread_id`——将同一会话的所有 trace 归为一组
- `user_id` = 来自 `get_effective_user_id()` 的有效用户(在无鉴权模式下回退为 `default`
- `trace_name` = assistant id默认为 `lead-agent`
- `tags` = `[env:<DEER_FLOW_ENV>, model:<model_name>]`(未设置时省略)
- `metadata.deerflow_trace_id` = DeerFlow 的请求关联 id当启用请求链路关联request trace correlation时与 `X-Trace-Id` 一致
这些字段会在图graph调用的根部注入到 `RunnableConfig.metadata`,同时覆盖 gateway 路径(`runtime/runs/worker.py::run_agent`)和内嵌路径(`client.py::DeerFlowClient.stream`),因此任何兼容 LangChain 的 callback 都能读取到它们。设置 `DEER_FLOW_ENV`(或 `ENVIRONMENT`)可按部署环境为 trace 打标签。
#### 同时使用两种追踪服务
如果同时启用 LangSmith 和 LangfuseDeerFlow 会挂载两个追踪 callback并将相同的模型活动上报到两个系统。
如果某个 provider 被显式启用但缺少必要的凭据,或其 callback 初始化失败DeerFlow 会在创建模型、初始化追踪时快速失败fail fast错误信息会指明导致失败的 provider。
Docker 部署时,追踪默认关闭。在 `.env` 中设置 `LANGSMITH_TRACING=true``LANGSMITH_API_KEY` 即可启用。 Docker 部署时,追踪默认关闭。在 `.env` 中设置 `LANGSMITH_TRACING=true``LANGSMITH_API_KEY` 即可启用。
## 从 Deep Research 到 Super Agent Harness ## 从 Deep Research 到 Super Agent Harness
@ -545,12 +410,10 @@ Skills 采用按需渐进加载,不会一次性把所有内容都塞进上下
通过 Gateway 安装 `.skill` 压缩包时DeerFlow 会接受标准的可选 frontmatter 元数据,比如 `version``author``compatibility`,不会把本来合法的外部 skill 拒之门外。 通过 Gateway 安装 `.skill` 压缩包时DeerFlow 会接受标准的可选 frontmatter 元数据,比如 `version``author``compatibility`,不会把本来合法的外部 skill 拒之门外。
Tools 也是同样的思路。DeerFlow 自带一组核心工具:网页搜索、网页抓取、网页渲染截图、文件操作、bash 执行;同时也支持通过 MCP Server 和 Python 函数扩展自定义工具。你可以替换任何一项,也可以继续往里加。 Tools 也是同样的思路。DeerFlow 自带一组核心工具网页搜索、网页抓取、文件操作、bash 执行;同时也支持通过 MCP Server 和 Python 函数扩展自定义工具。你可以替换任何一项,也可以继续往里加。
Gateway 生成后续建议时,现在会先把普通字符串输出和 block/list 风格的富文本内容统一归一化,再去解析 JSON 数组响应,因此不同 provider 的内容包装方式不会再悄悄把建议吞掉。 Gateway 生成后续建议时,现在会先把普通字符串输出和 block/list 风格的富文本内容统一归一化,再去解析 JSON 数组响应,因此不同 provider 的内容包装方式不会再悄悄把建议吞掉。
Web UI 支持从已完成的 assistant 回复分叉出一个新的主对话。新 thread 会保留该轮回复的 checkpoint 以及用户消息之前的重放 checkpoint因此分叉后可以立即重新生成该回复。对于缺少 checkpoint 父链接的旧历史或导入历史Gateway 会进行有界的时间顺序查找;如果不存在更早的重放 checkpoint分叉仍会按旧版单-checkpoint 形态成功创建,但无法重新生成继承的回复。已有的单-checkpoint 分叉会保持不变,不会通过不安全的 checkpoint 复制尝试修复。只有从最新回合分叉时才会尽力复制当前 thread 的工作区文件;从历史回合分叉不会带入后续时间线创建的文件。
```text ```text
# sandbox 容器内的路径 # sandbox 容器内的路径
/mnt/skills/public /mnt/skills/public
@ -593,28 +456,6 @@ DEERFLOW_LANGGRAPH_URL=http://localhost:2026/api/langgraph # LangGraph API
完整 API 说明见 [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md)。 完整 API 说明见 [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md)。
Web UI 输入框支持浏览器侧语音听写。浏览器提供 Web Speech API 时麦克风按钮会把语音转写为本地草稿DeerFlow 只接收转写后的文本,音频处理交由浏览器或操作系统语音识别服务按其环境策略完成。用户可以在发送前继续检查和编辑文本。
### Session Goals
`/goal <完成条件>` 为当前 thread 绑定一个激活态的完成条件。这个 goal 是 thread 维度的状态,而不是技能激活,所以它会跨轮次持续生效,直到 DeerFlow 判定它已被满足、或者你手动清除它。
支持的命令:
```text
/goal finish the implementation and make all tests pass
/goal # 查看当前激活的 goal
/goal clear # 清除它
```
每次 Gateway 驱动的 run 结束后DeerFlow 会用一个 non-thinking 的评估模型,把可见的对话内容拿去和激活的 goal 比对。评估模型必须返回一个带类型的 blocker`missing_evidence``needs_user_input``run_failed``external_wait``goal_not_met_yet`),并附上可见证据。只有在最近一轮 assistant 回复已被持久化 checkpoint、blocker 是 `goal_not_met_yet`、评估期间 thread 没有变化、且无进展熔断器没有触发时DeerFlow 才会注入一次 hidden continuation。安全上限默认是 8 次 hidden continuation连续两次相同的无进展评估后就会停止。`/goal clear` 以及任何用户手动输入的新内容,优先级都高于排队中的 continuation。当 goal 被满足时DeerFlow 会自动清除它,并发布更新后的 thread 状态。
Web UI 会在输入框上方展示当前激活的 goal。同样的命令在 TUI 和受支持的 IM 渠道里也可用。在 Web UI 和受支持的 IM 渠道里,设置 `/goal <完成条件>` 还会以该条件作为任务启动一次 run状态查询和清除命令则只管理 goal 状态本身。
### 手动上下文压缩
在 Web UI 输入框中使用 `/compact`,可以把当前 thread 的早期上下文压缩成摘要。完整聊天记录仍会保留在界面上但后续模型调用会基于压缩摘要和最近消息继续。当前历史不足时不会压缩thread 正在运行任务时会阻止压缩。
### Sub-Agents ### Sub-Agents
复杂任务通常不可能一次完成DeerFlow 会先拆解,再执行。 复杂任务通常不可能一次完成DeerFlow 会先拆解,再执行。
@ -662,7 +503,7 @@ DeerFlow 对模型没有强绑定,只要实现了 OpenAI 兼容 API 的 LLM
## 内嵌 Python Client ## 内嵌 Python Client
DeerFlow 也可以作为内嵌的 Python 库使用,不必启动完整的 HTTP 服务。`DeerFlowClient` 提供了进程内的直接访问方式,覆盖所有 agent 和 Gateway 能力,返回的数据结构与 HTTP Gateway API 保持一致。HTTP Gateway 还提供 `DELETE /api/threads/{thread_id}`,用于在 LangGraph thread 本身被删除之后,清理 DeerFlow 托管的本地 thread 数据 DeerFlow 也可以作为内嵌的 Python 库使用,不必启动完整的 HTTP 服务。`DeerFlowClient` 提供了进程内的直接访问方式,覆盖所有 agent 和 Gateway 能力,返回的数据结构与 HTTP Gateway API 保持一致:
```python ```python
from deerflow.client import DeerFlowClient from deerflow.client import DeerFlowClient
@ -682,56 +523,10 @@ models = client.list_models() # {"models": [...]}
skills = client.list_skills() # {"skills": [...]} skills = client.list_skills() # {"skills": [...]}
client.update_skill("web-search", enabled=True) client.update_skill("web-search", enabled=True)
client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]} client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files": [...]}
client.set_goal("thread-1", "finish the implementation and make all tests pass")
client.get_goal("thread-1") # {"goal": {...}} or {"goal": None}
client.clear_goal("thread-1")
``` ```
所有返回 dict 的方法都会在 CI 中通过 Gateway 的 Pydantic 响应模型校验(`TestGatewayConformance`),以确保内嵌 client 始终和 HTTP API schema 保持同步。完整 API 说明见 `backend/packages/harness/deerflow/client.py` 所有返回 dict 的方法都会在 CI 中通过 Gateway 的 Pydantic 响应模型校验(`TestGatewayConformance`),以确保内嵌 client 始终和 HTTP API schema 保持同步。完整 API 说明见 `backend/packages/harness/deerflow/client.py`
## 定时任务 (Scheduled Tasks)
DeerFlow 现在在 workspace 里内置了一个一等的定时任务scheduled-taskMVP。
当前 MVP 能力:
- 在 `/workspace/scheduled-tasks` 管理任务
- 每个定时任务可以选择复用同一个 thread也可以选择每次运行新建一个 thread
- 支持 `once``cron` 两种调度方式
- 后台定时执行以非交互式 DeerFlow run 运行(那里不会暴露 `ask_clarification`
- 当到期的 cron 执行与同一复用 thread 上的活跃 run 冲突时,采用 `skip` 的重叠处理策略
- 支持暂停、恢复、手动触发、查看历史和删除任务
- 定时任务通过正常的 DeerFlow run 生命周期执行
当前 MVP 限制:
- 暂时还没有可在对话中创建任务的 `schedule_task` 工具
- 没有纯文本通知任务
- 没有渠道或 GitHub 分发目标
- 第一版没有 `interval` 调度类型
通过 `config.yaml -> scheduler.enabled` 开启后台轮询。手动触发使用同样的 scheduled-task 资源和执行路径。
## 终端工作台 (TUI)
`deerflow` 是一个面向终端用户的工作台,**内嵌**运行在 `DeerFlowClient` 之上——无需启动 Gateway、前端、nginx 或 Docker同时沿用与 DeerFlow 其它部分相同的 `config.yaml`、checkpointer、技能、记忆、MCP 和沙箱配置。
![DeerFlow TUI](docs/tui/tui-preview.svg)
```bash
uv pip install 'deerflow-harness[tui]' # 可选的 'textual' 依赖
deerflow # 启动终端 UI需要 TTY
deerflow --continue # 恢复最近一次会话
deerflow --resume THREAD # 按 id 恢复指定会话
deerflow --print "总结一下这个仓库" # 无头模式,结果打印到 stdout
deerflow --json "hello" # 无头模式,输出按行分隔的 StreamEvent
```
键盘驱动的对话界面:流式渲染的对话区(回答按 Markdown 渲染)、紧凑的工具活动卡片、`/` 斜杠命令面板、`/model``/threads` 选择器、输入历史,以及 `Esc` / `Ctrl+C` 打断。在 TUI 里开启的会话也会出现在 Web UI 侧边栏——它会以本地默认用户身份写入共享的会话存储,因此终端与网页保持同步,**无需运行 Gateway**。
完整说明见 [backend/docs/TUI.md](backend/docs/TUI.md)。
## 文档 ## 文档
- [贡献指南](CONTRIBUTING.md) - 开发环境搭建与协作流程 - [贡献指南](CONTRIBUTING.md) - 开发环境搭建与协作流程

View File

@ -1,168 +0,0 @@
# Releasing DeerFlow
DeerFlow releases are **tag-driven**: pushing a `v*` git tag triggers the
publishing workflows. There is no separate release script that bumps versions —
the maintainer bumps the version sources, updates the changelog, commits, and
tags. The helper scripts below keep the version sources in lockstep, and CI
gates the release on them agreeing with the tag.
## Version sources
A release version must appear, identically, in four places:
| File | Field |
| -------------------------------------- | -------------------- |
| `backend/pyproject.toml` | `version = "X.Y.Z"` |
| `frontend/package.json` | `"version": "X.Y.Z"` |
| `deploy/helm/deer-flow/Chart.yaml` | `version: X.Y.Z` |
| `deploy/helm/deer-flow/Chart.yaml` | `appVersion: "X.Y.Z"`|
Plus the git tag `vX.Y.Z` itself, which is the canonical release identifier.
Container images are tagged from the git tag (not from these files), and the
Helm chart version is validated against the tag — so if any source lags the
tag, the release is blocked (see [Version gate](#version-gate)).
The frontend's in-app About page (Settings ▸ About) is a *derived* consumer, not
a fifth source: it reads `frontend/package.json`'s version at build time, so it
tracks the table above automatically with no bump needed. Nightly builds override
it with the chart's nightly string (`<base>-nightly.<YYYYMMDD>-<short_sha>`) via
the `APP_VERSION` build-arg in `nightly.yaml`, so a nightly image's About page
distinguishes it from a release.
## Helper scripts
- `scripts/bump_version.sh <version>` — set all four fields at once, then
self-verify. Tolerates a leading `v` (e.g. `v2.1.0`).
```bash
scripts/bump_version.sh 2.1.0
```
- `scripts/verify_versions.sh [version]` — check that all sources agree. With
no argument it requires mutual equality; with an argument it requires every
source to equal it. Exits non-zero on mismatch. Run it locally before tagging
to catch drift early:
```bash
scripts/verify_versions.sh 2.1.0
```
## Release procedure
1. **Bump the version** across all sources:
```bash
scripts/bump_version.sh 2.1.0
```
2. **Update `CHANGELOG.md`**: rename the `## [Unreleased]` section to
`## [2.1.0] — YYYY-MM-DD` (note the em dash `—`), and add a link reference
at the bottom of the file:
```
[2.1.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.1.0
```
Start a fresh `## [Unreleased]` section above it for the next cycle.
3. **Commit** the version + changelog changes:
```bash
git add -A
git commit -m "release: v2.1.0"
```
4. **Tag and push**:
```bash
git tag v2.1.0
git push origin v2.1.0
```
Pushing the tag triggers the publishing workflows (below).
## What CI publishes on a `v*` tag
- `.github/workflows/container.yaml` — builds and pushes `backend`,
`frontend`, and `provisioner` images to `ghcr.io`, tagged with the release
version (and `latest` on the default branch).
- `.github/workflows/chart.yaml` — packages the Helm chart and pushes it as an
OCI artifact to `ghcr.io`. Users install with:
```bash
helm install deer-flow oci://ghcr.io/<owner>/charts/deer-flow --version 2.1.0
```
## Nightly builds
`.github/workflows/nightly.yaml` runs on a schedule (and `workflow_dispatch`)
to publish the same three images plus the chart from unreleased `main`. It is
**not** gated by the version check (there is no `v*` tag) and it does **not**
touch the `latest` tag, which stays pinned to the last `v*` release. Every job
is gated on `github.repository == 'bytedance/deer-flow'`, so it only runs on
the upstream repo - a scheduled run or manual dispatch on a fork skips all jobs.
Artifacts (under the running repo's owner, where `<date>` is `YYYYMMDD`):
- Images: `ghcr.io/<owner>/deer-flow-{backend,frontend,provisioner}:nightly`
(rolling, overwritten each run) and `:nightly-<date>` (pinned to a day, but
mutable within it - a same-day re-dispatch overwrites it). For a truly
immutable pin, use `:sha-<short>`.
- Chart: `oci://ghcr.io/<owner>/charts/deer-flow`, version `<base>-nightly.<date>-<sha>`
(e.g. `2.1.0-nightly.20260710-77a3652`). The short SHA makes each dispatch's
chart version unique, so a same-day re-dispatch re-publishes cleanly (OCI
chart versions are immutable and otherwise can't be overwritten). The
packaged chart defaults `image.registry=ghcr.io/<owner>` and
`image.tag=nightly`, so installing it pulls the matching nightly images with
no values overrides:
```bash
helm install deer-flow oci://ghcr.io/<owner>/charts/deer-flow \
--version 2.1.0-nightly.20260710-77a3652
```
The chart version is patched in-workflow only - `Chart.yaml` and `values.yaml`
in the repo are never modified.
## Version gate
Both publishing workflows call `.github/workflows/verify-versions.yml` as their
first job. It runs `scripts/verify_versions.sh` against the tag (minus the
`v`). If any of the four version sources doesn't match the tag, the verify job
fails and **all** publish jobs are skipped — no images, no chart.
When it fails, the job annotation names the offending file and suggests the
fix:
```
::error::frontend/package.json is '2.0.0' but expected '2.1.0'.
Tip: run scripts/bump_version.sh 2.1.0 to align all sources.
```
## Pre-releases (RCs)
Pre-release tags like `v2.1.0-rc1` are valid `v*` tags and trigger the same
workflows. The version sources must equal the full pre-release string
(`2.1.0-rc1`) — the gate compares exact strings. Use the same procedure with
the rc version:
```bash
scripts/bump_version.sh 2.1.0-rc1
# update CHANGELOG, commit, tag v2.1.0-rc1, push
```
## Recovering from a failed gate
If the gate failed because a source was forgotten:
1. Run `scripts/bump_version.sh <version>` to align the sources.
2. Amend or add a follow-up commit.
3. Delete and re-create the tag, then push it:
```bash
git tag -d v2.1.0
git tag v2.1.0
git push origin :refs/tags/v2.1.0
git push origin v2.1.0
```
Re-pushing the tag re-triggers the workflows. Because the gate blocks **all**
artifacts when it fails, nothing was published under the bad tag, so re-tagging
is safe — no images or chart were pushed to overwrite.
## Post-release
Optionally draft a **GitHub Release** from the tag, pasting the corresponding
`CHANGELOG.md` section as the release notes. The changelog link references
point at these release URLs.
For the 2.1.0 chart release (the first chart release), pre-`charts/` nightly
builds remain at the legacy bare `ghcr.io/<owner>/deer-flow` package. That
package receives no new versions after 2.1.0; delete it or revoke its
visibility once nothing still pulls from it.

View File

@ -2,10 +2,10 @@
## Supported Versions ## Supported Versions
As deer-flow doesn't provide an official release yet, please use the latest version to receive security updates. As deer-flow doesn't provide an official release yet, please use the latest version for the security updates.
Currently, we have two branches to maintain: Currently, we have two branches to maintain:
* main branch for deer-flow 2.x * main branch for deer-flow 2.x
* main-1.x branch for deer-flow 1.x * main-1.x branch for deer-flow 1.x
## Reporting a Vulnerability ## Reporting a Vulnerability

9
backend/.gitignore vendored
View File

@ -24,14 +24,5 @@ config.yaml
# Langgraph # Langgraph
.langgraph_api .langgraph_api
# Sandbox runtime working dir — pre-created and excluded from uvicorn reload
# (scripts/serve.sh, docker/dev-entrypoint.sh). Anchored so it does not match
# the source package backend/packages/harness/deerflow/sandbox/.
/sandbox/
# Claude Code settings # Claude Code settings
.claude/settings.local.json .claude/settings.local.json
# pytest --basetemp workaround dirs (sandbox tmp_path permission)
.pytest_tmp/
.pytest_tmp_run/

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,557 @@
# CLAUDE.md # CLAUDE.md
The backend agent guidance lives in [AGENTS.md](AGENTS.md) so it is shared across coding agents (Claude Code, Codex, and others). Claude Code imports it below. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md ## Project Overview
DeerFlow is a LangGraph-based AI super agent system with a full-stack architecture. The backend provides a "super agent" with sandbox execution, persistent memory, subagent delegation, and extensible tool integration - all operating in per-thread isolated environments.
**Architecture**:
- **LangGraph Server** (port 2024): Agent runtime and workflow execution
- **Gateway API** (port 8001): REST API for models, MCP, skills, memory, artifacts, uploads, and local thread cleanup
- **Frontend** (port 3000): Next.js web interface
- **Nginx** (port 2026): Unified reverse proxy entry point
- **Provisioner** (port 8002, optional in Docker dev): Started only when sandbox is configured for provisioner/Kubernetes mode
**Runtime Modes**:
- **Standard mode** (`make dev`): LangGraph Server handles agent execution as a separate process. 4 processes total.
- **Gateway mode** (`make dev-pro`, experimental): Agent runtime embedded in Gateway via `RunManager` + `run_agent()` + `StreamBridge` (`packages/harness/deerflow/runtime/`). Service manages its own concurrency via async tasks. 3 processes total, no LangGraph Server.
**Project Structure**:
```
deer-flow/
├── Makefile # Root commands (check, install, dev, stop)
├── config.yaml # Main application configuration
├── extensions_config.json # MCP servers and skills configuration
├── backend/ # Backend application (this directory)
│ ├── Makefile # Backend-only commands (dev, gateway, lint)
│ ├── langgraph.json # LangGraph server configuration
│ ├── packages/
│ │ └── harness/ # deerflow-harness package (import: deerflow.*)
│ │ ├── pyproject.toml
│ │ └── deerflow/
│ │ ├── agents/ # LangGraph agent system
│ │ │ ├── lead_agent/ # Main agent (factory + system prompt)
│ │ │ ├── middlewares/ # 10 middleware components
│ │ │ ├── memory/ # Memory extraction, queue, prompts
│ │ │ └── thread_state.py # ThreadState schema
│ │ ├── sandbox/ # Sandbox execution system
│ │ │ ├── local/ # Local filesystem provider
│ │ │ ├── sandbox.py # Abstract Sandbox interface
│ │ │ ├── tools.py # bash, ls, read/write/str_replace
│ │ │ └── middleware.py # Sandbox lifecycle management
│ │ ├── subagents/ # Subagent delegation system
│ │ │ ├── builtins/ # general-purpose, bash agents
│ │ │ ├── executor.py # Background execution engine
│ │ │ └── registry.py # Agent registry
│ │ ├── tools/builtins/ # Built-in tools (present_files, ask_clarification, view_image)
│ │ ├── mcp/ # MCP integration (tools, cache, client)
│ │ ├── models/ # Model factory with thinking/vision support
│ │ ├── skills/ # Skills discovery, loading, parsing
│ │ ├── config/ # Configuration system (app, model, sandbox, tool, etc.)
│ │ ├── community/ # Community tools (tavily, jina_ai, firecrawl, image_search, aio_sandbox)
│ │ ├── reflection/ # Dynamic module loading (resolve_variable, resolve_class)
│ │ ├── utils/ # Utilities (network, readability)
│ │ └── client.py # Embedded Python client (DeerFlowClient)
│ ├── app/ # Application layer (import: app.*)
│ │ ├── gateway/ # FastAPI Gateway API
│ │ │ ├── app.py # FastAPI application
│ │ │ └── routers/ # FastAPI route modules (models, mcp, memory, skills, uploads, threads, artifacts, agents, suggestions, channels)
│ │ └── channels/ # IM platform integrations
│ ├── tests/ # Test suite
│ └── docs/ # Documentation
├── frontend/ # Next.js frontend application
└── skills/ # Agent skills directory
├── public/ # Public skills (committed)
└── custom/ # Custom skills (gitignored)
```
## Important Development Guidelines
### Documentation Update Policy
**CRITICAL: Always update README.md and CLAUDE.md after every code change**
When making code changes, you MUST update the relevant documentation:
- Update `README.md` for user-facing changes (features, setup, usage instructions)
- Update `CLAUDE.md` for development changes (architecture, commands, workflows, internal systems)
- Keep documentation synchronized with the codebase at all times
- Ensure accuracy and timeliness of all documentation
## Commands
**Root directory** (for full application):
```bash
make check # Check system requirements
make install # Install all dependencies (frontend + backend)
make dev # Start all services (LangGraph + Gateway + Frontend + Nginx), with config.yaml preflight
make dev-pro # Gateway mode (experimental): skip LangGraph, agent runtime embedded in Gateway
make start-pro # Production + Gateway mode (experimental)
make stop # Stop all services
```
**Backend directory** (for backend development only):
```bash
make install # Install backend dependencies
make dev # Run LangGraph server only (port 2024)
make gateway # Run Gateway API only (port 8001)
make test # Run all backend tests
make lint # Lint with ruff
make format # Format code with ruff
```
Regression tests related to Docker/provisioner behavior:
- `tests/test_docker_sandbox_mode_detection.py` (mode detection from `config.yaml`)
- `tests/test_provisioner_kubeconfig.py` (kubeconfig file/directory handling)
Boundary check (harness → app import firewall):
- `tests/test_harness_boundary.py` — ensures `packages/harness/deerflow/` never imports from `app.*`
CI runs these regression tests for every pull request via [.github/workflows/backend-unit-tests.yml](../.github/workflows/backend-unit-tests.yml).
## Architecture
### Harness / App Split
The backend is split into two layers with a strict dependency direction:
- **Harness** (`packages/harness/deerflow/`): Publishable agent framework package (`deerflow-harness`). Import prefix: `deerflow.*`. Contains agent orchestration, tools, sandbox, models, MCP, skills, config — everything needed to build and run agents.
- **App** (`app/`): Unpublished application code. Import prefix: `app.*`. Contains the FastAPI Gateway API and IM channel integrations (Feishu, Slack, Telegram).
**Dependency rule**: App imports deerflow, but deerflow never imports app. This boundary is enforced by `tests/test_harness_boundary.py` which runs in CI.
**Import conventions**:
```python
# Harness internal
from deerflow.agents import make_lead_agent
from deerflow.models import create_chat_model
# App internal
from app.gateway.app import app
from app.channels.service import start_channel_service
# App → Harness (allowed)
from deerflow.config import get_app_config
# Harness → App (FORBIDDEN — enforced by test_harness_boundary.py)
# from app.gateway.routers.uploads import ... # ← will fail CI
```
### Agent System
**Lead Agent** (`packages/harness/deerflow/agents/lead_agent/agent.py`):
- Entry point: `make_lead_agent(config: RunnableConfig)` registered in `langgraph.json`
- Dynamic model selection via `create_chat_model()` with thinking/vision support
- Tools loaded via `get_available_tools()` - combines sandbox, built-in, MCP, community, and subagent tools
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear)
**Runtime Configuration** (via `config.configurable`):
- `thinking_enabled` - Enable model's extended thinking
- `model_name` - Select specific LLM model
- `is_plan_mode` - Enable TodoList middleware
- `subagent_enabled` - Enable task delegation tool
### Middleware Chain
Middlewares execute in strict order in `packages/harness/deerflow/agents/lead_agent/agent.py`:
1. **ThreadDataMiddleware** - Creates per-thread directories (`backend/.deer-flow/threads/{thread_id}/user-data/{workspace,uploads,outputs}`); Web UI thread deletion now follows LangGraph thread removal with Gateway cleanup of the local `.deer-flow/threads/{thread_id}` directory
2. **UploadsMiddleware** - Tracks and injects newly uploaded files into conversation
3. **SandboxMiddleware** - Acquires sandbox, stores `sandbox_id` in state
4. **DanglingToolCallMiddleware** - Injects placeholder ToolMessages for AIMessage tool_calls that lack responses (e.g., due to user interruption)
5. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
6. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
7. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
8. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
9. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
10. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
11. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if subagent_enabled)
12. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
### Configuration System
**Main Configuration** (`config.yaml`):
Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** directory.
**Config Versioning**: `config.example.yaml` has a `config_version` field. On startup, `AppConfig.from_file()` compares user version vs example version and emits a warning if outdated. Missing `config_version` = version 0. Run `make config-upgrade` to auto-merge missing fields. When changing the config schema, bump `config_version` in `config.example.yaml`.
**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path changes or the file's mtime increases. This keeps Gateway and LangGraph reads aligned with `config.yaml` edits without requiring a manual process restart.
Configuration priority:
1. Explicit `config_path` argument
2. `DEER_FLOW_CONFIG_PATH` environment variable
3. `config.yaml` in current directory (backend/)
4. `config.yaml` in parent directory (project root - **recommended location**)
Config values starting with `$` are resolved as environment variables (e.g., `$OPENAI_API_KEY`).
`ModelConfig` also declares `use_responses_api` and `output_version` so OpenAI `/v1/responses` can be enabled explicitly while still using `langchain_openai:ChatOpenAI`.
**Extensions Configuration** (`extensions_config.json`):
MCP servers and skills are configured together in `extensions_config.json` in project root:
Configuration priority:
1. Explicit `config_path` argument
2. `DEER_FLOW_EXTENSIONS_CONFIG_PATH` environment variable
3. `extensions_config.json` in current directory (backend/)
4. `extensions_config.json` in parent directory (project root - **recommended location**)
### Gateway API (`app/gateway/`)
FastAPI application on port 8001 with health check at `GET /health`.
**Routers**:
| Router | Endpoints |
|--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
| **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
### Sandbox System (`packages/harness/deerflow/sandbox/`)
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
**Provider Pattern**: `SandboxProvider` with `acquire`, `get`, `release` lifecycle
**Implementations**:
- `LocalSandboxProvider` - Singleton local filesystem execution with path mappings
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation
**Virtual Path System**:
- Agent sees: `/mnt/user-data/{workspace,uploads,outputs}`, `/mnt/skills`
- Physical: `backend/.deer-flow/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
- Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()`
- Detection: `is_local_sandbox()` checks `sandbox_id == "local"`
**Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`):
- `bash` - Execute commands with path translation and error handling
- `ls` - Directory listing (tree format, max 2 levels)
- `read_file` - Read file contents with optional line range
- `write_file` - Write/append to files, creates directories
- `str_replace` - Substring replacement (single or all occurrences); same-path serialization is scoped to `(sandbox.id, path)` so isolated sandboxes do not contend on identical virtual paths inside one process
### Subagent System (`packages/harness/deerflow/subagents/`)
**Built-in Agents**: `general-purpose` (all tools except `task`) and `bash` (command specialist)
**Execution**: Dual thread pool - `_scheduler_pool` (3 workers) + `_execution_pool` (3 workers)
**Concurrency**: `MAX_CONCURRENT_SUBAGENTS = 3` enforced by `SubagentLimitMiddleware` (truncates excess tool calls in `after_model`), 15-minute timeout
**Flow**: `task()` tool → `SubagentExecutor` → background thread → poll 5s → SSE events → result
**Events**: `task_started`, `task_running`, `task_completed`/`task_failed`/`task_timed_out`
### Tool System (`packages/harness/deerflow/tools/`)
`get_available_tools(groups, include_mcp, model_name, subagent_enabled)` assembles:
1. **Config-defined tools** - Resolved from `config.yaml` via `resolve_variable()`
2. **MCP tools** - From enabled MCP servers (lazy initialized, cached with mtime invalidation)
3. **Built-in tools**:
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware → interrupts)
- `view_image` - Read image as base64 (added only if model supports vision)
4. **Subagent tool** (if enabled):
- `task` - Delegate to subagent (description, prompt, subagent_type, max_turns)
**Community tools** (`packages/harness/deerflow/community/`):
- `tavily/` - Web search (5 results default) and web fetch (4KB limit)
- `jina_ai/` - Web fetch via Jina reader API with readability extraction
- `firecrawl/` - Web scraping via Firecrawl API
**ACP agent tools**:
- `invoke_acp_agent` - Invokes external ACP-compatible agents from `config.yaml`
- ACP launchers must be real ACP adapters. The standard `codex` CLI is not ACP-compatible by itself; configure a wrapper such as `npx -y @zed-industries/codex-acp` or an installed `codex-acp` binary
- Missing ACP executables now return an actionable error message instead of a raw `[Errno 2]`
- Each ACP agent uses a per-thread workspace at `{base_dir}/threads/{thread_id}/acp-workspace/`. The workspace is accessible to the lead agent via the virtual path `/mnt/acp-workspace/` (read-only). In docker sandbox mode, the directory is volume-mounted into the container at `/mnt/acp-workspace` (read-only); in local sandbox mode, path translation is handled by `tools.py`
- `image_search/` - Image search via DuckDuckGo
### MCP System (`packages/harness/deerflow/mcp/`)
- Uses `langchain-mcp-adapters` `MultiServerMCPClient` for multi-server management
- **Lazy initialization**: Tools loaded on first use via `get_cached_mcp_tools()`
- **Cache invalidation**: Detects config file changes via mtime comparison
- **Transports**: stdio (command-based), SSE, HTTP
- **OAuth (HTTP/SSE)**: Supports token endpoint flows (`client_credentials`, `refresh_token`) with automatic token refresh + Authorization header injection
- **Runtime updates**: Gateway API saves to extensions_config.json; LangGraph detects via mtime
### Skills System (`packages/harness/deerflow/skills/`)
- **Location**: `deer-flow/skills/{public,custom}/`
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
- **Injection**: Enabled skills listed in agent system prompt with container paths
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
### Model Factory (`packages/harness/deerflow/models/factory.py`)
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
- Supports `thinking_enabled` flag with per-model `when_thinking_enabled` overrides
- Supports vLLM-style thinking toggles via `when_thinking_enabled.extra_body.chat_template_kwargs.enable_thinking` for Qwen reasoning models, while normalizing legacy `thinking` configs for backward compatibility
- Supports `supports_vision` flag for image understanding models
- Config values starting with `$` resolved as environment variables
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM 0.19.0 Qwen reasoning models, while accepting the older `thinking` alias
### IM Channels System (`app/channels/`)
Bridges external messaging platforms (Feishu, Slack, Telegram) to the DeerFlow agent via the LangGraph Server.
**Architecture**: Channels communicate with the LangGraph Server through `langgraph-sdk` HTTP client (same as the frontend), ensuring threads are created and managed server-side.
**Components**:
- `message_bus.py` - Async pub/sub hub (`InboundMessage` → queue → dispatcher; `OutboundMessage` → callbacks → channels)
- `store.py` - JSON-file persistence mapping `channel_name:chat_id[:topic_id]``thread_id` (keys are `channel:chat` for root conversations and `channel:chat:topic` for threaded conversations)
- `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands, keeps Slack/Telegram on `client.runs.wait()`, and uses `client.runs.stream(["messages-tuple", "values"])` for Feishu incremental outbound updates
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle)
- `service.py` - Manages lifecycle of all configured channels from `config.yaml`
- `slack.py` / `feishu.py` / `telegram.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place)
**Message Flow**:
1. External platform -> Channel impl -> `MessageBus.publish_inbound()`
2. `ChannelManager._dispatch_loop()` consumes from queue
3. For chat: look up/create thread on LangGraph Server
4. Feishu chat: `runs.stream()` → accumulate AI text → publish multiple outbound updates (`is_final=False`) → publish final outbound (`is_final=True`)
5. Slack/Telegram chat: `runs.wait()` → extract final response → publish outbound
6. Feishu channel sends one running reply card up front, then patches the same card for each outbound update (card JSON sets `config.update_multi=true` for Feishu's patch API requirement)
7. For commands (`/new`, `/status`, `/models`, `/memory`, `/help`): handle locally or query Gateway API
8. Outbound → channel callbacks → platform reply
**Configuration** (`config.yaml` -> `channels`):
- `langgraph_url` - LangGraph Server URL (default: `http://localhost:2024`)
- `gateway_url` - Gateway API URL for auxiliary commands (default: `http://localhost:8001`)
- In Docker Compose, IM channels run inside the `gateway` container, so `localhost` points back to that container. Use `http://langgraph:2024` / `http://gateway:8001`, or set `DEER_FLOW_CHANNELS_LANGGRAPH_URL` / `DEER_FLOW_CHANNELS_GATEWAY_URL`.
- Per-channel configs: `feishu` (app_id, app_secret), `slack` (bot_token, app_token), `telegram` (bot_token)
### Memory System (`packages/harness/deerflow/agents/memory/`)
**Components**:
- `updater.py` - LLM-based memory updates with fact extraction, whitespace-normalized fact deduplication (trims leading/trailing whitespace before comparing), and atomic file I/O
- `queue.py` - Debounced update queue (per-thread deduplication, configurable wait time)
- `prompt.py` - Prompt templates for memory updates
**Data Structure** (stored in `backend/.deer-flow/memory.json`):
- **User Context**: `workContext`, `personalContext`, `topOfMind` (1-3 sentence summaries)
- **History**: `recentMonths`, `earlierContext`, `longTermBackground`
- **Facts**: Discrete facts with `id`, `content`, `category` (preference/knowledge/context/behavior/goal), `confidence` (0-1), `createdAt`, `source`
**Workflow**:
1. `MemoryMiddleware` filters messages (user inputs + final AI responses) and queues conversation
2. Queue debounces (30s default), batches updates, deduplicates per-thread
3. Background thread invokes LLM to extract context updates and facts
4. Applies updates atomically (temp file + rename) with cache invalidation, skipping duplicate fact content before append
5. Next interaction injects top 15 facts + context into `<memory>` tags in system prompt
Focused regression coverage for the updater lives in `backend/tests/test_memory_updater.py`.
**Configuration** (`config.yaml``memory`):
- `enabled` / `injection_enabled` - Master switches
- `storage_path` - Path to memory.json
- `debounce_seconds` - Wait time before processing (default: 30)
- `model_name` - LLM for updates (null = default model)
- `max_facts` / `fact_confidence_threshold` - Fact storage limits (100 / 0.7)
- `max_injection_tokens` - Token limit for prompt injection (2000)
### Reflection System (`packages/harness/deerflow/reflection/`)
- `resolve_variable(path)` - Import module and return variable (e.g., `module.path:variable_name`)
- `resolve_class(path, base_class)` - Import and validate class against base class
### Config Schema
**`config.yaml`** key sections:
- `models[]` - LLM configs with `use` class path, `supports_thinking`, `supports_vision`, provider-specific fields
- vLLM reasoning models should use `deerflow.models.vllm_provider:VllmChatModel`; for Qwen-style parsers prefer `when_thinking_enabled.extra_body.chat_template_kwargs.enable_thinking`, and DeerFlow will also normalize the older `thinking` alias
- `tools[]` - Tool configs with `use` variable path and `group`
- `tool_groups[]` - Logical groupings for tools
- `sandbox.use` - Sandbox provider class path
- `skills.path` / `skills.container_path` - Host and container paths to skills directory
- `title` - Auto-title generation (enabled, max_words, max_chars, prompt_template)
- `summarization` - Context summarization (enabled, trigger conditions, keep policy)
- `subagents.enabled` - Master switch for subagent delegation
- `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens)
**`extensions_config.json`**:
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description)
- `skills` - Map of skill name → state (enabled)
Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods.
### Embedded Client (`packages/harness/deerflow/client.py`)
`DeerFlowClient` provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes.
**Architecture**: Imports the same `deerflow` modules that LangGraph Server and Gateway API use. Shares the same config files and data directories. No FastAPI dependency.
**Agent Conversation** (replaces LangGraph Server):
- `chat(message, thread_id)` — synchronous, accumulates streaming deltas per message-id and returns the final AI text
- `stream(message, thread_id)` — subscribes to LangGraph `stream_mode=["values", "messages", "custom"]` and yields `StreamEvent`:
- `"values"` — full state snapshot (title, messages, artifacts); AI text already delivered via `messages` mode is **not** re-synthesized here to avoid duplicate deliveries
- `"messages-tuple"` — per-chunk update: for AI text this is a **delta** (concat per `id` to rebuild the full message); tool calls and tool results are emitted once each
- `"custom"` — forwarded from `StreamWriter`
- `"end"` — stream finished (carries cumulative `usage` counted once per message id)
- Agent created lazily via `create_agent()` + `_build_middlewares()`, same as `make_lead_agent`
- Supports `checkpointer` parameter for state persistence across turns
- `reset_agent()` forces agent recreation (e.g. after memory or skill changes)
- See [docs/STREAMING.md](docs/STREAMING.md) for the full design: why Gateway and DeerFlowClient are parallel paths, LangGraph's `stream_mode` semantics, the per-id dedup invariants, and regression testing strategy
**Gateway Equivalent Methods** (replaces Gateway API):
| Category | Methods | Return format |
|----------|---------|---------------|
| Models | `list_models()`, `get_model(name)` | `{"models": [...]}`, `{name, display_name, ...}` |
| MCP | `get_mcp_config()`, `update_mcp_config(servers)` | `{"mcp_servers": {...}}` |
| Skills | `list_skills()`, `get_skill(name)`, `update_skill(name, enabled)`, `install_skill(path)` | `{"skills": [...]}` |
| Memory | `get_memory()`, `reload_memory()`, `get_memory_config()`, `get_memory_status()` | dict |
| Uploads | `upload_files(thread_id, files)`, `list_uploads(thread_id)`, `delete_upload(thread_id, filename)` | `{"success": true, "files": [...]}`, `{"files": [...], "count": N}` |
| Artifacts | `get_artifact(thread_id, path)``(bytes, mime_type)` | tuple |
**Key difference from Gateway**: Upload accepts local `Path` objects instead of HTTP `UploadFile`, rejects directory paths before copying, and reuses a single worker when document conversion must run inside an active event loop. Artifact returns `(bytes, mime_type)` instead of HTTP Response. The new Gateway-only thread cleanup route deletes `.deer-flow/threads/{thread_id}` after LangGraph thread deletion; there is no matching `DeerFlowClient` method yet. `update_mcp_config()` and `update_skill()` automatically invalidate the cached agent.
**Tests**: `tests/test_client.py` (77 unit tests including `TestGatewayConformance`), `tests/test_client_live.py` (live integration tests, requires config.yaml)
**Gateway Conformance Tests** (`TestGatewayConformance`): Validate that every dict-returning client method conforms to the corresponding Gateway Pydantic response model. Each test parses the client output through the Gateway model — if Gateway adds a required field that the client doesn't provide, Pydantic raises `ValidationError` and CI catches the drift. Covers: `ModelsListResponse`, `ModelResponse`, `SkillsListResponse`, `SkillResponse`, `SkillInstallResponse`, `McpConfigResponse`, `UploadResponse`, `MemoryConfigResponse`, `MemoryStatusResponse`.
## Development Workflow
### Test-Driven Development (TDD) — MANDATORY
**Every new feature or bug fix MUST be accompanied by unit tests. No exceptions.**
- Write tests in `backend/tests/` following the existing naming convention `test_<feature>.py`
- Run the full suite before and after your change: `make test`
- Tests must pass before a feature is considered complete
- For lightweight config/utility modules, prefer pure unit tests with no external dependencies
- If a module causes circular import issues in tests, add a `sys.modules` mock in `tests/conftest.py` (see existing example for `deerflow.subagents.executor`)
```bash
# Run all tests
make test
# Run a specific test file
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
```
### Running the Full Application
From the **project root** directory:
```bash
make dev
```
This starts all services and makes the application available at `http://localhost:2026`.
**All startup modes:**
| | **Local Foreground** | **Local Daemon** | **Docker Dev** | **Docker Prod** |
|---|---|---|---|---|
| **Dev** | `./scripts/serve.sh --dev`<br/>`make dev` | `./scripts/serve.sh --dev --daemon`<br/>`make dev-daemon` | `./scripts/docker.sh start`<br/>`make docker-start` | — |
| **Dev + Gateway** | `./scripts/serve.sh --dev --gateway`<br/>`make dev-pro` | `./scripts/serve.sh --dev --gateway --daemon`<br/>`make dev-daemon-pro` | `./scripts/docker.sh start --gateway`<br/>`make docker-start-pro` | — |
| **Prod** | `./scripts/serve.sh --prod`<br/>`make start` | `./scripts/serve.sh --prod --daemon`<br/>`make start-daemon` | — | `./scripts/deploy.sh`<br/>`make up` |
| **Prod + Gateway** | `./scripts/serve.sh --prod --gateway`<br/>`make start-pro` | `./scripts/serve.sh --prod --gateway --daemon`<br/>`make start-daemon-pro` | — | `./scripts/deploy.sh --gateway`<br/>`make up-pro` |
| Action | Local | Docker Dev | Docker Prod |
|---|---|---|---|
| **Stop** | `./scripts/serve.sh --stop`<br/>`make stop` | `./scripts/docker.sh stop`<br/>`make docker-stop` | `./scripts/deploy.sh down`<br/>`make down` |
| **Restart** | `./scripts/serve.sh --restart [flags]` | `./scripts/docker.sh restart` | — |
Gateway mode embeds the agent runtime in Gateway, no LangGraph server.
**Nginx routing**:
- Standard mode: `/api/langgraph/*` → LangGraph Server (2024)
- Gateway mode: `/api/langgraph/*` → Gateway embedded runtime (8001) (via envsubst)
- `/api/*` (other) → Gateway API (8001)
- `/` (non-API) → Frontend (3000)
### Running Backend Services Separately
From the **backend** directory:
```bash
# Terminal 1: LangGraph server
make dev
# Terminal 2: Gateway API
make gateway
```
Direct access (without nginx):
- LangGraph: `http://localhost:2024`
- Gateway: `http://localhost:8001`
### Frontend Configuration
The frontend uses environment variables to connect to backend services:
- `NEXT_PUBLIC_LANGGRAPH_BASE_URL` - Defaults to `/api/langgraph` (through nginx)
- `NEXT_PUBLIC_BACKEND_BASE_URL` - Defaults to empty string (through nginx)
When using `make dev` from root, the frontend automatically connects through nginx.
## Key Features
### File Upload
Multi-file upload with automatic document conversion:
- Endpoint: `POST /api/threads/{thread_id}/uploads`
- Supports: PDF, PPT, Excel, Word documents (converted via `markitdown`)
- Rejects directory inputs before copying so uploads stay all-or-nothing
- Reuses one conversion worker per request when called from an active event loop
- Files stored in thread-isolated directories
- Agent receives uploaded file list via `UploadsMiddleware`
See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
### Plan Mode
TodoList middleware for complex multi-step tasks:
- Controlled via runtime config: `config.configurable.is_plan_mode = True`
- Provides `write_todos` tool for task tracking
- One task in_progress at a time, real-time updates
See [docs/plan_mode_usage.md](docs/plan_mode_usage.md) for details.
### Context Summarization
Automatic conversation summarization when approaching token limits:
- Configured in `config.yaml` under `summarization` key
- Trigger types: tokens, messages, or fraction of max input
- Keeps recent messages while summarizing older ones
See [docs/summarization.md](docs/summarization.md) for details.
### Vision Support
For models with `supports_vision: true`:
- `ViewImageMiddleware` processes images in conversation
- `view_image_tool` added to agent's toolset
- Images automatically converted to base64 and injected into state
## Code Style
- Uses `ruff` for linting and formatting
- Line length: 240 characters
- Python 3.12+ with type hints
- Double quotes, space indentation
## Documentation
See `docs/` directory for detailed documentation:
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - Architecture details
- [API.md](docs/API.md) - API reference
- [SETUP.md](docs/SETUP.md) - Setup guide
- [FILE_UPLOAD.md](docs/FILE_UPLOAD.md) - File upload feature
- [PATH_EXAMPLES.md](docs/PATH_EXAMPLES.md) - Path types and usage
- [summarization.md](docs/summarization.md) - Context summarization
- [plan_mode_usage.md](docs/plan_mode_usage.md) - Plan mode with TodoList

View File

@ -56,49 +56,76 @@ export OPENAI_API_KEY="your-api-key"
### Run the Development Server ### Run the Development Server
```bash ```bash
# Gateway API + embedded agent runtime # Terminal 1: LangGraph server
make dev make dev
# Terminal 2: Gateway API
make gateway
``` ```
## Project Structure ## Project Structure
``` ```
backend/ backend/src/
├── packages/harness/deerflow/ # deerflow-harness package (import: deerflow.*) ├── agents/ # Agent system
│ ├── agents/ # Agent system │ ├── lead_agent/ # Main agent implementation
│ │ ├── lead_agent/ # Main agent (agent.py factory, prompt.py) │ │ └── agent.py # Agent factory and creation
│ │ ├── middlewares/ # Agent middleware chain │ ├── middlewares/ # Agent middlewares
│ │ ├── memory/ # Memory extraction & storage │ │ ├── thread_data_middleware.py
│ │ └── thread_state.py # Thread state definition │ │ ├── sandbox_middleware.py
│ ├── sandbox/ # Sandbox execution │ │ ├── title_middleware.py
│ │ ├── local/ # Local sandbox provider │ │ ├── uploads_middleware.py
│ │ ├── sandbox.py # Abstract interface │ │ ├── view_image_middleware.py
│ │ ├── tools.py # Sandbox tools (bash, file ops) │ │ └── clarification_middleware.py
│ │ └── middleware.py # Sandbox lifecycle │ └── thread_state.py # Thread state definition
│ ├── subagents/ # Subagent delegation
│ ├── tools/builtins/ # Built-in tools ├── gateway/ # FastAPI Gateway
│ ├── mcp/ # MCP integration │ ├── app.py # FastAPI application
│ ├── models/ # Model factory │ └── routers/ # Route handlers
│ ├── skills/ # Skills system │ ├── models.py # /api/models endpoints
│ ├── config/ # Configuration system │ ├── mcp.py # /api/mcp endpoints
│ ├── runtime/ # Embedded run execution (RunManager, StreamBridge) │ ├── skills.py # /api/skills endpoints
│ ├── persistence/ # Checkpointer/store engines & schema migrations │ ├── artifacts.py # /api/threads/.../artifacts
│ ├── guardrails/ # Pre-tool-call authorization providers │ └── uploads.py # /api/threads/.../uploads
│ ├── tracing/ # Tracer factory & trace metadata
│ ├── uploads/ # Uploads manager ├── sandbox/ # Sandbox execution
│ ├── tui/ # Terminal UI (`deerflow` console script) │ ├── __init__.py # Sandbox interface
│ ├── community/ # Community tools (tavily/, jina_ai/, firecrawl/, …) │ ├── local.py # Local sandbox provider
│ ├── reflection/ # Dynamic module loading │ └── tools.py # Sandbox tools (bash, file ops)
│ └── utils/ # Utilities
└── app/ # FastAPI Gateway + IM channels (import: app.*) ├── tools/ # Agent tools
├── gateway/ # Gateway API │ └── builtins/ # Built-in tools
│ ├── app.py # FastAPI application │ ├── present_file_tool.py
│ └── routers/ # Route handlers (threads, models, mcp, skills, uploads, …) │ ├── ask_clarification_tool.py
└── channels/ # IM channel integrations (Feishu, Slack, Telegram, …) │ └── view_image_tool.py
├── mcp/ # MCP integration
│ └── manager.py # MCP server management
├── models/ # Model system
│ └── factory.py # Model factory
├── skills/ # Skills system
│ └── loader.py # Skills loader
├── config/ # Configuration
│ ├── app_config.py # Main app config
│ ├── extensions_config.py # Extensions config
│ └── summarization_config.py
├── community/ # Community tools
│ ├── tavily/ # Tavily web search
│ ├── jina/ # Jina web fetch
│ ├── firecrawl/ # Firecrawl scraping
│ └── aio_sandbox/ # Docker sandbox
├── reflection/ # Dynamic loading
│ └── __init__.py # Module resolution
└── utils/ # Utilities
└── __init__.py
``` ```
See [AGENTS.md](AGENTS.md) for the full module-by-module breakdown.
## Code Style ## Code Style
### Linting and Formatting ### Linting and Formatting

View File

@ -13,11 +13,6 @@ FROM python:3.12-slim-bookworm AS builder
ARG NODE_MAJOR=22 ARG NODE_MAJOR=22
ARG APT_MIRROR ARG APT_MIRROR
ARG UV_INDEX_URL ARG UV_INDEX_URL
ARG NPM_REGISTRY
ARG LARK_CLI_NPM_VERSION=1.0.65
# Optional extras to install (e.g. "postgres" for PostgreSQL support)
# Usage: docker build --build-arg UV_EXTRAS=postgres,discord ...
ARG UV_EXTRAS
# Optionally override apt mirror for restricted networks (e.g. APT_MIRROR=mirrors.aliyun.com) # Optionally override apt mirror for restricted networks (e.g. APT_MIRROR=mirrors.aliyun.com)
RUN if [ -n "${APT_MIRROR}" ]; then \ RUN if [ -n "${APT_MIRROR}" ]; then \
@ -38,11 +33,6 @@ RUN apt-get update && apt-get install -y \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install the official Lark/Feishu CLI used by the managed Lark integration.
RUN if [ -n "${NPM_REGISTRY}" ]; then npm config set registry "${NPM_REGISTRY}"; fi \
&& npm install -g @larksuite/cli@${LARK_CLI_NPM_VERSION} \
&& lark-cli --version
# Install uv (source image overridable via UV_IMAGE build arg) # Install uv (source image overridable via UV_IMAGE build arg)
COPY --from=uv-source /uv /uvx /usr/local/bin/ COPY --from=uv-source /uv /uvx /usr/local/bin/
@ -53,30 +43,8 @@ WORKDIR /app
COPY backend ./backend COPY backend ./backend
# Install dependencies with cache mount # Install dependencies with cache mount
# `redis` is always installed because Docker Compose defaults the stream bridge
# to Redis (DEER_FLOW_STREAM_BRIDGE_REDIS_URL); it is an optional extra elsewhere
# so slim non-Docker installs do not pull it.
# When UV_EXTRAS is set (comma- or whitespace-separated), installs optional dependencies.
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
sh -c 'cd backend && \ sh -c "cd backend && UV_INDEX_URL=${UV_INDEX_URL:-https://pypi.org/simple} uv sync"
set -f; \
extras_flags=""; \
for extra in $(printf "%s" "$UV_EXTRAS" | tr "," " "); do \
case "$extra" in \
[!A-Za-z]* | *[!A-Za-z0-9_-]*) \
echo "UV_EXTRAS entry $extra is invalid (must match [A-Za-z][A-Za-z0-9_-]*)" >&2; \
exit 1; \
;; \
esac; \
extras_flags="$extras_flags --extra $extra"; \
done; \
UV_INDEX_URL=${UV_INDEX_URL:-https://pypi.org/simple} uv sync --extra redis $extras_flags'
# UTF-8 locale prevents UnicodeEncodeError on Chinese/emoji content in minimal
# containers where locale configuration may be missing and the default encoding is not UTF-8.
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
ENV PYTHONIOENCODING=utf-8
# ── Stage 2: Dev ────────────────────────────────────────────────────────────── # ── Stage 2: Dev ──────────────────────────────────────────────────────────────
# Retains compiler toolchain from builder so startup-time `uv sync` can build # Retains compiler toolchain from builder so startup-time `uv sync` can build
@ -86,7 +54,7 @@ FROM builder AS dev
# Install Docker CLI (for DooD: allows starting sandbox containers via host Docker socket) # Install Docker CLI (for DooD: allows starting sandbox containers via host Docker socket)
COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker
EXPOSE 8001 EXPOSE 8001 2024
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"] CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
@ -94,18 +62,11 @@ CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app
# Clean image without build-essential — reduces size (~200 MB) and attack surface. # Clean image without build-essential — reduces size (~200 MB) and attack surface.
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
ENV PYTHONIOENCODING=utf-8
# Copy Node.js runtime from builder (provides npx for MCP servers) # Copy Node.js runtime from builder (provides npx for MCP servers)
COPY --from=builder /usr/bin/node /usr/bin/node COPY --from=builder /usr/bin/node /usr/bin/node
COPY --from=builder /usr/lib/node_modules /usr/lib/node_modules COPY --from=builder /usr/lib/node_modules /usr/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/bin/npm \ RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/bin/npm \
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/bin/npx \ && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/bin/npx
&& LARK_CLI_BIN="$(node -p 'const bin=require("/usr/lib/node_modules/@larksuite/cli/package.json").bin; typeof bin === "string" ? bin : (bin["lark-cli"] || Object.values(bin)[0])')" \
&& ln -s "../lib/node_modules/@larksuite/cli/${LARK_CLI_BIN#./}" /usr/bin/lark-cli \
&& lark-cli --version
# Install Docker CLI (for DooD: allows starting sandbox containers via host Docker socket) # Install Docker CLI (for DooD: allows starting sandbox containers via host Docker socket)
COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker
@ -119,8 +80,8 @@ WORKDIR /app
# Copy backend with pre-built virtualenv from builder # Copy backend with pre-built virtualenv from builder
COPY --from=builder /app/backend ./backend COPY --from=builder /app/backend ./backend
# Expose Gateway API port. # Expose ports (gateway: 8001, langgraph: 2024)
EXPOSE 8001 EXPOSE 8001 2024
# Default command (can be overridden in docker-compose) # Default command (can be overridden in docker-compose)
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"] CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]

View File

@ -2,34 +2,17 @@ install:
uv sync uv sync
dev: dev:
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload uv run langgraph dev --no-browser --no-reload --n-jobs-per-worker 10
gateway: gateway:
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
test: test:
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest tests/ -v PYTHONPATH=. uv run pytest tests/ -v
test-blocking-io:
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest tests/blocking_io -q --tb=short
lint: lint:
uv run ruff check . uvx ruff check .
uv run ruff format --check . uvx ruff format --check .
format: format:
uv run ruff check . --fix && uv run ruff format . uvx ruff check . --fix && uvx ruff format .
detect-blocking-io:
@PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run python ../scripts/detect_blocking_io_static.py --output ../.deer-flow/blocking-io-findings.json
# Generate a new alembic revision by autogenerating the diff against the live
# ORM models. Usage: make migrate-rev MSG="add foo column to runs"
# The Gateway runs `alembic upgrade head` automatically at startup via
# `bootstrap_schema`, so there is intentionally no `migrate` / `migrate-stamp`
# target -- the single execution path keeps ops mistakes off the table.
# The script builds a fresh temp SQLite at head, then diffs the live models
# against it, so a clean checkout does NOT need a pre-existing ./data/deerflow.db.
migrate-rev:
@if [ -z "$(MSG)" ]; then echo 'usage: make migrate-rev MSG="describe the change"'; exit 1; fi
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run python scripts/_autogen_revision.py "$(MSG)"

View File

@ -11,26 +11,31 @@ DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent
│ Nginx (Port 2026) │ │ Nginx (Port 2026) │
│ Unified reverse proxy │ │ Unified reverse proxy │
└───────┬──────────────────┬───────────┘ └───────┬──────────────────┬───────────┘
│ │
/api/langgraph/* │ /api/* (other) /api/langgraph/* │ │ /api/* (other)
rewritten to /api/* │ ▼ ▼
┌────────────────────┐ ┌────────────────────────┐
┌────────────────────────────────────────┐ │ LangGraph Server │ │ Gateway API (8001) │
│ Gateway API (8001) │ │ (Port 2024) │ │ FastAPI REST │
│ FastAPI REST + agent runtime │ │ │ │ │
│ │ │ ┌────────────────┐ │ │ Models, MCP, Skills, │
│ Models, MCP, Skills, Memory, Uploads, │ │ │ Lead Agent │ │ │ Memory, Uploads, │
│ Artifacts, Threads, Runs, Streaming │ │ │ ┌──────────┐ │ │ │ Artifacts │
│ │ │ │ │Middleware│ │ │ └────────────────────────┘
│ ┌────────────────────────────────────┐ │ │ │ │ Chain │ │ │
│ │ Lead Agent │ │ │ │ └──────────┘ │ │
│ │ Middleware Chain, Tools, Subagents │ │ │ │ ┌──────────┐ │ │
│ └────────────────────────────────────┘ │ │ │ │ Tools │ │ │
└────────────────────────────────────────┘ │ │ └──────────┘ │ │
│ │ ┌──────────┐ │ │
│ │ │Subagents │ │ │
│ │ └──────────┘ │ │
│ └────────────────┘ │
└────────────────────┘
``` ```
**Request Routing** (via Nginx): **Request Routing** (via Nginx):
- `/api/langgraph/*` → Gateway LangGraph-compatible API - agent interactions, threads, streaming - `/api/langgraph/*`LangGraph Server - agent interactions, threads, streaming
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup - `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup
- `/` (non-API) → Frontend - Next.js web interface - `/` (non-API) → Frontend - Next.js web interface
@ -69,13 +74,12 @@ Middlewares execute in strict order, each handling a specific concern:
Per-thread isolated execution with virtual path translation: Per-thread isolated execution with virtual path translation:
- **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir` - **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir`
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/). Async runtime paths use async sandbox lifecycle hooks so startup, readiness polling, and release do not block the event loop. `AioSandboxProvider` validates active-cache and warm-pool containers during acquire/reuse, dropping definitively dead entries so a thread can provision a fresh sandbox after an unexpected container exit while keeping `get()` as an in-memory lookup. Backend health-check failures are treated as unknown, not dead, and a container that cannot be verified during discovery is simply not adopted (acquire falls through to create instead of failing). - **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories - **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
- **Skills path**: `/mnt/skills``deer-flow/skills/` directory - **Skills path**: `/mnt/skills``deer-flow/skills/` directory
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths - **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
- **SkillScan**: Native offline deterministic scanning runs before the LLM skill scanner on installs and agent-managed skill writes; `CRITICAL` findings block and warning findings become LLM context
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match - **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`write_file` overwrites by default and exposes `append` for end-of-file writes; `bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access) - **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
### Subagent System ### Subagent System
@ -94,7 +98,6 @@ LLM-powered persistent context retention across conversations:
- **Structured storage**: User context (work, personal, top-of-mind), history, and confidence-scored facts - **Structured storage**: User context (work, personal, top-of-mind), history, and confidence-scored facts
- **Debounced updates**: Batches updates to minimize LLM calls (configurable wait time) - **Debounced updates**: Batches updates to minimize LLM calls (configurable wait time)
- **System prompt injection**: Top facts + context injected into agent prompts - **System prompt injection**: Top facts + context injected into agent prompts
- **Run-level memory identity**: `GET /api/threads/{thread_id}/runs/{run_id}/events?event_types=context:memory` returns the SHA-256 identity of the effective hidden memory block without copying memory text into the event store
- **Storage**: JSON file with mtime-based cache invalidation - **Storage**: JSON file with mtime-based cache invalidation
### Tool Ecosystem ### Tool Ecosystem
@ -103,7 +106,7 @@ LLM-powered persistent context retention across conversations:
|----------|-------| |----------|-------|
| **Sandbox** | `bash`, `ls`, `read_file`, `write_file`, `str_replace` | | **Sandbox** | `bash`, `ls`, `read_file`, `write_file`, `str_replace` |
| **Built-in** | `present_files`, `ask_clarification`, `view_image`, `task` (subagent) | | **Built-in** | `present_files`, `ask_clarification`, `view_image`, `task` (subagent) |
| **Community** | Tavily (web search), Jina AI (web fetch), Crawl4AI (web fetch), Firecrawl (scraping), fastCRW (scraping), DuckDuckGo (image search) | | **Community** | Tavily (web search), Jina AI (web fetch), Firecrawl (scraping), DuckDuckGo (image search) |
| **MCP** | Any Model Context Protocol server (stdio, SSE, HTTP transports) | | **MCP** | Any Model Context Protocol server (stdio, SSE, HTTP transports) |
| **Skills** | Domain-specific workflows injected via system prompt | | **Skills** | Domain-specific workflows injected via system prompt |
@ -115,24 +118,22 @@ FastAPI application providing REST endpoints for frontend integration:
|-------|---------| |-------|---------|
| `GET /api/models` | List available LLM models | | `GET /api/models` | List available LLM models |
| `GET/PUT /api/mcp/config` | Manage MCP server configurations | | `GET/PUT /api/mcp/config` | Manage MCP server configurations |
| `POST /api/mcp/cache/reset` | Reset cached MCP tools so they reload on next use |
| `GET/PUT /api/skills` | List and manage skills | | `GET/PUT /api/skills` | List and manage skills |
| `POST /api/skills/install` | Install skill from `.skill` archive | | `POST /api/skills/install` | Install skill from `.skill` archive |
| `GET /api/memory` | Retrieve memory data | | `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload | | `POST /api/memory/reload` | Force memory reload |
| `GET /api/memory/config` | Memory configuration | | `GET /api/memory/config` | Memory configuration |
| `GET /api/memory/status` | Combined config + data | | `GET /api/memory/status` | Combined config + data |
| `GET /api/threads/{id}/runs/{run_id}/events` | Debug/audit events for one run; filter `event_types=context:memory` for effective memory identity | | `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths) |
| `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths, auto-renames duplicate filenames in one request) |
| `GET /api/threads/{id}/uploads/list` | List uploaded files | | `GET /api/threads/{id}/uploads/list` | List uploaded files |
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts | | `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
### IM Channels ### IM Channels
The IM bridge supports Feishu, Slack, and Telegram. Slack and Telegram still use the final `runs.wait()` response path, while Feishu now streams through `runs.stream(["messages-tuple", "values"])`, serializes rapid same-thread turns inside the channel manager, and updates a single in-thread card per source message in place. The IM bridge supports Feishu, Slack, and Telegram. Slack and Telegram still use the final `runs.wait()` response path, while Feishu now streams through `runs.stream(["messages-tuple", "values"])` and updates a single in-thread card in place.
For Feishu card updates, DeerFlow stores the running card's `message_id` per inbound message and patches that same card until the run finishes, preserving the existing `OK` / `DONE` reaction flow. When a follow-up arrives inside an existing Feishu topic while another turn is still running, the later message now waits on the mapped DeerFlow `thread_id`, receives a queued/running card on that exact source message, and keeps a compact source-message blockquote in subsequent patches so rapid consecutive questions remain distinguishable. For Feishu card updates, DeerFlow stores the running card's `message_id` per inbound message and patches that same card until the run finishes, preserving the existing `OK` / `DONE` reaction flow.
--- ---
@ -192,7 +193,7 @@ export OPENAI_API_KEY="your-api-key-here"
**Full Application** (from project root): **Full Application** (from project root):
```bash ```bash
make dev # Starts Gateway + Frontend + Nginx make dev # Starts LangGraph + Gateway + Frontend + Nginx
``` ```
Access at: http://localhost:2026 Access at: http://localhost:2026
@ -200,23 +201,14 @@ Access at: http://localhost:2026
**Backend Only** (from backend directory): **Backend Only** (from backend directory):
```bash ```bash
# Gateway API + embedded agent runtime # Terminal 1: LangGraph server
make dev make dev
# Terminal 2: Gateway API
make gateway
``` ```
Direct access: Gateway at http://localhost:8001 Direct access: LangGraph at http://localhost:2024, Gateway at http://localhost:8001
**Terminal Workbench (TUI)** — a terminal-native UI over the embedded harness,
no services required:
```bash
uv pip install 'deerflow-harness[tui]' # optional 'textual' dependency
deerflow # launch the TUI
deerflow --print "summarize this repo" # headless one-shot
```
Sessions opened in the TUI appear in the Web UI sidebar (it writes the shared
`threads_meta` store under the local default user). See [docs/TUI.md](docs/TUI.md).
--- ---
@ -224,53 +216,40 @@ Sessions opened in the TUI appear in the Web UI sidebar (it writes the shared
``` ```
backend/ backend/
├── packages/harness/ # deerflow-harness package (import: deerflow.*) ├── src/
│ └── deerflow/ │ ├── agents/ # Agent system
│ ├── agents/ # Agent system │ │ ├── lead_agent/ # Main agent (factory, prompts)
│ │ ├── lead_agent/ # Main agent (factory, prompts) │ │ ├── middlewares/ # 9 middleware components
│ │ ├── middlewares/ # Middleware components │ │ ├── memory/ # Memory extraction & storage
│ │ ├── memory/ # Memory extraction & storage │ │ └── thread_state.py # ThreadState schema
│ │ └── thread_state.py # ThreadState schema │ ├── gateway/ # FastAPI Gateway API
│ ├── sandbox/ # Sandbox execution │ │ ├── app.py # Application setup
│ │ ├── local/ # Local filesystem provider │ │ └── routers/ # 6 route modules
│ │ ├── sandbox.py # Abstract interface │ ├── sandbox/ # Sandbox execution
│ │ ├── tools.py # bash, ls, read/write/str_replace │ │ ├── local/ # Local filesystem provider
│ │ └── middleware.py # Sandbox lifecycle │ │ ├── sandbox.py # Abstract interface
│ ├── subagents/ # Subagent delegation │ │ ├── tools.py # bash, ls, read/write/str_replace
│ │ ├── builtins/ # general-purpose, bash agents │ │ └── middleware.py # Sandbox lifecycle
│ │ ├── executor.py # Background execution engine │ ├── subagents/ # Subagent delegation
│ │ └── registry.py # Agent registry │ │ ├── builtins/ # general-purpose, bash agents
│ ├── tools/builtins/ # Built-in tools │ │ ├── executor.py # Background execution engine
│ ├── mcp/ # MCP protocol integration │ │ └── registry.py # Agent registry
│ ├── models/ # Model factory │ ├── tools/builtins/ # Built-in tools
│ ├── skills/ # Skill discovery & loading │ ├── mcp/ # MCP protocol integration
│ ├── config/ # Configuration system │ ├── models/ # Model factory
│ ├── runtime/ # Embedded run execution (RunManager, StreamBridge) │ ├── skills/ # Skill discovery & loading
│ ├── persistence/ # Checkpointer/store engines & schema migrations │ ├── config/ # Configuration system
│ ├── guardrails/ # Pre-tool-call authorization providers │ ├── community/ # Community tools & providers
│ ├── tracing/ # Tracer factory & trace metadata │ ├── reflection/ # Dynamic module loading
│ ├── uploads/ # Uploads manager │ └── utils/ # Utilities
│ ├── tui/ # Terminal UI (`deerflow` console script)
│ ├── community/ # Community tools & providers
│ ├── reflection/ # Dynamic module loading
│ └── utils/ # Utilities
├── app/ # FastAPI Gateway + IM channels (import: app.*)
│ ├── gateway/ # Gateway API
│ │ ├── app.py # Application setup
│ │ └── routers/ # Route modules
│ └── channels/ # IM channel integrations
├── docs/ # Documentation ├── docs/ # Documentation
├── tests/ # Test suite ├── tests/ # Test suite
├── langgraph.json # LangGraph graph registry for tooling/Studio compatibility ├── langgraph.json # LangGraph server configuration
├── pyproject.toml # Python dependencies ├── pyproject.toml # Python dependencies
├── Makefile # Development commands ├── Makefile # Development commands
└── Dockerfile # Container build └── Dockerfile # Container build
``` ```
`langgraph.json` is not the default service entrypoint. The scripts and Docker
deployments run the Gateway embedded runtime; the file is kept for LangGraph
tooling, Studio, or direct LangGraph Server compatibility.
--- ---
## Configuration ## Configuration
@ -319,26 +298,6 @@ MCP servers and skill states in a single file:
"client_id": "$MCP_OAUTH_CLIENT_ID", "client_id": "$MCP_OAUTH_CLIENT_ID",
"client_secret": "$MCP_OAUTH_CLIENT_SECRET" "client_secret": "$MCP_OAUTH_CLIENT_SECRET"
} }
},
"postgres": {
"enabled": false,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"],
"description": "PostgreSQL database access",
"routing": {
"mode": "prefer",
"priority": 50,
"keywords": ["orders", "users", "SQL", "database", "table"]
},
"tools": {
"query": {
"routing": {
"priority": 100,
"keywords": ["query database", "orders table", "metrics"]
}
}
}
} }
}, },
"skills": { "skills": {
@ -347,12 +306,6 @@ MCP servers and skill states in a single file:
} }
``` ```
`routing` adds soft MCP preference hints to the agent prompt. It helps the
model prefer a configured MCP tool for matching requests without forbidding
other tools. When `tool_search.enabled=true` defers MCP schemas, matching
routing metadata can auto-promote up to `tool_search.auto_promote_top_k`
deferred schemas before the model call.
### Environment Variables ### Environment Variables
- `DEER_FLOW_CONFIG_PATH` - Override config.yaml location - `DEER_FLOW_CONFIG_PATH` - Override config.yaml location
@ -409,40 +362,12 @@ If a provider is explicitly enabled but required credentials are missing, or the
```bash ```bash
make install # Install dependencies make install # Install dependencies
make dev # Run Gateway API + embedded agent runtime (port 8001) make dev # Run LangGraph server (port 2024)
make gateway # Run Gateway API without reload (port 8001) make gateway # Run Gateway API (port 8001)
make lint # Run linter (ruff) make lint # Run linter (ruff)
make format # Format code (ruff) make format # Format code (ruff)
make detect-blocking-io # Inventory blocking IO that may block the backend event loop
make migrate-rev MSG="..." # Autogenerate a new alembic revision against the live ORM models
``` ```
### Schema Migrations
DeerFlow's application tables (`runs`, `threads_meta`, `feedback`, `users`,
`run_events`, and the `channel_*` tables) are owned by alembic. The Gateway
runs `alembic upgrade head` automatically on startup via
`bootstrap_schema(engine, backend=...)`, so operators do not run `alembic`
manually in production. Bootstrap is concurrency-safe (Postgres advisory lock
across processes; per-engine `asyncio.Lock` inside one SQLite process) and
idempotent against pre-existing schemas (empty / legacy / versioned).
When you add or change an ORM model, ship the change as a new revision under
`packages/harness/deerflow/persistence/migrations/versions/`:
```bash
make migrate-rev MSG="add foo column to runs"
```
The target invokes `scripts/_autogen_revision.py`, which builds a fresh temp
SQLite at `head` and diffs the live models against it — so a clean checkout
does not need a pre-existing `./data/deerflow.db`. Review the generated file
and switch raw `op.add_column` / `op.drop_column` calls to the idempotent
helpers in `migrations/_helpers.py` before committing. There is no
`make migrate` / `make migrate-stamp` target on purpose — Gateway startup is
the only execution path, which keeps operational mistakes off the table. See
`backend/CLAUDE.md` (Schema Migrations) for the full design.
### Code Style ### Code Style
- **Linter/Formatter**: `ruff` - **Linter/Formatter**: `ruff`
@ -457,18 +382,6 @@ the only execution path, which keeps operational mistakes off the table. See
uv run pytest uv run pytest
``` ```
`make detect-blocking-io` statically scans backend business code for blocking
IO that may run on the backend event loop and is not test-coverage-bound. It
prints a concise summary for human review and writes complete JSON findings to
`.deer-flow/blocking-io-findings.json` at the repository root (regardless of
whether the target is invoked from the repo root or from `backend/`). JSON
findings include both broad IO category and review-oriented fields such as
`priority`, `location`, `blocking_call`, `event_loop_exposure`, `reason`, and
`code`. `priority` is a deterministic review ordering from the operation type,
not proof of a bug. Bare-name same-file calls are resolved by function name,
so duplicate helper names in one file can conservatively over-report async
reachability.
--- ---
## Technology Stack ## Technology Stack

View File

@ -2,7 +2,7 @@
Provides a pluggable channel system that connects external messaging platforms Provides a pluggable channel system that connects external messaging platforms
(Feishu/Lark, Slack, Telegram) to the DeerFlow agent via the ChannelManager, (Feishu/Lark, Slack, Telegram) to the DeerFlow agent via the ChannelManager,
which uses ``langgraph-sdk`` to communicate with Gateway's LangGraph-compatible API. which uses ``langgraph-sdk`` to communicate with the underlying LangGraph Server.
""" """
from app.channels.base import Channel from app.channels.base import Channel

View File

@ -2,20 +2,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable from typing import Any
from concurrent.futures import CancelledError as FutureCancelledError
from typing import Any, TypeVar
from app.channels.commands import extract_connect_code
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
T = TypeVar("T")
class Channel(ABC): class Channel(ABC):
"""Base class for all IM channel implementations. """Base class for all IM channel implementations.
@ -32,16 +26,11 @@ class Channel(ABC):
self.bus = bus self.bus = bus
self.config = config self.config = config
self._running = False self._running = False
self._connection_repo: Any = config.get("connection_repo")
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
return self._running return self._running
@property
def supports_streaming(self) -> bool:
return False
# -- lifecycle --------------------------------------------------------- # -- lifecycle ---------------------------------------------------------
@abstractmethod @abstractmethod
@ -72,66 +61,6 @@ class Channel(ABC):
# -- helpers ----------------------------------------------------------- # -- helpers -----------------------------------------------------------
async def _send_with_retry(
self,
operation: Callable[[], Awaitable[T]],
*,
max_retries: int,
log_prefix: str | None = None,
operation_name: str = "send",
) -> T:
"""Run an outbound send operation with the shared channel retry policy."""
prefix = log_prefix or f"[{self.name}]"
last_exc: Exception | None = None
for attempt in range(max_retries):
try:
return await operation()
except Exception as exc:
last_exc = exc
if attempt < max_retries - 1:
delay = 2**attempt
logger.warning(
"%s %s failed (attempt %d/%d), retrying in %ds: %s",
prefix,
operation_name,
attempt + 1,
max_retries,
delay,
exc,
)
await asyncio.sleep(delay)
logger.error("%s %s failed after %d attempts: %s", prefix, operation_name, max_retries, last_exc)
if last_exc is None:
raise RuntimeError(f"{self.name} {operation_name} failed without an exception from any attempt")
raise last_exc
def _log_future_error(self, fut: Any, name: str, msg_id: Any) -> None:
"""Callback for concurrent futures scheduled from channel worker threads."""
try:
exc = fut.exception()
except (asyncio.CancelledError, FutureCancelledError, asyncio.InvalidStateError):
return
except Exception:
logger.exception("[%s] failed to inspect future for %s (msg_id=%s)", self.name, name, msg_id)
return
if exc:
logger.error("[%s] %s failed for msg_id=%s: %s", self.name, name, msg_id, exc)
def _pending_connect_code(self, text: str) -> str | None:
"""Return the one-time bind code if *text* is a ``/connect <code>`` command
and channel connections are configured, else ``None``.
Adapters MUST consult this **before** applying their ``allowed_users`` /
``_check_user`` gate, so a browser-initiated bind can bootstrap an external
identity that the platform bot has never seen and is therefore not yet
authorized. (Telegram uses its deep-link ``/start <token>`` flow instead.)
"""
if self._connection_repo is None:
return None
return extract_connect_code(text)
def _make_inbound( def _make_inbound(
self, self,
chat_id: str, chat_id: str,
@ -178,7 +107,7 @@ class Channel(ABC):
except Exception: except Exception:
logger.exception("[%s] failed to upload file %s", self.name, attachment.filename) logger.exception("[%s] failed to upload file %s", self.name, attachment.filename)
async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage: async def receive_file(self, msg: InboundMessage, thread_id: str) -> InboundMessage:
""" """
Optionally process and materialize inbound file attachments for this channel. Optionally process and materialize inbound file attachments for this channel.
@ -190,10 +119,8 @@ class Channel(ABC):
Args: Args:
msg: The inbound message, possibly containing file metadata in msg.files. msg: The inbound message, possibly containing file metadata in msg.files.
thread_id: The resolved DeerFlow thread ID for sandbox path context. thread_id: The resolved DeerFlow thread ID for sandbox path context.
user_id: Optional DeerFlow storage user ID for user-scoped channel workers.
Returns: Returns:
The (possibly modified) InboundMessage, with text and/or files updated as needed. The (possibly modified) InboundMessage, with text and/or files updated as needed.
""" """
del user_id
return msg return msg

View File

@ -11,7 +11,6 @@ from __future__ import annotations
KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset( KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset(
{ {
"/bootstrap", "/bootstrap",
"/goal",
"/new", "/new",
"/status", "/status",
"/models", "/models",
@ -19,71 +18,3 @@ KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset(
"/help", "/help",
} }
) )
def _is_leading_mention_token(token: str) -> bool:
"""Return whether *token* looks like a platform bot/user mention.
Group chats often require ``@bot`` before the message is delivered. Slack
and Discord strip those tokens before connect parsing; Feishu / DingTalk
leave them in the text (``@_user_1``, ``@bot``, ``<@id>``). Treat them as
transport noise only when they lead the message so
``@bot /connect <code>`` still binds.
"""
if not token:
return False
# Slack / Discord style: <@U123> or <@!U123> or <@U123|name>
if token.startswith("<@") and token.endswith(">"):
return True
# Feishu / DingTalk / generic: @_user_1, @bot, @nickname
if token.startswith("@") and len(token) > 1:
return True
return False
def strip_leading_mentions(text: str) -> str:
"""Drop leading platform mention tokens (``@bot``, ``<@id>``) so a group-chat
``@bot /goal`` reads as ``/goal`` for command classification and dispatch.
A mention must be flush at the start (no preceding whitespace), mirroring the
"a control command must be at position 0" rule in :func:`is_known_channel_command`:
text with a leading space or no leading mention is returned unchanged, so
``" /new"`` stays a non-command. Whitespace is otherwise preserved (unlike
:func:`extract_connect_code`, which is deliberately whitespace-lenient for the
bind path). Channels that resolve their own bot id (Slack/Discord) strip only
the bot's mention upstream; this is for adapters that leave the mention in the
text and cannot tell the bot's mention from another user's (Feishu/DingTalk).
"""
remainder = text
while True:
parts = remainder.split(maxsplit=1)
if not parts or remainder[0].isspace() or not _is_leading_mention_token(parts[0]):
break
remainder = parts[1] if len(parts) > 1 else ""
return remainder
def extract_connect_code(text: str) -> str | None:
"""Extract the one-time channel binding code from a connect command.
Accepts a leading platform mention so group ``@bot /connect <code>``
messages bind the same way as bare ``/connect <code>`` (Slack/Discord
already strip mentions before calling this helper).
"""
parts = text.strip().split()
index = 0
while index < len(parts) and _is_leading_mention_token(parts[index]):
index += 1
if index + 1 >= len(parts):
return None
command = parts[index].lower()
if command == "/connect":
return parts[index + 1]
return None
def is_known_channel_command(text: str) -> bool:
"""Return whether text starts with a registered channel control command."""
if not text.startswith("/"):
return False
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS

View File

@ -1,44 +0,0 @@
"""Helpers for attaching persisted channel connection ownership to inbound messages."""
from __future__ import annotations
from typing import Any
from app.channels.message_bus import InboundMessage
async def attach_connection_identity(
inbound: InboundMessage,
*,
repo: Any,
provider: str,
workspace_id: str | None,
fallback_without_workspace: bool = False,
) -> InboundMessage:
"""Attach connection metadata to an inbound message when a persisted binding exists."""
if repo is None:
return inbound
workspace_candidates: list[str | None] = []
if workspace_id:
workspace_candidates.append(workspace_id)
if fallback_without_workspace:
workspace_candidates.append(None)
if not workspace_candidates:
return inbound
for candidate in workspace_candidates:
connection = await repo.find_connection_by_external_identity(
provider=provider,
external_account_id=inbound.user_id,
workspace_id=candidate,
)
if connection is None:
continue
inbound.connection_id = connection["id"]
inbound.owner_user_id = connection["owner_user_id"]
inbound.workspace_id = connection.get("workspace_id")
return inbound
return inbound

View File

@ -1,276 +0,0 @@
"""Inbound webhook dedupe store.
The manager-level inbound dedupe (``ChannelManager._inbound_dedupe_key``) guards an
agent run / final answer against provider redeliveries. The default store is an
in-process ``OrderedDict`` (backward compatible, single-pod). A shared store (e.g.
Postgres) can be injected for multi-pod deployments so a redelivery landing on a
different pod is still dropped as a duplicate. See issue #4120.
"""
from __future__ import annotations
import logging
import os
import time
from collections import OrderedDict
from typing import Any, Protocol
from sqlalchemy import text
logger = logging.getLogger(__name__)
INBOUND_DEDUPE_TTL_SECONDS = 10 * 60
INBOUND_DEDUPE_MAX_ENTRIES = 4096
# Key tuple matches ChannelManager._inbound_dedupe_key:
# (channel_name, workspace_id, chat_id, message_id).
InboundDedupeKey = tuple[str, str, str, str]
class InboundDedupeStore(Protocol):
"""Async contract for recording / releasing inbound dedupe keys.
``try_record`` returns ``True`` if the key already existed (duplicate -> drop)
and ``False`` if it was newly recorded or its prior entry had expired (proceed).
Shared-state implementations must be atomic; the Postgres variant uses a single
conditional upsert (``INSERT ... ON CONFLICT DO UPDATE ... WHERE first_seen < TTL``).
"""
async def try_record(self, key: InboundDedupeKey) -> bool: ...
async def release(self, key: InboundDedupeKey) -> None: ...
class MemoryInboundDedupeStore:
"""Process-local ``OrderedDict`` store — preserves the pre-#4120 behavior exactly."""
def __init__(
self,
ttl_seconds: int = INBOUND_DEDUPE_TTL_SECONDS,
max_entries: int = INBOUND_DEDUPE_MAX_ENTRIES,
) -> None:
self._ttl = ttl_seconds
self._max = max_entries
# Insertion order == chronological (keys are never re-inserted), so an
# OrderedDict lets us evict expired/overflow entries from the front in
# O(k) instead of scanning all entries on every inbound message.
self._store: OrderedDict[InboundDedupeKey, float] = OrderedDict()
async def try_record(self, key: InboundDedupeKey) -> bool:
now = time.monotonic()
# Entries are in chronological insertion order, so expired ones cluster at
# the front: pop from the front until we hit a still-live entry.
while self._store:
_, oldest_at = next(iter(self._store.items()))
if now - oldest_at > self._ttl:
self._store.popitem(last=False)
else:
break
while len(self._store) > self._max:
self._store.popitem(last=False)
if key in self._store:
return True
self._store[key] = now
return False
async def release(self, key: InboundDedupeKey) -> None:
self._store.pop(key, None)
class PostgresInboundDedupeStore:
"""Shared Postgres-backed dedupe store (issue #4120).
One row per dispatched inbound webhook (keyed by the 4-tuple). A redelivery
routed to a different gateway pod hits the same table. The acquire is a single
atomic conditional upsert:
INSERT ... ON CONFLICT (4-tuple) DO UPDATE SET first_seen = now()
WHERE first_seen < now() - TTL RETURNING channel
- No conflict -> row inserted -> proceed.
- Conflict + expired row -> DO UPDATE refreshes ``first_seen``, row RETURNED ->
proceed (honors the 10-minute ceiling and re-admits a never-released/expired
redelivery, e.g. a manual provider "Redeliver").
- Conflict + live row -> WHERE fails, no row RETURNED -> drop as duplicate.
Because the upsert is a single row-locked statement, two pods racing on the same
expired key cannot both proceed (one wins the update; the other sees the fresh row
and is dropped). Lazy cleanup (``DELETE`` of rows older than the TTL) runs in the
same transaction, amortized into the proceed path with no background task.
Fail-open: any DB error is logged and treated as "allow" so a storage
outage never drops a webhook or returns 5xx to the provider.
"""
def __init__(self, session_factory: Any | None = None) -> None:
# Injected in tests; otherwise resolved lazily from the app engine.
self._session_factory = session_factory
def _resolve_session_factory(self) -> Any:
if self._session_factory is not None:
return self._session_factory
from deerflow.persistence.engine import get_session_factory
sf = get_session_factory()
if sf is None:
raise RuntimeError("PostgresInboundDedupeStore requires a Postgres session factory")
return sf
async def try_record(self, key: InboundDedupeKey) -> bool:
channel, workspace_id, chat_id, message_id = key
try:
sf = self._resolve_session_factory()
async with sf() as session:
async with session.begin():
# Atomic acquire + TTL reclamation in ONE row-locked statement.
#
# - No conflict -> new row inserted, RETURNING a row -> proceed.
# - Conflict + the existing row is EXPIRED (first_seen older than
# the TTL) -> DO UPDATE refreshes first_seen to now() and the row
# is RETURNED, so the redelivery is re-admitted (proceed). This is
# the cross-pod-safe equivalent of the memory store's "evict
# expired entries before the membership check", and it honors the
# 10-minute ceiling of issue #4120 even for a row that was never
# released (e.g. a crashed run): a manual provider "Redeliver" of
# an old message is re-admitted instead of being dropped forever
# in a quiet deployment.
# - Conflict + the existing row is still LIVE -> the WHERE fails, no
# UPDATE, no row RETURNED -> duplicate -> drop.
#
# A single conditional upsert has no TOCTOU window (unlike a separate
# DELETE-then-INSERT), so two pods racing on the same expired key
# cannot both proceed: one wins the update and proceeds, the other
# sees the now-fresh row and is dropped.
result = await session.execute(
text(
"INSERT INTO webhook_deliveries "
"(channel, workspace_id, chat_id, message_id, first_seen) "
"VALUES (:c, :w, :ch, :m, now()) "
"ON CONFLICT (channel, workspace_id, chat_id, message_id) "
"DO UPDATE SET first_seen = now() "
"WHERE webhook_deliveries.first_seen < now() - make_interval(secs => :ttl) "
"RETURNING channel"
),
{
"c": channel,
"w": workspace_id,
"ch": chat_id,
"m": message_id,
"ttl": INBOUND_DEDUPE_TTL_SECONDS,
},
)
# A returned row means the key was admitted (new delivery, or an
# expired row that was re-admitted). No row means a live duplicate
# that must be dropped. RETURNING (not rowcount) is used because
# rowcount reliability for ON CONFLICT DO NOTHING/DO UPDATE varies
# across DB drivers.
inserted = result.fetchone() is not None
# Lazy cleanup in the same transaction: drop rows older than the
# TTL. Only when a row was admitted (proceed path) so the periodic
# sweep is amortized into normal inbound traffic and keys never
# re-accessed still get reclaimed.
if inserted:
await session.execute(
text("DELETE FROM webhook_deliveries WHERE first_seen < now() - make_interval(secs => :ttl)"),
{"ttl": INBOUND_DEDUPE_TTL_SECONDS},
)
# inserted=True -> admitted (proceed, not a duplicate).
return not inserted
except Exception:
# Fail-open: if the store is unavailable we must NOT drop the
# message. Return False so the caller treats it as a new delivery
# and proceeds (at worst a possible duplicate, never silent loss).
logger.exception("PostgresInboundDedupeStore.try_record failed; proceeding without dedupe (fail-open)")
return False
async def release(self, key: InboundDedupeKey) -> None:
channel, workspace_id, chat_id, message_id = key
try:
sf = self._resolve_session_factory()
async with sf() as session:
async with session.begin():
await session.execute(
text("DELETE FROM webhook_deliveries WHERE channel = :c AND workspace_id = :w AND chat_id = :ch AND message_id = :m"),
{"c": channel, "w": workspace_id, "ch": chat_id, "m": message_id},
)
except Exception:
logger.exception("PostgresInboundDedupeStore.release failed; key left for TTL expiry (fail-open)")
def _gateway_workers() -> int:
"""Mirror deps._enforce_postgres_for_multi_worker's worker detection."""
try:
return int(os.environ.get("GATEWAY_WORKERS", "1") or 1)
except (TypeError, ValueError):
return 1
def _build_postgres_store() -> InboundDedupeStore:
"""Build the shared Postgres dedupe store."""
return PostgresInboundDedupeStore()
def make_inbound_dedupe_store(app_config: Any | None = None) -> InboundDedupeStore:
"""Resolve the inbound dedupe store from app config.
- ``memory`` -> in-process store (per-pod; a redelivery routed to a different
replica is NOT deduped).
- ``postgres`` -> shared Postgres store. Requires ``database.backend='postgres'``;
if the application DB is not Postgres the store falls back to the in-process
memory store and logs a WARNING (otherwise cross-pod dedupe would be silently
disabled).
- ``auto`` (default) -> shared Postgres store whenever the application DB is
Postgres. This is the recommended setting for any deployment that may run more
than one replica, including Kubernetes with ``GATEWAY_WORKERS=1`` per pod where
multiple pods still share the single Postgres DB. For non-Postgres DBs ``auto``
falls back to the cheaper in-process memory store (correct for a single-DB,
single-pod deployment).
Emits a WARNING when a multi-worker/multi-replica deployment cannot use the shared
Postgres store (the cross-pod dedupe gap becomes an explicit misconfiguration
rather than silent default behavior).
"""
backend = "auto"
db_is_postgres = False
db_backend = None
if app_config is not None:
dedupe_cfg = getattr(app_config, "dedupe_storage", None)
if dedupe_cfg is not None:
backend = str(dedupe_cfg.backend.value if hasattr(dedupe_cfg.backend, "value") else dedupe_cfg.backend)
db = getattr(app_config, "database", None)
db_backend = getattr(db, "backend", None)
db_is_postgres = db_backend == "postgres"
multi_worker = _gateway_workers() > 1
if backend == "postgres":
if not db_is_postgres:
logger.warning(
"dedupe_storage=postgres requires database.backend='postgres' (got '%s'). Falling back to the in-process memory store; inbound webhook dedupe is per-pod and cross-pod redeliveries will NOT be deduped. See issue #4120.",
db_backend,
)
return MemoryInboundDedupeStore()
return _build_postgres_store()
if backend == "memory":
if multi_worker:
logger.warning(
"dedupe_storage=memory with GATEWAY_WORKERS>1: inbound webhook dedupe "
"is per-pod and will NOT drop redeliveries routed to a different replica. "
"Use dedupe_storage=postgres (or remove the setting to let 'auto' pick it) "
"for multi-worker deployments. See issue #4120."
)
return MemoryInboundDedupeStore()
# auto
if db_is_postgres:
logger.info("dedupe_storage=auto resolved to the shared Postgres store (database.backend=postgres); inbound webhook dedupe is shared across pods.")
return _build_postgres_store()
if multi_worker:
logger.warning(
"Multi-worker deployment detected but dedupe_storage=auto resolved to the "
"in-process memory store (application database is not Postgres). Inbound "
"webhook dedupe is per-pod and will NOT drop redeliveries routed to a different "
"replica. Set database.backend=postgres (required for multi-worker) so dedupe "
"shares state across pods. See issue #4120."
)
return MemoryInboundDedupeStore()

File diff suppressed because it is too large Load Diff

View File

@ -3,17 +3,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import io
import json
import logging import logging
import threading import threading
from pathlib import Path
from typing import Any from typing import Any
from app.channels.base import Channel from app.channels.base import Channel
from app.channels.commands import is_known_channel_command from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -26,12 +21,6 @@ class DiscordChannel(Channel):
Configuration keys (in ``config.yaml`` under ``channels.discord``): Configuration keys (in ``config.yaml`` under ``channels.discord``):
- ``bot_token``: Discord Bot token. - ``bot_token``: Discord Bot token.
- ``allowed_guilds``: (optional) List of allowed Discord guild IDs. Empty = allow all. - ``allowed_guilds``: (optional) List of allowed Discord guild IDs. Empty = allow all.
- ``mention_only``: (optional) If true, only respond when the bot is mentioned.
- ``allowed_channels``: (optional) List of channel IDs where messages are always accepted
(even when mention_only is true). Use for channels where you want the bot to respond
without mentions. Empty = mention_only applies everywhere.
- ``thread_mode``: (optional) If true, group a channel conversation into a thread.
Default: same as ``mention_only``.
""" """
def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None: def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None:
@ -43,29 +32,6 @@ class DiscordChannel(Channel):
self._allowed_guilds.add(int(guild_id)) self._allowed_guilds.add(int(guild_id))
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
self._mention_only: bool = bool(config.get("mention_only", False))
self._thread_mode: bool = config.get("thread_mode", self._mention_only)
self._allowed_channels: set[str] = set()
for channel_id in config.get("allowed_channels", []):
self._allowed_channels.add(str(channel_id))
# Session tracking: channel_id -> Discord thread_id (in-memory, persisted to JSON).
# Uses a dedicated JSON file separate from ChannelStore, which maps IM
# conversations to DeerFlow thread IDs — a different concern.
self._active_threads: dict[str, str] = {}
# Reverse-lookup set for O(1) thread ID checks (avoids O(n) scan of _active_threads.values()).
self._active_thread_ids: set[str] = set()
# Lock protecting _active_threads and the JSON file from concurrent access.
# _run_client (Discord loop thread) and the main thread both read/write.
self._thread_store_lock = threading.Lock()
store = config.get("channel_store")
if store is not None:
self._thread_store_path = store._path.parent / "discord_threads.json"
else:
self._thread_store_path = Path.home() / ".deer-flow" / "channels" / "discord_threads.json"
# Typing indicator management
self._typing_tasks: dict[str, asyncio.Task] = {}
self._client = None self._client = None
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
@ -109,78 +75,12 @@ class DiscordChannel(Channel):
self._thread = threading.Thread(target=self._run_client, daemon=True) self._thread = threading.Thread(target=self._run_client, daemon=True)
self._thread.start() self._thread.start()
await asyncio.to_thread(self._load_active_threads)
logger.info("Discord channel started") logger.info("Discord channel started")
def _load_active_threads(self) -> None:
"""Restore Discord thread mappings from the dedicated JSON file on startup."""
with self._thread_store_lock:
try:
if not self._thread_store_path.exists():
logger.debug("[Discord] no thread mappings file at %s", self._thread_store_path)
return
data = json.loads(self._thread_store_path.read_text())
self._active_threads.clear()
self._active_thread_ids.clear()
for channel_id, thread_id in data.items():
self._active_threads[channel_id] = thread_id
self._active_thread_ids.add(thread_id)
if self._active_threads:
logger.info("[Discord] restored %d thread mappings from %s", len(self._active_threads), self._thread_store_path)
except Exception:
logger.exception("[Discord] failed to load thread mappings")
def _record_thread_mapping(self, channel_id: str, thread_id: str) -> None:
"""Synchronously update the in-memory channel->thread mapping and its reverse-lookup set.
Runs on the event loop (no IO, no await) so a follow-up message in the
newly created thread is recognized immediately, before the offloaded
persistence write completes. Deferring this update into the worker
thread opened a window where ``_active_thread_ids`` had not yet been
updated and an inbound message was misclassified as orphaned (see the
#3927 review). Persistence is handled separately by
``_persist_thread_mappings``.
"""
old_id = self._active_threads.get(channel_id)
self._active_threads[channel_id] = thread_id
if old_id:
self._active_thread_ids.discard(old_id)
self._active_thread_ids.add(thread_id)
def _persist_thread_mappings(self) -> None:
"""Flush the current in-memory thread mappings to disk.
Intended for ``asyncio.to_thread``: this is pure filesystem IO. The
in-memory state is updated synchronously by ``_record_thread_mapping``,
so persistence latency never delays visibility of a new mapping to
inbound-message handling. The mapping is snapshotted under the store
lock so a concurrent record cannot mutate the dict mid-serialization.
"""
with self._thread_store_lock:
try:
snapshot = dict(self._active_threads)
self._thread_store_path.parent.mkdir(parents=True, exist_ok=True)
self._thread_store_path.write_text(json.dumps(snapshot, indent=2))
except Exception:
logger.exception("[Discord] failed to persist thread mappings")
@staticmethod
def _read_attachment_bytes(path: str) -> bytes:
"""Read an attachment file synchronously (intended for ``asyncio.to_thread``)."""
with open(path, "rb") as fp:
return fp.read()
async def stop(self) -> None: async def stop(self) -> None:
self._running = False self._running = False
self.bus.unsubscribe_outbound(self._on_outbound) self.bus.unsubscribe_outbound(self._on_outbound)
# Cancel all active typing indicator tasks
for target_id, task in list(self._typing_tasks.items()):
if not task.done():
task.cancel()
logger.debug("[Discord] cancelled typing task for target %s", target_id)
self._typing_tasks.clear()
if self._client and self._discord_loop and self._discord_loop.is_running(): if self._client and self._discord_loop and self._discord_loop.is_running():
close_future = asyncio.run_coroutine_threadsafe(self._client.close(), self._discord_loop) close_future = asyncio.run_coroutine_threadsafe(self._client.close(), self._discord_loop)
try: try:
@ -200,10 +100,6 @@ class DiscordChannel(Channel):
logger.info("Discord channel stopped") logger.info("Discord channel stopped")
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
# Stop typing indicator once we're sending the response
stop_future = asyncio.run_coroutine_threadsafe(self._stop_typing(msg.chat_id, msg.thread_ts), self._discord_loop)
await asyncio.wrap_future(stop_future)
target = await self._resolve_target(msg) target = await self._resolve_target(msg)
if target is None: if target is None:
logger.error("[Discord] target not found for chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts) logger.error("[Discord] target not found for chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts)
@ -215,9 +111,6 @@ class DiscordChannel(Channel):
await asyncio.wrap_future(send_future) await asyncio.wrap_future(send_future)
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
stop_future = asyncio.run_coroutine_threadsafe(self._stop_typing(msg.chat_id, msg.thread_ts), self._discord_loop)
await asyncio.wrap_future(stop_future)
target = await self._resolve_target(msg) target = await self._resolve_target(msg)
if target is None: if target is None:
logger.error("[Discord] target not found for file upload chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts) logger.error("[Discord] target not found for file upload chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts)
@ -227,13 +120,8 @@ class DiscordChannel(Channel):
return False return False
try: try:
# Read the attachment off the event loop (open + read are blocking IO), fp = open(str(attachment.actual_path), "rb") # noqa: SIM115
# then hand discord.py an in-memory buffer. The bytes are consumed while file = self._discord_module.File(fp, filename=attachment.filename)
# ``target.send`` runs on ``_discord_loop``; once that future resolves the
# buffer can be reclaimed, so this avoids leaking a file handle on both the
# success and failure paths.
data = await asyncio.to_thread(self._read_attachment_bytes, str(attachment.actual_path))
file = self._discord_module.File(io.BytesIO(data), filename=attachment.filename)
send_future = asyncio.run_coroutine_threadsafe(target.send(file=file), self._discord_loop) send_future = asyncio.run_coroutine_threadsafe(target.send(file=file), self._discord_loop)
await asyncio.wrap_future(send_future) await asyncio.wrap_future(send_future)
logger.info("[Discord] file uploaded: %s", attachment.filename) logger.info("[Discord] file uploaded: %s", attachment.filename)
@ -242,41 +130,6 @@ class DiscordChannel(Channel):
logger.exception("[Discord] failed to upload file: %s", attachment.filename) logger.exception("[Discord] failed to upload file: %s", attachment.filename)
return False return False
async def _start_typing(self, channel, chat_id: str, thread_ts: str | None = None) -> None:
"""Starts a loop to send periodic typing indicators."""
target_id = thread_ts or chat_id
if target_id in self._typing_tasks:
return # Already typing for this target
async def _typing_loop():
try:
while True:
try:
await channel.trigger_typing()
except Exception:
pass
await asyncio.sleep(10)
except asyncio.CancelledError:
pass
task = asyncio.create_task(_typing_loop())
self._typing_tasks[target_id] = task
async def _stop_typing(self, chat_id: str, thread_ts: str | None = None) -> None:
"""Stops the typing loop for a specific target."""
target_id = thread_ts or chat_id
task = self._typing_tasks.pop(target_id, None)
if task and not task.done():
task.cancel()
logger.debug("[Discord] stopped typing indicator for target %s", target_id)
async def _add_reaction(self, message) -> None:
"""Add a checkmark reaction to acknowledge the message was received."""
try:
await message.add_reaction("")
except Exception:
logger.debug("[Discord] failed to add reaction to message %s", message.id, exc_info=True)
async def _on_message(self, message) -> None: async def _on_message(self, message) -> None:
if not self._running or not self._client: if not self._running or not self._client:
return return
@ -299,150 +152,17 @@ class DiscordChannel(Channel):
if self._discord_module is None: if self._discord_module is None:
return return
# Determine whether the bot is mentioned in this message
user = self._client.user if self._client else None
if user:
bot_mention = user.mention # <@ID>
alt_mention = f"<@!{user.id}>" # <@!ID> (ping variant)
standard_mention = f"<@{user.id}>"
else:
bot_mention = None
alt_mention = None
standard_mention = ""
has_mention = (bot_mention and bot_mention in message.content) or (alt_mention and alt_mention in message.content) or (standard_mention and standard_mention in message.content)
# Strip mention from text for processing
if has_mention:
text = text.replace(bot_mention or "", "").replace(alt_mention or "", "").replace(standard_mention or "", "").strip()
# Don't return early if text is empty — still process the mention (e.g., create thread)
connect_code = self._pending_connect_code(text)
if connect_code and await self._bind_connection_from_connect_code(message, connect_code):
return
# --- Determine thread/channel routing and typing target ---
thread_id = None
chat_id = None
typing_target = None # The Discord object to type into
if isinstance(message.channel, self._discord_module.Thread): if isinstance(message.channel, self._discord_module.Thread):
# --- Message already inside a thread --- chat_id = str(message.channel.parent_id or message.channel.id)
thread_obj = message.channel thread_id = str(message.channel.id)
thread_id = str(thread_obj.id)
chat_id = str(thread_obj.parent_id or thread_obj.id)
typing_target = thread_obj
# If this is a known active thread, process normally
if thread_id in self._active_thread_ids:
msg_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
inbound = self._make_inbound(
chat_id=chat_id,
user_id=str(message.author.id),
text=text,
msg_type=msg_type,
thread_ts=thread_id,
metadata={
"guild_id": str(guild.id) if guild else None,
"channel_id": str(message.channel.id),
"message_id": str(message.id),
},
)
inbound.topic_id = thread_id
inbound = await self._attach_connection_identity(inbound, guild_id=str(guild.id) if guild else None)
self._publish(inbound)
# Start typing indicator in the thread
if typing_target:
asyncio.create_task(self._start_typing(typing_target, chat_id, thread_id))
asyncio.create_task(self._add_reaction(message))
return
# Thread not tracked (orphaned) — create new thread and handle below
logger.debug("[Discord] message in orphaned thread %s, will create new thread", thread_id)
thread_id = None
typing_target = None
# At this point we're guaranteed to be in a channel, not a thread
# (the Thread case is handled above). Apply mention_only for all
# non-thread messages — no special case needed.
channel_id = str(message.channel.id)
# Check if there's an active thread for this channel
if channel_id in self._active_threads:
# respect mention_only: if enabled, only process messages that mention the bot
# (unless the channel is in allowed_channels)
# Messages within a thread are always allowed through (continuation).
# At this code point we know the message is in a channel, not a thread
# (Thread case handled above), so always apply the check.
if self._mention_only and not has_mention and channel_id not in self._allowed_channels:
logger.debug("[Discord] skipping no-@ message in channel %s (not in thread)", channel_id)
return
# mention_only + fresh @ → create new thread instead of routing to existing one
if self._mention_only and has_mention:
thread_obj = await self._create_thread(message)
if thread_obj is not None:
target_thread_id = str(thread_obj.id)
self._record_thread_mapping(channel_id, target_thread_id)
await asyncio.to_thread(self._persist_thread_mappings)
thread_id = target_thread_id
chat_id = channel_id
typing_target = thread_obj
logger.info("[Discord] created new thread %s in channel %s on mention (replacing existing thread)", target_thread_id, channel_id)
else:
logger.info("[Discord] thread creation failed in channel %s, falling back to channel replies", channel_id)
thread_id = channel_id
chat_id = channel_id
typing_target = message.channel
else:
# Existing session → route to the existing thread
target_thread_id = self._active_threads[channel_id]
logger.debug("[Discord] routing message in channel %s to existing thread %s", channel_id, target_thread_id)
thread_id = target_thread_id
chat_id = channel_id
typing_target = await self._get_channel_or_thread(target_thread_id)
elif self._mention_only and not has_mention and channel_id not in self._allowed_channels:
# Not mentioned and not in an allowed channel → skip
logger.debug("[Discord] skipping message without mention in channel %s", channel_id)
return
elif self._mention_only and has_mention:
# First mention in this channel → create thread
thread_obj = await self._create_thread(message)
if thread_obj is not None:
target_thread_id = str(thread_obj.id)
self._record_thread_mapping(channel_id, target_thread_id)
await asyncio.to_thread(self._persist_thread_mappings)
thread_id = target_thread_id
chat_id = channel_id
typing_target = thread_obj # Type into the new thread
logger.info("[Discord] created thread %s in channel %s for user %s", target_thread_id, channel_id, message.author.display_name)
else:
# Fallback: thread creation failed (disabled/permissions), reply in channel
logger.info("[Discord] thread creation failed in channel %s, falling back to channel replies", channel_id)
thread_id = channel_id
chat_id = channel_id
typing_target = message.channel # Type into the channel
elif self._thread_mode:
# thread_mode but mention_only is False → create thread anyway for conversation grouping
thread_obj = await self._create_thread(message)
if thread_obj is None:
# Thread creation failed (disabled/permissions), fall back to channel replies
logger.info("[Discord] thread creation failed in channel %s, falling back to channel replies", channel_id)
thread_id = channel_id
chat_id = channel_id
typing_target = message.channel # Type into the channel
else:
target_thread_id = str(thread_obj.id)
self._record_thread_mapping(channel_id, target_thread_id)
await asyncio.to_thread(self._persist_thread_mappings)
thread_id = target_thread_id
chat_id = channel_id
typing_target = thread_obj # Type into the new thread
else: else:
# No threading — reply directly in channel thread = await self._create_thread(message)
thread_id = channel_id if thread is None:
chat_id = channel_id return
typing_target = message.channel # Type into the channel chat_id = str(message.channel.id)
thread_id = str(thread.id)
msg_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
inbound = self._make_inbound( inbound = self._make_inbound(
chat_id=chat_id, chat_id=chat_id,
user_id=str(message.author.id), user_id=str(message.author.id),
@ -456,75 +176,11 @@ class DiscordChannel(Channel):
}, },
) )
inbound.topic_id = thread_id inbound.topic_id = thread_id
inbound = await self._attach_connection_identity(inbound, guild_id=str(guild.id) if guild else None)
# Start typing indicator in the correct target (thread or channel)
if typing_target:
asyncio.create_task(self._start_typing(typing_target, chat_id, thread_id))
self._publish(inbound)
asyncio.create_task(self._add_reaction(message))
def _publish(self, inbound) -> None:
"""Publish an inbound message to the main event loop."""
if self._main_loop and self._main_loop.is_running(): if self._main_loop and self._main_loop.is_running():
future = asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._main_loop) future = asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._main_loop)
future.add_done_callback(lambda f: logger.exception("[Discord] publish_inbound failed", exc_info=f.exception()) if f.exception() else None) future.add_done_callback(lambda f: logger.exception("[Discord] publish_inbound failed", exc_info=f.exception()) if f.exception() else None)
async def _attach_connection_identity(self, inbound: InboundMessage, guild_id: str | None = None) -> InboundMessage:
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="discord",
workspace_id=guild_id,
fallback_without_workspace=True,
)
async def _bind_connection_from_connect_code(self, message, code: str) -> bool:
if self._connection_repo is None or not code:
return False
state = await self._connection_repo.consume_oauth_state(provider="discord", state=code)
if state is None:
await self._send_connection_reply(message, "Discord connection code is invalid or expired.")
return True
guild = getattr(message, "guild", None)
channel = getattr(message, "channel", None)
author = getattr(message, "author", None)
user_id = str(getattr(author, "id", "") or "")
if not user_id:
await self._send_connection_reply(message, "Discord connection could not be completed from this message.")
return True
guild_id = str(getattr(guild, "id", "") or "") or None
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="discord",
external_account_id=user_id,
external_account_name=getattr(author, "display_name", None) or getattr(author, "name", None),
workspace_id=guild_id,
workspace_name=getattr(guild, "name", None) if guild is not None else None,
metadata={
"guild_id": guild_id,
"channel_id": str(getattr(channel, "id", "") or ""),
},
status="connected",
)
await self._send_connection_reply(message, "Discord connected to DeerFlow.")
return True
@staticmethod
async def _send_connection_reply(message, text: str) -> None:
channel = getattr(message, "channel", None)
send = getattr(channel, "send", None)
if send is None:
return
try:
await send(text)
except Exception:
logger.exception("[Discord] failed to send connection reply")
def _run_client(self) -> None: def _run_client(self) -> None:
self._discord_loop = asyncio.new_event_loop() self._discord_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._discord_loop) asyncio.set_event_loop(self._discord_loop)
@ -542,40 +198,14 @@ class DiscordChannel(Channel):
async def _create_thread(self, message): async def _create_thread(self, message):
try: try:
if self._discord_module is None:
return None
# Only TextChannel (type 0) and NewsChannel (type 10) support threads
channel_type = message.channel.type
if channel_type not in (
self._discord_module.ChannelType.text,
self._discord_module.ChannelType.news,
):
logger.info(
"[Discord] channel type %s (%s) does not support threads",
channel_type.value,
channel_type.name,
)
return None
thread_name = f"deerflow-{message.author.display_name}-{message.id}"[:100] thread_name = f"deerflow-{message.author.display_name}-{message.id}"[:100]
return await message.create_thread(name=thread_name) return await message.create_thread(name=thread_name)
except self._discord_module.errors.HTTPException as exc:
if exc.code == 50024:
logger.info(
"[Discord] cannot create thread in channel %s (error code 50024): %s",
message.channel.id,
channel_type.name if (channel_type := message.channel.type) else "unknown",
)
else:
logger.exception(
"[Discord] failed to create thread for message=%s (HTTPException %s)",
message.id,
exc.code,
)
return None
except Exception: except Exception:
logger.exception("[Discord] failed to create thread for message=%s (threads may be disabled or missing permissions)", message.id) logger.exception("[Discord] failed to create thread for message=%s (threads may be disabled or missing permissions)", message.id)
try:
await message.channel.send("Could not create a thread for your message. Please check that threads are enabled in this channel.")
except Exception:
pass
return None return None
async def _resolve_target(self, msg: OutboundMessage): async def _resolve_target(self, msg: OutboundMessage):

View File

@ -7,33 +7,21 @@ import json
import logging import logging
import re import re
import threading import threading
import time
from typing import Any, Literal from typing import Any, Literal
from app.channels.base import Channel from app.channels.base import Channel
from app.channels.commands import is_known_channel_command, strip_leading_mentions from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from app.channels.connection_identity import attach_connection_identity from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
from app.channels.message_bus import (
PENDING_CLARIFICATION_METADATA_KEY,
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY,
InboundMessage,
InboundMessageType,
MessageBus,
OutboundMessage,
ResolvedAttachment,
)
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.sandbox_provider import get_sandbox_provider from deerflow.sandbox.sandbox_provider import get_sandbox_provider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PENDING_CLARIFICATION_TTL_SECONDS = 30 * 60
FEISHU_INBOUND_BATCH_WINDOW_SECONDS = 0.75
SOURCE_PREVIEW_METADATA_KEY = "feishu_source_preview"
def _is_feishu_command(text: str) -> bool: def _is_feishu_command(text: str) -> bool:
return is_known_channel_command(text) if not text.startswith("/"):
return False
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS
class FeishuChannel(Channel): class FeishuChannel(Channel):
@ -48,9 +36,9 @@ class FeishuChannel(Channel):
Message flow: Message flow:
1. User sends a message bot adds "OK" emoji reaction 1. User sends a message bot adds "OK" emoji reaction
2. Bot replies with a card: "Working on it......" 2. Bot replies in thread: "Working on it......"
3. Agent processes the message and returns a result 3. Agent processes the message and returns a result
4. Bot updates the card with the result 4. Bot replies in thread with the result
5. Bot adds "DONE" emoji reaction to the original message 5. Bot adds "DONE" emoji reaction to the original message
""" """
@ -67,8 +55,6 @@ class FeishuChannel(Channel):
self._background_tasks: set[asyncio.Task] = set() self._background_tasks: set[asyncio.Task] = set()
self._running_card_ids: dict[str, str] = {} self._running_card_ids: dict[str, str] = {}
self._running_card_tasks: dict[str, asyncio.Task] = {} self._running_card_tasks: dict[str, asyncio.Task] = {}
self._pending_clarifications: dict[tuple[str, str], list[dict[str, Any]]] = {}
self._pending_inbound_batches: dict[tuple[str, str], dict[str, Any]] = {}
self._CreateFileRequest = None self._CreateFileRequest = None
self._CreateFileRequestBody = None self._CreateFileRequestBody = None
self._CreateImageRequest = None self._CreateImageRequest = None
@ -76,76 +62,6 @@ class FeishuChannel(Channel):
self._GetMessageResourceRequest = None self._GetMessageResourceRequest = None
self._thread_lock = threading.Lock() self._thread_lock = threading.Lock()
@staticmethod
def _non_empty_str(value: Any) -> str | None:
if isinstance(value, str) and value.strip():
return value.strip()
return None
@staticmethod
def _pending_key(chat_id: str, user_id: str) -> tuple[str, str]:
return (chat_id, user_id)
@staticmethod
def _should_include_source_preview(
*,
chat_type: str | None,
root_id: str | None,
parent_id: str | None,
thread_id: str | None,
) -> bool:
if chat_type == "p2p":
return False
return bool(root_id or parent_id or thread_id)
@staticmethod
def _compact_source_preview(text: str) -> str | None:
stripped = text.strip()
if not stripped:
return None
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
if not lines:
return None
preview = "\n".join(lines[:3])
if len(preview) > 240:
preview = preview[:237].rstrip() + "..."
return preview
@classmethod
def _compose_card_text(cls, text: str, metadata: dict[str, Any] | None = None) -> str:
preview = None
if isinstance(metadata, dict):
raw_preview = metadata.get(SOURCE_PREVIEW_METADATA_KEY)
if isinstance(raw_preview, str) and raw_preview.strip():
preview = raw_preview.strip()
if not preview:
return text
quoted_preview = "\n".join(f"> {line}" for line in preview.splitlines())
return f"{quoted_preview}\n\n{text}"
@property
def supports_streaming(self) -> bool:
return True
@property
def is_running(self) -> bool:
if not self._running:
return False
return self._thread is not None and self._thread.is_alive()
def _build_event_handler(self, lark):
return (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(self._on_message)
.register_p2_im_message_message_read_v1(self._on_ignored_message_event)
.register_p2_im_message_reaction_created_v1(self._on_ignored_message_event)
.register_p2_im_message_reaction_deleted_v1(self._on_ignored_message_event)
.register_p2_im_message_recalled_v1(self._on_ignored_message_event)
.build()
)
async def start(self) -> None: async def start(self) -> None:
if self._running: if self._running:
return return
@ -239,7 +155,7 @@ class FeishuChannel(Channel):
# thread's uvloop. # thread's uvloop.
_ws_client_mod.loop = loop _ws_client_mod.loop = loop
event_handler = self._build_event_handler(lark) event_handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(self._on_message).build()
ws_client = lark.ws.Client( ws_client = lark.ws.Client(
app_id=app_id, app_id=app_id,
app_secret=app_secret, app_secret=app_secret,
@ -251,10 +167,6 @@ class FeishuChannel(Channel):
except Exception: except Exception:
if self._running: if self._running:
logger.exception("Feishu WebSocket error") logger.exception("Feishu WebSocket error")
self._running = False
def _on_ignored_message_event(self, event) -> None:
logger.debug("[Feishu] ignoring non-content message event: %s", type(event).__name__)
async def stop(self) -> None: async def stop(self) -> None:
self._running = False self._running = False
@ -282,11 +194,28 @@ class FeishuChannel(Channel):
len(msg.text), len(msg.text),
) )
await self._send_with_retry( last_exc: Exception | None = None
lambda: self._send_card_message(msg), for attempt in range(_max_retries):
max_retries=_max_retries, try:
log_prefix="[Feishu]", await self._send_card_message(msg)
) return # success
except Exception as exc:
last_exc = exc
if attempt < _max_retries - 1:
delay = 2**attempt # 1s, 2s
logger.warning(
"[Feishu] send failed (attempt %d/%d), retrying in %ds: %s",
attempt + 1,
_max_retries,
delay,
exc,
)
await asyncio.sleep(delay)
logger.error("[Feishu] send failed after %d attempts: %s", _max_retries, last_exc)
if last_exc is None:
raise RuntimeError("Feishu send failed without an exception from any attempt")
raise last_exc
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
if not self._api_client: if not self._api_client:
@ -311,13 +240,11 @@ class FeishuChannel(Channel):
content = json.dumps({"file_key": file_key}) content = json.dumps({"file_key": file_key})
if msg.thread_ts: if msg.thread_ts:
request = self._ReplyMessageRequest.builder().message_id(msg.thread_ts).request_body(self._ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build()).build() request = self._ReplyMessageRequest.builder().message_id(msg.thread_ts).request_body(self._ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).reply_in_thread(True).build()).build()
response = await asyncio.to_thread(self._api_client.im.v1.message.reply, request) await asyncio.to_thread(self._api_client.im.v1.message.reply, request)
else: else:
request = self._CreateMessageRequest.builder().receive_id_type("chat_id").request_body(self._CreateMessageRequestBody.builder().receive_id(msg.chat_id).msg_type(msg_type).content(content).build()).build() request = self._CreateMessageRequest.builder().receive_id_type("chat_id").request_body(self._CreateMessageRequestBody.builder().receive_id(msg.chat_id).msg_type(msg_type).content(content).build()).build()
response = await asyncio.to_thread(self._api_client.im.v1.message.create, request) await asyncio.to_thread(self._api_client.im.v1.message.create, request)
if not response.success():
raise RuntimeError(f"Feishu file send failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}")
logger.info("[Feishu] file sent: %s (type=%s)", attachment.filename, msg_type) logger.info("[Feishu] file sent: %s (type=%s)", attachment.filename, msg_type)
return True return True
@ -355,7 +282,7 @@ class FeishuChannel(Channel):
raise RuntimeError(f"Feishu file upload failed: code={response.code}, msg={response.msg}") raise RuntimeError(f"Feishu file upload failed: code={response.code}, msg={response.msg}")
return response.data.file_key return response.data.file_key
async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage: async def receive_file(self, msg: InboundMessage, thread_id: str) -> InboundMessage:
"""Download a Feishu file into the thread uploads directory. """Download a Feishu file into the thread uploads directory.
Returns the sandbox virtual path when the image is persisted successfully. Returns the sandbox virtual path when the image is persisted successfully.
@ -370,23 +297,15 @@ class FeishuChannel(Channel):
text = msg.text text = msg.text
for file in files: for file in files:
if file.get("image_key"): if file.get("image_key"):
virtual_path = await self._receive_single_file(msg.thread_ts, file["image_key"], "image", thread_id, user_id=user_id) virtual_path = await self._receive_single_file(msg.thread_ts, file["image_key"], "image", thread_id)
text = text.replace("[image]", virtual_path, 1) text = text.replace("[image]", virtual_path, 1)
elif file.get("file_key"): elif file.get("file_key"):
virtual_path = await self._receive_single_file(msg.thread_ts, file["file_key"], "file", thread_id, user_id=user_id) virtual_path = await self._receive_single_file(msg.thread_ts, file["file_key"], "file", thread_id)
text = text.replace("[file]", virtual_path, 1) text = text.replace("[file]", virtual_path, 1)
msg.text = text msg.text = text
return msg return msg
async def _receive_single_file( async def _receive_single_file(self, message_id: str, file_key: str, type: Literal["image", "file"], thread_id: str) -> str:
self,
message_id: str,
file_key: str,
type: Literal["image", "file"],
thread_id: str,
*,
user_id: str | None = None,
) -> str:
request = self._GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(type).build() request = self._GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(type).build()
def inner(): def inner():
@ -425,9 +344,8 @@ class FeishuChannel(Channel):
return f"Failed to obtain the [{type}]" return f"Failed to obtain the [{type}]"
paths = get_paths() paths = get_paths()
effective_user_id = user_id or get_effective_user_id() paths.ensure_thread_dirs(thread_id)
paths.ensure_thread_dirs(thread_id, user_id=effective_user_id) uploads_dir = paths.sandbox_uploads_dir(thread_id).resolve()
uploads_dir = paths.sandbox_uploads_dir(thread_id, user_id=effective_user_id).resolve()
ext = "png" if type == "image" else "bin" ext = "png" if type == "image" else "bin"
raw_filename = getattr(response, "file_name", "") or f"feishu_{file_key[-12:]}.{ext}" raw_filename = getattr(response, "file_name", "") or f"feishu_{file_key[-12:]}.{ext}"
@ -456,7 +374,7 @@ class FeishuChannel(Channel):
try: try:
sandbox_provider = get_sandbox_provider() sandbox_provider = get_sandbox_provider()
sandbox_id = sandbox_provider.acquire(thread_id, user_id=effective_user_id) sandbox_id = sandbox_provider.acquire(thread_id)
if sandbox_id != "local": if sandbox_id != "local":
sandbox = sandbox_provider.get(sandbox_id) sandbox = sandbox_provider.get(sandbox_id)
if sandbox is None: if sandbox is None:
@ -493,17 +411,7 @@ class FeishuChannel(Channel):
return return
try: try:
request = self._CreateMessageReactionRequest.builder().message_id(message_id).request_body(self._CreateMessageReactionRequestBody.builder().reaction_type(self._Emoji.builder().emoji_type(emoji_type).build()).build()).build() request = self._CreateMessageReactionRequest.builder().message_id(message_id).request_body(self._CreateMessageReactionRequestBody.builder().reaction_type(self._Emoji.builder().emoji_type(emoji_type).build()).build()).build()
response = await asyncio.to_thread(self._api_client.im.v1.message_reaction.create, request) await asyncio.to_thread(self._api_client.im.v1.message_reaction.create, request)
if not response.success():
logger.warning(
"[Feishu] reaction '%s' add failed for message %s: code=%s, msg=%s, log_id=%s",
emoji_type,
message_id,
response.code,
response.msg,
response.get_log_id(),
)
return
logger.info("[Feishu] reaction '%s' added to message %s", emoji_type, message_id) logger.info("[Feishu] reaction '%s' added to message %s", emoji_type, message_id)
except Exception: except Exception:
logger.exception("[Feishu] failed to add reaction '%s' to message %s", emoji_type, message_id) logger.exception("[Feishu] failed to add reaction '%s' to message %s", emoji_type, message_id)
@ -514,10 +422,8 @@ class FeishuChannel(Channel):
return None return None
content = self._build_card_content(text) content = self._build_card_content(text)
request = self._ReplyMessageRequest.builder().message_id(message_id).request_body(self._ReplyMessageRequestBody.builder().msg_type("interactive").content(content).build()).build() request = self._ReplyMessageRequest.builder().message_id(message_id).request_body(self._ReplyMessageRequestBody.builder().msg_type("interactive").content(content).reply_in_thread(True).build()).build()
response = await asyncio.to_thread(self._api_client.im.v1.message.reply, request) response = await asyncio.to_thread(self._api_client.im.v1.message.reply, request)
if not response.success():
raise RuntimeError(f"Feishu card reply failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}")
response_data = getattr(response, "data", None) response_data = getattr(response, "data", None)
return getattr(response_data, "message_id", None) return getattr(response_data, "message_id", None)
@ -528,9 +434,7 @@ class FeishuChannel(Channel):
content = self._build_card_content(text) content = self._build_card_content(text)
request = self._CreateMessageRequest.builder().receive_id_type("chat_id").request_body(self._CreateMessageRequestBody.builder().receive_id(chat_id).msg_type("interactive").content(content).build()).build() request = self._CreateMessageRequest.builder().receive_id_type("chat_id").request_body(self._CreateMessageRequestBody.builder().receive_id(chat_id).msg_type("interactive").content(content).build()).build()
response = await asyncio.to_thread(self._api_client.im.v1.message.create, request) await asyncio.to_thread(self._api_client.im.v1.message.create, request)
if not response.success():
raise RuntimeError(f"Feishu card creation failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}")
async def _update_card(self, message_id: str, text: str) -> None: async def _update_card(self, message_id: str, text: str) -> None:
"""Patch an existing card message in place.""" """Patch an existing card message in place."""
@ -539,9 +443,7 @@ class FeishuChannel(Channel):
content = self._build_card_content(text) content = self._build_card_content(text)
request = self._PatchMessageRequest.builder().message_id(message_id).request_body(self._PatchMessageRequestBody.builder().content(content).build()).build() request = self._PatchMessageRequest.builder().message_id(message_id).request_body(self._PatchMessageRequestBody.builder().content(content).build()).build()
response = await asyncio.to_thread(self._api_client.im.v1.message.patch, request) await asyncio.to_thread(self._api_client.im.v1.message.patch, request)
if not response.success():
raise RuntimeError(f"Feishu card update failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}")
def _track_background_task(self, task: asyncio.Task, *, name: str, msg_id: str) -> None: def _track_background_task(self, task: asyncio.Task, *, name: str, msg_id: str) -> None:
"""Keep a strong reference to fire-and-forget tasks and surface errors.""" """Keep a strong reference to fire-and-forget tasks and surface errors."""
@ -552,15 +454,9 @@ class FeishuChannel(Channel):
self._background_tasks.discard(task) self._background_tasks.discard(task)
self._log_task_error(task, name, msg_id) self._log_task_error(task, name, msg_id)
async def _create_running_card( async def _create_running_card(self, source_message_id: str, text: str) -> str | None:
self,
source_message_id: str,
text: str,
*,
metadata: dict[str, Any] | None = None,
) -> str | None:
"""Create the running card and cache its message ID when available.""" """Create the running card and cache its message ID when available."""
running_card_id = await self._reply_card(source_message_id, self._compose_card_text(text, metadata)) running_card_id = await self._reply_card(source_message_id, text)
if running_card_id: if running_card_id:
self._running_card_ids[source_message_id] = running_card_id self._running_card_ids[source_message_id] = running_card_id
logger.info("[Feishu] running card created: source=%s card=%s", source_message_id, running_card_id) logger.info("[Feishu] running card created: source=%s card=%s", source_message_id, running_card_id)
@ -568,13 +464,7 @@ class FeishuChannel(Channel):
logger.warning("[Feishu] running card creation returned no message_id for source=%s, subsequent updates will fall back to new replies", source_message_id) logger.warning("[Feishu] running card creation returned no message_id for source=%s, subsequent updates will fall back to new replies", source_message_id)
return running_card_id return running_card_id
def _ensure_running_card_started( def _ensure_running_card_started(self, source_message_id: str, text: str = "Working on it...") -> asyncio.Task | None:
self,
source_message_id: str,
text: str = "thinking...",
*,
metadata: dict[str, Any] | None = None,
) -> asyncio.Task | None:
"""Start running-card creation once per source message.""" """Start running-card creation once per source message."""
running_card_id = self._running_card_ids.get(source_message_id) running_card_id = self._running_card_ids.get(source_message_id)
if running_card_id: if running_card_id:
@ -584,7 +474,7 @@ class FeishuChannel(Channel):
if running_card_task: if running_card_task:
return running_card_task return running_card_task
running_card_task = asyncio.create_task(self._create_running_card(source_message_id, text, metadata=metadata)) running_card_task = asyncio.create_task(self._create_running_card(source_message_id, text))
self._running_card_tasks[source_message_id] = running_card_task self._running_card_tasks[source_message_id] = running_card_task
running_card_task.add_done_callback(lambda done_task, mid=source_message_id: self._finalize_running_card_task(mid, done_task)) running_card_task.add_done_callback(lambda done_task, mid=source_message_id: self._finalize_running_card_task(mid, done_task))
return running_card_task return running_card_task
@ -594,31 +484,21 @@ class FeishuChannel(Channel):
self._running_card_tasks.pop(source_message_id, None) self._running_card_tasks.pop(source_message_id, None)
self._log_task_error(task, "create_running_card", source_message_id) self._log_task_error(task, "create_running_card", source_message_id)
async def _ensure_running_card( async def _ensure_running_card(self, source_message_id: str, text: str = "Working on it...") -> str | None:
self,
source_message_id: str,
text: str = "thinking...",
*,
metadata: dict[str, Any] | None = None,
) -> str | None:
"""Ensure the in-thread running card exists and track its message ID.""" """Ensure the in-thread running card exists and track its message ID."""
running_card_id = self._running_card_ids.get(source_message_id) running_card_id = self._running_card_ids.get(source_message_id)
if running_card_id: if running_card_id:
return running_card_id return running_card_id
running_card_task = self._ensure_running_card_started( running_card_task = self._ensure_running_card_started(source_message_id, text)
source_message_id,
text,
metadata=metadata,
)
if running_card_task is None: if running_card_task is None:
return self._running_card_ids.get(source_message_id) return self._running_card_ids.get(source_message_id)
return await running_card_task return await running_card_task
async def _send_running_reply(self, message_id: str, *, metadata: dict[str, Any] | None = None) -> None: async def _send_running_reply(self, message_id: str) -> None:
"""Reply to a message in-thread with a running card.""" """Reply to a message in-thread with a running card."""
try: try:
await self._ensure_running_card(message_id, metadata=metadata) await self._ensure_running_card(message_id)
except Exception: except Exception:
logger.exception("[Feishu] failed to send running reply for message %s", message_id) logger.exception("[Feishu] failed to send running reply for message %s", message_id)
@ -636,9 +516,8 @@ class FeishuChannel(Channel):
running_card_id = await running_card_task running_card_id = await running_card_task
if running_card_id: if running_card_id:
card_text = self._compose_card_text(msg.text, msg.metadata)
try: try:
await self._update_card(running_card_id, card_text) await self._update_card(running_card_id, msg.text)
except Exception: except Exception:
if not msg.is_final: if not msg.is_final:
raise raise
@ -646,32 +525,18 @@ class FeishuChannel(Channel):
"[Feishu] failed to patch running card %s, falling back to final reply", "[Feishu] failed to patch running card %s, falling back to final reply",
running_card_id, running_card_id,
) )
fallback_card_id = await self._reply_card(source_message_id, card_text) await self._reply_card(source_message_id, msg.text)
self._remember_thread_mapping(msg, source_message_id, fallback_card_id)
self._remember_pending_clarification(msg, fallback_card_id)
else: else:
self._remember_thread_mapping(msg, source_message_id, running_card_id)
self._remember_pending_clarification(msg, running_card_id)
logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id) logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id)
elif msg.is_final: elif msg.is_final:
final_card_id = await self._reply_card( await self._reply_card(source_message_id, msg.text)
source_message_id,
self._compose_card_text(msg.text, msg.metadata),
)
self._remember_thread_mapping(msg, source_message_id, final_card_id)
self._remember_pending_clarification(msg, final_card_id)
elif awaited_running_card_task: elif awaited_running_card_task:
logger.warning( logger.warning(
"[Feishu] running card task finished without message_id for source=%s, skipping duplicate non-final creation", "[Feishu] running card task finished without message_id for source=%s, skipping duplicate non-final creation",
source_message_id, source_message_id,
) )
else: else:
created_card_id = await self._ensure_running_card( await self._ensure_running_card(source_message_id, msg.text)
source_message_id,
msg.text,
metadata=msg.metadata,
)
self._remember_thread_mapping(msg, source_message_id, created_card_id)
if msg.is_final: if msg.is_final:
self._running_card_ids.pop(source_message_id, None) self._running_card_ids.pop(source_message_id, None)
@ -682,235 +547,15 @@ class FeishuChannel(Channel):
# -- internal ---------------------------------------------------------- # -- internal ----------------------------------------------------------
def _remember_thread_mapping(self, msg: OutboundMessage, *topic_ids: str | None) -> None:
store = self.config.get("channel_store")
if store is None or not msg.thread_id:
return
metadata_topic_ids = [
msg.metadata.get("message_id"),
msg.metadata.get("root_id"),
msg.metadata.get("parent_id"),
msg.metadata.get("thread_id"),
msg.metadata.get("topic_id"),
]
user_id = ""
raw_user_id = msg.metadata.get("user_id")
if isinstance(raw_user_id, str):
user_id = raw_user_id
seen: set[str] = set()
for topic_id in [*topic_ids, *metadata_topic_ids]:
topic_id = self._non_empty_str(topic_id)
if not topic_id or topic_id in seen:
continue
seen.add(topic_id)
try:
store.set_thread_id(
self.name,
msg.chat_id,
msg.thread_id,
topic_id=topic_id,
user_id=user_id,
)
except Exception:
logger.exception("[Feishu] failed to remember thread mapping for topic_id=%s", topic_id)
def _remember_pending_clarification(self, msg: OutboundMessage, card_message_id: str | None) -> None:
if not msg.is_final or msg.metadata.get(PENDING_CLARIFICATION_METADATA_KEY) is not True:
return
user_id = self._non_empty_str(msg.metadata.get("user_id"))
topic_id = self._non_empty_str(msg.metadata.get("topic_id"))
source_message_id = self._non_empty_str(msg.thread_ts) or self._non_empty_str(msg.metadata.get("message_id"))
if not (user_id and topic_id and msg.thread_id and source_message_id and card_message_id):
return
key = self._pending_key(msg.chat_id, user_id)
pending = {
"thread_id": msg.thread_id,
"topic_id": topic_id,
"source_message_id": source_message_id,
"card_message_id": card_message_id,
"created_at": time.time(),
}
with self._thread_lock:
# Plain-message clarification continuity is a short-lived in-memory
# hint; explicit Feishu replies are still covered by persisted
# message-id mappings.
self._pending_clarifications.setdefault(key, []).append(pending)
logger.info(
"[Feishu] pending clarification remembered: chat_id=%s user_id=%s topic_id=%s thread_id=%s",
msg.chat_id,
user_id,
topic_id,
msg.thread_id,
)
def _consume_pending_clarification(self, chat_id: str, user_id: str) -> dict[str, Any] | None:
key = self._pending_key(chat_id, user_id)
with self._thread_lock:
pending_items = self._pending_clarifications.get(key)
if not pending_items:
return None
now = time.time()
while pending_items:
pending = pending_items.pop(0)
created_at = pending.get("created_at")
if isinstance(created_at, (int, float)) and now - created_at <= PENDING_CLARIFICATION_TTL_SECONDS:
if pending_items:
self._pending_clarifications[key] = pending_items
else:
self._pending_clarifications.pop(key, None)
return pending
logger.info("[Feishu] pending clarification expired: chat_id=%s user_id=%s", chat_id, user_id)
self._pending_clarifications.pop(key, None)
return None
def _ensure_pending_thread_mapping(self, chat_id: str, user_id: str, pending: dict[str, Any]) -> None:
store = self.config.get("channel_store")
topic_id = self._non_empty_str(pending.get("topic_id"))
thread_id = self._non_empty_str(pending.get("thread_id"))
if store is None or not topic_id or not thread_id:
return
try:
store.set_thread_id(self.name, chat_id, thread_id, topic_id=topic_id, user_id=user_id)
except Exception:
logger.exception("[Feishu] failed to restore pending clarification mapping for topic_id=%s", topic_id)
def _resolve_topic_id(
self,
chat_id: str,
msg_id: str,
*,
root_id: str | None,
parent_id: str | None,
thread_id: str | None,
) -> tuple[str, bool]:
store = self.config.get("channel_store")
candidates = [root_id, parent_id, thread_id]
if store is not None:
for candidate in candidates:
candidate = self._non_empty_str(candidate)
if not candidate:
continue
try:
if store.get_thread_id(self.name, chat_id, topic_id=candidate):
return candidate, True
except Exception:
logger.exception("[Feishu] failed to resolve stored topic mapping for topic_id=%s", candidate)
return root_id or msg_id, False
@staticmethod @staticmethod
def _is_batchable_file_inbound( def _log_future_error(fut, name: str, msg_id: str) -> None:
*, """Callback for run_coroutine_threadsafe futures to surface errors."""
msg_type: InboundMessageType, try:
text: str, exc = fut.exception()
files: list[dict[str, Any]], if exc:
root_id: str | None, logger.error("[Feishu] %s failed for msg_id=%s: %s", name, msg_id, exc)
parent_id: str | None, except Exception:
thread_id: str | None, pass
) -> bool:
return msg_type == InboundMessageType.CHAT and text in {"[file]", "[image]"} and len(files) == 1 and not (root_id or parent_id or thread_id)
def _schedule_prepare_inbound(
self,
msg_id: str,
inbound: InboundMessage,
*,
source_message_ids: list[str] | None = None,
) -> None:
if self._main_loop and self._main_loop.is_running():
logger.info("[Feishu] publishing inbound message to bus (type=%s, msg_id=%s)", inbound.msg_type.value, msg_id)
fut = asyncio.run_coroutine_threadsafe(
self._prepare_inbound(msg_id, inbound, source_message_ids=source_message_ids),
self._main_loop,
)
fut.add_done_callback(lambda f, mid=msg_id: self._log_future_error(f, "prepare_inbound", mid))
else:
logger.warning("[Feishu] main loop not running, cannot publish inbound message")
def _schedule_batch_flush(self, key: tuple[str, str], source_message_id: str) -> None:
if self._main_loop and self._main_loop.is_running():
fut = asyncio.run_coroutine_threadsafe(self._flush_pending_inbound_batch_after(key, source_message_id), self._main_loop)
fut.add_done_callback(lambda f, mid=source_message_id: self._log_future_error(f, "flush_inbound_batch", mid))
else:
logger.warning("[Feishu] main loop not running, cannot flush inbound batch")
def _queue_file_inbound_batch(self, msg_id: str, inbound: InboundMessage) -> bool:
key = self._pending_key(inbound.chat_id, inbound.user_id)
should_schedule_flush = False
expired_batch: tuple[str, InboundMessage, list[str]] | None = None
with self._thread_lock:
batch = self._pending_inbound_batches.get(key)
now = time.time()
if batch:
if now - batch["created_at"] <= FEISHU_INBOUND_BATCH_WINDOW_SECONDS:
batched_inbound = batch["inbound"]
batch["message_ids"].append(msg_id)
batch["text_parts"].append(inbound.text)
batched_inbound.text = "\n\n".join(part for part in batch["text_parts"] if part)
batched_inbound.files.extend(inbound.files)
batched_inbound.metadata["batched_message_ids"] = list(batch["message_ids"])
logger.info(
"[Feishu] batched inbound file message: chat_id=%s user_id=%s anchor=%s msg_id=%s files=%d",
inbound.chat_id,
inbound.user_id,
batch["anchor_message_id"],
msg_id,
len(batched_inbound.files),
)
return True
expired_batch = (batch["anchor_message_id"], batch["inbound"], list(batch["message_ids"]))
self._pending_inbound_batches[key] = {
"anchor_message_id": msg_id,
"created_at": now,
"inbound": inbound,
"message_ids": [msg_id],
"text_parts": [inbound.text],
}
inbound.metadata["batched_message_ids"] = [msg_id]
should_schedule_flush = True
if should_schedule_flush:
self._schedule_batch_flush(key, msg_id)
if expired_batch:
anchor_message_id, expired_inbound, source_message_ids = expired_batch
self._schedule_prepare_inbound(anchor_message_id, expired_inbound, source_message_ids=source_message_ids)
return True
def _pop_pending_inbound_batch(self, key: tuple[str, str], *, anchor_message_id: str | None = None) -> tuple[str, InboundMessage, list[str]] | None:
with self._thread_lock:
batch = self._pending_inbound_batches.get(key)
if not batch:
return None
if anchor_message_id is not None and batch["anchor_message_id"] != anchor_message_id:
return None
self._pending_inbound_batches.pop(key, None)
return batch["anchor_message_id"], batch["inbound"], list(batch["message_ids"])
async def _flush_pending_inbound_batch_after(self, key: tuple[str, str], anchor_message_id: str) -> None:
await asyncio.sleep(FEISHU_INBOUND_BATCH_WINDOW_SECONDS)
batch = self._pop_pending_inbound_batch(key, anchor_message_id=anchor_message_id)
if not batch:
return
anchor_message_id, inbound, source_message_ids = batch
logger.info(
"[Feishu] flushing inbound file batch: chat_id=%s user_id=%s anchor=%s messages=%d files=%d",
inbound.chat_id,
inbound.user_id,
anchor_message_id,
len(source_message_ids),
len(inbound.files),
)
await self._prepare_inbound(anchor_message_id, inbound, source_message_ids=source_message_ids)
@staticmethod @staticmethod
def _log_task_error(task: asyncio.Task, name: str, msg_id: str) -> None: def _log_task_error(task: asyncio.Task, name: str, msg_id: str) -> None:
@ -924,51 +569,13 @@ class FeishuChannel(Channel):
except Exception: except Exception:
pass pass
async def _prepare_inbound(self, msg_id: str, inbound, *, source_message_ids: list[str] | None = None) -> None: async def _prepare_inbound(self, msg_id: str, inbound) -> None:
"""Kick off Feishu side effects without delaying inbound dispatch.""" """Kick off Feishu side effects without delaying inbound dispatch."""
inbound = await self._attach_connection_identity(inbound) reaction_task = asyncio.create_task(self._add_reaction(msg_id, "OK"))
reaction_message_ids = source_message_ids or [msg_id] self._track_background_task(reaction_task, name="add_reaction", msg_id=msg_id)
for reaction_message_id in reaction_message_ids: self._ensure_running_card_started(msg_id)
reaction_task = asyncio.create_task(self._add_reaction(reaction_message_id, "OK"))
self._track_background_task(reaction_task, name="add_reaction", msg_id=reaction_message_id)
self._ensure_running_card_started(msg_id, metadata=inbound.metadata)
await self.bus.publish_inbound(inbound) await self.bus.publish_inbound(inbound)
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="feishu",
workspace_id=inbound.chat_id,
)
async def _bind_connection_from_connect_code(self, *, message_id: str, chat_id: str, user_id: str, code: str) -> bool:
if self._connection_repo is None or not code:
return False
state = await self._connection_repo.consume_oauth_state(provider="feishu", state=code)
if state is None:
await self._reply_card(message_id, "Feishu connection code is invalid or expired.")
return True
if not user_id or not chat_id:
await self._reply_card(message_id, "Feishu connection could not be completed from this message.")
return True
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="feishu",
external_account_id=user_id,
workspace_id=chat_id,
metadata={
"chat_id": chat_id,
"message_id": message_id,
},
status="connected",
)
await self._reply_card(message_id, "Feishu connected to DeerFlow.")
return True
def _on_message(self, event) -> None: def _on_message(self, event) -> None:
"""Called by lark-oapi when a message is received (runs in lark thread).""" """Called by lark-oapi when a message is received (runs in lark thread)."""
try: try:
@ -978,10 +585,9 @@ class FeishuChannel(Channel):
msg_id = message.message_id msg_id = message.message_id
sender_id = event.event.sender.sender_id.open_id sender_id = event.event.sender.sender_id.open_id
# root_id is set when the message is a reply within a Feishu thread.
# Use it as topic_id so all replies share the same DeerFlow thread.
root_id = getattr(message, "root_id", None) or None root_id = getattr(message, "root_id", None) or None
chat_type = getattr(message, "chat_type", None)
parent_id = self._non_empty_str(getattr(message, "parent_id", None))
feishu_thread_id = self._non_empty_str(getattr(message, "thread_id", None))
# Parse message content # Parse message content
content = json.loads(message.content) content = json.loads(message.content)
@ -1042,93 +648,27 @@ class FeishuChannel(Channel):
text = text.strip() text = text.strip()
logger.info( logger.info(
"[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, parent_id=%s, thread_id=%s, chat_type=%s, sender=%s, text_len=%d", "[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, sender=%s, text=%r",
chat_id, chat_id,
msg_id, msg_id,
root_id, root_id,
parent_id,
feishu_thread_id,
chat_type,
sender_id, sender_id,
len(text or ""), text[:100] if text else "",
) )
if not (text or files_list): if not (text or files_list):
logger.info("[Feishu] empty text, ignoring message") logger.info("[Feishu] empty text, ignoring message")
return return
connect_code = self._pending_connect_code(text)
if connect_code:
if self._main_loop and self._main_loop.is_running():
fut = asyncio.run_coroutine_threadsafe(
self._bind_connection_from_connect_code(
message_id=msg_id,
chat_id=chat_id,
user_id=sender_id,
code=connect_code,
),
self._main_loop,
)
fut.add_done_callback(lambda f, mid=msg_id: self._log_future_error(f, "bind_connection", mid))
else:
logger.warning("[Feishu] main loop not running, cannot bind channel connection")
return
# Only treat known slash commands as commands; absolute paths and # Only treat known slash commands as commands; absolute paths and
# other slash-prefixed text should be handled as normal chat. # other slash-prefixed text should be handled as normal chat.
# Feishu group chats deliver "@bot /goal" with the mention left in the if _is_feishu_command(text):
# text (Slack/Discord strip their own bot mention upstream). Skip a
# leading mention only for the command path so ordinary chat keeps any
# @mentions intact for the agent; the stripped form also flows into the
# inbound so ChannelManager._handle_command parses the bare command.
command_text = strip_leading_mentions(text)
if _is_feishu_command(command_text):
msg_type = InboundMessageType.COMMAND msg_type = InboundMessageType.COMMAND
text = command_text
else: else:
msg_type = InboundMessageType.CHAT msg_type = InboundMessageType.CHAT
# topic_id determines which LangGraph thread the message maps to. # topic_id: use root_id for replies (same topic), msg_id for new messages (new topic)
# P2P chats: topic_id=None so all messages share one thread (like Telegram DMs). topic_id = root_id or msg_id
# But check stored mappings first for backward compatibility with pre-upgrade P2P threads.
topic_id, resolved_from_stored_mapping = self._resolve_topic_id(
chat_id,
msg_id,
root_id=root_id,
parent_id=parent_id,
thread_id=feishu_thread_id,
)
if chat_type == "p2p" and not resolved_from_stored_mapping:
topic_id = None
resolved_from_pending = False
if msg_type == InboundMessageType.CHAT and not resolved_from_stored_mapping:
pending = self._consume_pending_clarification(chat_id, sender_id)
pending_topic_id = self._non_empty_str(pending.get("topic_id")) if pending else None
if pending_topic_id:
topic_id = pending_topic_id
self._ensure_pending_thread_mapping(chat_id, sender_id, pending)
resolved_from_pending = True
source_preview = None
if self._should_include_source_preview(
chat_type=chat_type,
root_id=root_id,
parent_id=parent_id,
thread_id=feishu_thread_id,
):
source_preview = self._compact_source_preview(text)
metadata = {
"message_id": msg_id,
"root_id": root_id,
"parent_id": parent_id,
"thread_id": feishu_thread_id,
"topic_id": topic_id,
"user_id": sender_id,
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY: resolved_from_pending,
}
if source_preview:
metadata[SOURCE_PREVIEW_METADATA_KEY] = source_preview
inbound = self._make_inbound( inbound = self._make_inbound(
chat_id=chat_id, chat_id=chat_id,
@ -1137,21 +677,16 @@ class FeishuChannel(Channel):
msg_type=msg_type, msg_type=msg_type,
thread_ts=msg_id, thread_ts=msg_id,
files=files_list, files=files_list,
metadata=metadata, metadata={"message_id": msg_id, "root_id": root_id},
) )
inbound.topic_id = topic_id inbound.topic_id = topic_id
if self._is_batchable_file_inbound( # Schedule on the async event loop
msg_type=msg_type, if self._main_loop and self._main_loop.is_running():
text=text, logger.info("[Feishu] publishing inbound message to bus (type=%s, msg_id=%s)", msg_type.value, msg_id)
files=files_list, fut = asyncio.run_coroutine_threadsafe(self._prepare_inbound(msg_id, inbound), self._main_loop)
root_id=root_id, fut.add_done_callback(lambda f, mid=msg_id: self._log_future_error(f, "prepare_inbound", mid))
parent_id=parent_id, else:
thread_id=feishu_thread_id, logger.warning("[Feishu] main loop not running, cannot publish inbound message")
):
self._queue_file_inbound_batch(msg_id, inbound)
return
self._schedule_prepare_inbound(msg_id, inbound)
except Exception: except Exception:
logger.exception("[Feishu] error processing message") logger.exception("[Feishu] error processing message")

View File

@ -1,15 +0,0 @@
"""Per-run policy registration for the Feishu channel."""
from __future__ import annotations
from app.channels.run_policy import CHANNEL_RUN_POLICY, ChannelRunPolicy
def register_policy() -> None:
"""Register Feishu's queue-same-thread behavior in the shared policy map."""
CHANNEL_RUN_POLICY["feishu"] = ChannelRunPolicy(
serialize_thread_runs=True,
)
register_policy()

View File

@ -1,115 +0,0 @@
"""GitHub channel — webhook-driven IM channel for PR/issue comments.
Unlike other IM channels (Feishu, Slack, Telegram) which long-poll or use
WebSockets, GitHub delivers messages via HTTP push webhooks. This channel
therefore has a no-op ``start``/``stop`` inbound messages arrive through
``POST /api/webhooks/github`` and are published to the bus by the webhook
route handler.
**The channel does not auto-post the agent's final response.** Each GitHub
agent (coder, reviewer, ) has the `gh` CLI in its sandbox and is expected to
decide for itself what if anything to post on the issue or PR, and to use
``gh issue comment`` / ``gh pr comment`` / ``gh pr create`` during the run.
The agent's final assistant message is logged at INFO for visibility in
``gateway.log`` but is **not** sent to GitHub.
Why log-only rather than auto-post:
- Two agents can bind the same event (e.g. coder + reviewer on a mention).
If both auto-posted their final messages, the user would see two replies
for every mention even when only one had useful work to do. Letting the
LLM call ``gh`` mid-run means silence is just "the LLM did not call gh."
- The agent often wants to post *intermediate* updates (an issue comment
linking the PR, a separate comment on a new sub-issue, ) the
auto-post-the-final-message contract didn't model that and forced the
final message to play double duty.
- The dispatcher's per-agent ``_is_self_event`` gate already prevents the
comments the LLM posts via ``gh`` from looping the webhook back into a
new run for the same agent.
"""
from __future__ import annotations
import logging
from typing import Any
from app.channels.base import Channel
from app.channels.message_bus import MessageBus, OutboundMessage
logger = logging.getLogger(__name__)
class GitHubChannel(Channel):
"""Webhook-driven GitHub channel.
Inbound: ``POST /api/webhooks/github`` publishes ``InboundMessage`` to
the bus. Outbound: ``send`` is log-only (see module docstring) agents
post to GitHub themselves via the ``gh`` CLI in their sandbox.
Configuration keys (in ``config.yaml`` under ``channels.github``):
- ``enabled`` (bool): set to ``true`` to activate.
- ``default_mention_login`` (str, optional): bot handle used by
``require_mention`` when the agent binding does not set one.
Falls back to ``"deerflow-bot"``.
"""
def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None:
super().__init__(name="github", bus=bus, config=config)
# -- lifecycle ---------------------------------------------------------
async def start(self) -> None:
"""Register the outbound callback.
GitHub is push-based (webhooks), so no long-poll or socket
listener is needed. We only register for outbound replies so the
agent's final message gets logged.
"""
if self._running:
return
self.bus.subscribe_outbound(self._on_outbound)
self._running = True
logger.info("GitHubChannel started (webhook-driven, no polling)")
async def stop(self) -> None:
"""Unregister the outbound callback."""
if not self._running:
return
self.bus.unsubscribe_outbound(self._on_outbound)
self._running = False
logger.info("GitHubChannel stopped")
# -- outbound ----------------------------------------------------------
async def send(self, msg: OutboundMessage) -> None:
"""Log the agent's final message — do NOT post it to GitHub.
GitHub agents post to issues/PRs themselves via ``gh`` mid-run; the
final assistant message is logged for ``gateway.log`` visibility but
is not delivered to the platform. See the module docstring for why.
Metadata layout (read for logging context only):
- ``repo`` (str, e.g. ``"owner/name"``) falls back to ``chat_id``
- ``number`` (int, issue or PR number)
- ``installation_id`` (int)
"""
gh = msg.metadata.get("github", {}) if isinstance(msg.metadata, dict) else {}
if not isinstance(gh, dict):
gh = {}
repo = gh.get("repo") or msg.chat_id
number = gh.get("number")
body = msg.text or ""
logger.info(
"[GitHubChannel] final message from agent for %s#%s (text_len=%d) — not posted; agents use `gh` directly",
repo,
number,
len(body),
)
# Mirror the body itself at DEBUG so operators can correlate without
# spamming INFO. Truncate to keep log lines bounded.
if body:
logger.debug("[GitHubChannel] final body (truncated to 2000 chars): %s", body[:2000])

File diff suppressed because it is too large Load Diff

View File

@ -13,12 +13,6 @@ from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PENDING_CLARIFICATION_METADATA_KEY = "pending_clarification"
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY = "resolved_from_pending_clarification"
# Adapter-owned bytes may use this transient key while crossing the channel
# boundary. ChannelManager consumes and removes it before persisting metadata.
INBOUND_FILE_CONTENT_KEY = "_content"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Message types # Message types
@ -47,12 +41,6 @@ class InboundMessage:
Messages sharing the same ``topic_id`` within a ``chat_id`` will Messages sharing the same ``topic_id`` within a ``chat_id`` will
reuse the same DeerFlow thread. When ``None``, each message reuse the same DeerFlow thread. When ``None``, each message
creates a new thread (one-shot Q&A). creates a new thread (one-shot Q&A).
connection_id: Optional DeerFlow channel connection id. When present,
conversation mapping is scoped by the connection instead of the
legacy global ``channel_name:chat_id[:topic_id]`` key.
owner_user_id: DeerFlow user id that owns the channel connection.
Platform user ids stay in ``user_id``.
workspace_id: Optional external workspace/guild/team id.
files: Optional list of file attachments (platform-specific dicts). files: Optional list of file attachments (platform-specific dicts).
metadata: Arbitrary extra data from the channel. metadata: Arbitrary extra data from the channel.
created_at: Unix timestamp when the message was created. created_at: Unix timestamp when the message was created.
@ -65,9 +53,6 @@ class InboundMessage:
msg_type: InboundMessageType = InboundMessageType.CHAT msg_type: InboundMessageType = InboundMessageType.CHAT
thread_ts: str | None = None thread_ts: str | None = None
topic_id: str | None = None topic_id: str | None = None
connection_id: str | None = None
owner_user_id: str | None = None
workspace_id: str | None = None
files: list[dict[str, Any]] = field(default_factory=list) files: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
created_at: float = field(default_factory=time.time) created_at: float = field(default_factory=time.time)
@ -107,9 +92,6 @@ class OutboundMessage:
is_final: Whether this is the final message in the response stream. is_final: Whether this is the final message in the response stream.
thread_ts: Optional platform thread identifier for threaded replies. thread_ts: Optional platform thread identifier for threaded replies.
metadata: Arbitrary extra data. metadata: Arbitrary extra data.
connection_id: Optional DeerFlow channel connection id used for
connection-specific outbound credentials.
owner_user_id: DeerFlow user id that owns the channel connection.
created_at: Unix timestamp. created_at: Unix timestamp.
""" """
@ -121,8 +103,6 @@ class OutboundMessage:
attachments: list[ResolvedAttachment] = field(default_factory=list) attachments: list[ResolvedAttachment] = field(default_factory=list)
is_final: bool = True is_final: bool = True
thread_ts: str | None = None thread_ts: str | None = None
connection_id: str | None = None
owner_user_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
created_at: float = field(default_factory=time.time) created_at: float = field(default_factory=time.time)
@ -175,7 +155,7 @@ class MessageBus:
def unsubscribe_outbound(self, callback: OutboundCallback) -> None: def unsubscribe_outbound(self, callback: OutboundCallback) -> None:
"""Remove a previously registered outbound callback.""" """Remove a previously registered outbound callback."""
self._outbound_listeners = [cb for cb in self._outbound_listeners if cb != callback] self._outbound_listeners = [cb for cb in self._outbound_listeners if cb is not callback]
async def publish_outbound(self, msg: OutboundMessage) -> None: async def publish_outbound(self, msg: OutboundMessage) -> None:
"""Dispatch an outbound message to all registered listeners.""" """Dispatch an outbound message to all registered listeners."""

View File

@ -1,118 +0,0 @@
"""Per-channel run policy registry.
Holds the global ``CHANNEL_RUN_POLICY`` map and its :class:`ChannelRunPolicy`
descriptor. Split into its own module so channels can register their own
policy entries (typically as a side-effect of importing their package)
without creating a circular dependency on :mod:`app.channels.manager`.
The dispatch path in :class:`app.channels.manager.ChannelManager` looks
up policy entries by ``msg.channel_name`` and applies them after
``_resolve_run_params``.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from app.channels.message_bus import InboundMessage
@dataclass(frozen=True, slots=True)
class ChannelRunPolicy:
"""Per-channel knobs applied by :meth:`ChannelManager._apply_channel_policy`.
Webhook-driven channels (GitHub today; others later) need four
things the generic interactive-chat path does not: a higher
``recursion_limit`` for autonomous long runs, suppression of
``ask_clarification`` (no human is synchronously present), a
credentials provider that mints platform tokens for the agent, and
an opt-out from the per-sender bound-identity gate (authenticity is
enforced at the webhook route by HMAC, and there is no equivalent
of a per-user ``/connect`` handshake to perform).
Declaring all four on one dataclass keeps the channel's run
behavior in a single discoverable place and turns "add a new
webhook channel" into a one-row registration instead of touching
multiple separate methods on the manager.
Attributes:
is_interactive: When False, the manager sets
``run_context["disable_clarification"] = True`` so
``ClarificationMiddleware`` returns a "proceed with best
judgment" ToolMessage instead of interrupting via
``Command(goto=END)``. Defaults to True (the safe default
for an IM channel).
default_recursion_limit: When set, the manager raises
``run_config["recursion_limit"]`` to ``max(existing,
limit)``. None leaves the global default (100) untouched
interactive chat turns don't need 250 super-steps.
credentials_provider: Optional async hook that mutates
``run_context`` with platform-specific credentials. Called
after ``_resolve_run_params``. Exceptions are caught and
logged so a credential failure degrades gracefully (agent
runs read-only) instead of dropping the delivery.
requires_bound_identity: When False, the manager skips the
per-sender bound-identity gate (``_get_bound_identity_rejection``)
for this channel even when ``channel_connections.enabled`` is
on. Webhook-authenticated channels (GitHub) have no
per-sender ``/connect`` handshake authenticity is enforced
by HMAC at the webhook route, and the binding from "sender"
to DeerFlow user is encoded in the agent's ``config.yaml``
ownership, not in the channel-connections table. Defaults to
True (the safe default for an interactive IM channel).
fire_and_forget: When True, the manager schedules the run with
``runs.create`` (returns immediately once the run is
``pending``) instead of ``runs.wait`` (which keeps an HTTP
stream open for the entire run lifetime). Channels that do
their own outbound during the run e.g. GitHub, where the
agent posts to the issue/PR via the ``gh`` CLI in its
sandbox don't need the manager to ferry a final state
back. Eliminates the SDK's 300s ``httpx.ReadTimeout`` on
runs that legitimately take more than 5 minutes, and the
false "internal error" outbound that follows when it
fires. Defaults to False (the safe default for an
interactive IM channel that depends on the manager to
publish the agent's reply).
serialize_thread_runs: When True, the manager serializes
same-thread inbound turns for this channel instead of
surfacing the runtime's generic busy-thread error. This is
useful for chat surfaces like Feishu topics where rapid
follow-up messages should queue behind the active turn while
unrelated DeerFlow threads continue concurrently. Defaults
to False so existing channels keep the runtime's native
multitask behavior unless they opt in explicitly.
buffer_followups_on_busy: When True, a ``ConflictError`` on the
``fire_and_forget`` dispatch path (see
:meth:`ChannelManager._handle_chat_on_thread`) does more than
log + reply with the generic busy message: the triggering
message is appended to a per-thread follow-up buffer, and a
background watcher subscribes to the active run's
``StreamBridge`` stream so it can coalesce the buffer into a
follow-up run as soon as that run ends. This targets
``fire_and_forget`` channels whose ``send`` is otherwise the
only feedback a busy sender gets (e.g. GitHub, where ``send``
is log-only) without it, a concurrent comment is silently
dropped from the sender's point of view. Defaults to False so
channels that have not opted in keep the exact old
silent-drop-with-log behavior; see
``app.gateway.github.run_policy`` for GitHub's opt-in.
"""
is_interactive: bool = True
default_recursion_limit: int | None = None
credentials_provider: Callable[[InboundMessage, dict[str, Any]], Awaitable[None]] | None = None
requires_bound_identity: bool = True
fire_and_forget: bool = False
serialize_thread_runs: bool = False
buffer_followups_on_busy: bool = False
# Channel name → policy. Channels absent from this map fall through to
# the policy default (an interactive IM channel with no credential
# plumbing) — which is what every IM channel had before GitHub. Webhook
# channels register their entry at package-import time (see
# ``app.gateway.github.run_policy``).
CHANNEL_RUN_POLICY: dict[str, ChannelRunPolicy] = {}

View File

@ -1,157 +0,0 @@
"""Local persistence for runtime IM channel configuration."""
from __future__ import annotations
import json
import logging
import tempfile
import threading
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
RUNTIME_CHANNEL_DISABLED_FLAG = "_runtime_disabled"
class ChannelRuntimeConfigStore:
"""JSON-backed store for channel credentials entered from the UI.
This intentionally mirrors ``ChannelStore``: local/private deployments get
durable runtime configuration without needing a public callback URL or a
config.yaml edit.
"""
def __init__(self, path: str | Path | None = None) -> None:
if path is None:
from deerflow.config.paths import get_paths
path = Path(get_paths().base_dir) / "channels" / "runtime-config.json"
self._path = Path(path)
self._path.parent.mkdir(parents=True, exist_ok=True)
self._data: dict[str, dict[str, Any]] = self._load()
self._lock = threading.Lock()
def _load(self) -> dict[str, dict[str, Any]]:
if self._path.exists():
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
logger.warning("Corrupt channel runtime config store at %s, starting fresh", self._path)
return {}
if isinstance(raw, dict):
return {str(name): dict(value) for name, value in raw.items() if isinstance(value, dict)}
return {}
def _save(self) -> None:
fd = tempfile.NamedTemporaryFile(
mode="w",
dir=self._path.parent,
suffix=".tmp",
delete=False,
)
try:
try:
Path(fd.name).chmod(0o600)
except OSError:
logger.debug("Unable to chmod temporary channel runtime config store at %s", fd.name, exc_info=True)
json.dump(self._data, fd, indent=2, ensure_ascii=False)
fd.close()
Path(fd.name).replace(self._path)
try:
self._path.chmod(0o600)
except OSError:
logger.debug("Unable to chmod channel runtime config store at %s", self._path, exc_info=True)
except BaseException:
fd.close()
Path(fd.name).unlink(missing_ok=True)
raise
def load_all(self) -> dict[str, dict[str, Any]]:
with self._lock:
return {name: dict(config) for name, config in self._data.items()}
def get_provider_config(self, provider: str) -> dict[str, Any] | None:
with self._lock:
config = self._data.get(provider)
return dict(config) if isinstance(config, dict) else None
def set_provider_config(self, provider: str, config: dict[str, Any]) -> None:
with self._lock:
self._data[provider] = dict(config)
self._save()
def set_provider_disconnected(self, provider: str) -> None:
with self._lock:
self._data[provider] = {
"enabled": False,
RUNTIME_CHANNEL_DISABLED_FLAG: True,
}
self._save()
def remove_provider_config(self, provider: str) -> bool:
with self._lock:
if provider not in self._data:
return False
del self._data[provider]
self._save()
return True
def _provider_enabled(channel_connections_config: Any, provider: str) -> bool:
provider_config = getattr(channel_connections_config, provider, None)
return bool(getattr(provider_config, "enabled", False))
def _runtime_channel_disconnected(runtime_config: dict[str, Any]) -> bool:
return runtime_config.get(RUNTIME_CHANNEL_DISABLED_FLAG) is True and runtime_config.get("enabled") is False
def merge_runtime_channel_configs(
channels_config: dict[str, Any],
channel_connections_config: Any,
*,
store: ChannelRuntimeConfigStore | None = None,
) -> None:
"""Merge persisted runtime provider config into ``channels_config`` in-place."""
if channel_connections_config is None or not getattr(channel_connections_config, "enabled", False):
return
runtime_store = store or ChannelRuntimeConfigStore()
for provider, runtime_config in runtime_store.load_all().items():
if not _provider_enabled(channel_connections_config, provider):
continue
if _runtime_channel_disconnected(runtime_config):
channels_config.pop(provider, None)
continue
existing = channels_config.get(provider)
merged = dict(existing) if isinstance(existing, dict) else {}
merged.update(runtime_config)
channels_config[provider] = merged
def apply_runtime_connection_config(
channel_connections_config: Any,
*,
store: ChannelRuntimeConfigStore | None = None,
) -> Any:
"""Apply persisted connection metadata that lives outside ``channels``.
Telegram uses a bot username for deep links; UI-entered values are stored
with the runtime channel config so local restarts keep the provider
configured.
"""
if channel_connections_config is None or not getattr(channel_connections_config, "enabled", False):
return channel_connections_config
runtime_store = store or ChannelRuntimeConfigStore()
telegram_runtime_config = runtime_store.get_provider_config("telegram")
bot_username = ""
if isinstance(telegram_runtime_config, dict):
bot_username = str(telegram_runtime_config.get("bot_username") or "").strip()
if not bot_username or not _provider_enabled(channel_connections_config, "telegram"):
return channel_connections_config
config = channel_connections_config.model_copy(deep=True)
config.telegram.bot_username = bot_username
return config

View File

@ -2,57 +2,31 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import os import os
from collections.abc import Callable from typing import Any
from typing import TYPE_CHECKING, Any
from app.channels.base import Channel from app.channels.base import Channel
from app.channels.manager import DEFAULT_GATEWAY_URL, DEFAULT_LANGGRAPH_URL, ChannelManager from app.channels.manager import DEFAULT_GATEWAY_URL, DEFAULT_LANGGRAPH_URL, ChannelManager
from app.channels.message_bus import MessageBus from app.channels.message_bus import MessageBus
from app.channels.runtime_config_store import merge_runtime_channel_configs
from app.channels.store import ChannelStore from app.channels.store import ChannelStore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
from deerflow.runtime import StreamBridge
# Channel name → import path for lazy loading # Channel name → import path for lazy loading
_CHANNEL_REGISTRY: dict[str, str] = { _CHANNEL_REGISTRY: dict[str, str] = {
"dingtalk": "app.channels.dingtalk:DingTalkChannel",
"discord": "app.channels.discord:DiscordChannel", "discord": "app.channels.discord:DiscordChannel",
"feishu": "app.channels.feishu:FeishuChannel", "feishu": "app.channels.feishu:FeishuChannel",
"github": "app.channels.github:GitHubChannel",
"slack": "app.channels.slack:SlackChannel", "slack": "app.channels.slack:SlackChannel",
"telegram": "app.channels.telegram:TelegramChannel", "telegram": "app.channels.telegram:TelegramChannel",
"wechat": "app.channels.wechat:WechatChannel", "wechat": "app.channels.wechat:WechatChannel",
"wecom": "app.channels.wecom:WeComChannel", "wecom": "app.channels.wecom:WeComChannel",
} }
# Keys that indicate a user has configured credentials for a channel.
_CHANNEL_CREDENTIAL_KEYS: dict[str, list[str]] = {
"dingtalk": ["client_id", "client_secret"],
"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_LANGGRAPH_URL_ENV = "DEER_FLOW_CHANNELS_LANGGRAPH_URL"
_CHANNELS_GATEWAY_URL_ENV = "DEER_FLOW_CHANNELS_GATEWAY_URL" _CHANNELS_GATEWAY_URL_ENV = "DEER_FLOW_CHANNELS_GATEWAY_URL"
def _channel_has_credentials(name: str, channel_config: dict[str, Any]) -> bool:
cred_keys = _CHANNEL_CREDENTIAL_KEYS.get(name, [])
return any(not isinstance(channel_config.get(key), bool) and channel_config.get(key) is not None and str(channel_config[key]).strip() for key in cred_keys)
def _resolve_service_url(config: dict[str, Any], config_key: str, env_key: str, default: str) -> str: def _resolve_service_url(config: dict[str, Any], config_key: str, env_key: str, default: str) -> str:
value = config.pop(config_key, None) value = config.pop(config_key, None)
if isinstance(value, str) and value.strip(): if isinstance(value, str) and value.strip():
@ -63,29 +37,6 @@ def _resolve_service_url(config: dict[str, Any], config_key: str, env_key: str,
return default return default
def _merge_channel_connection_runtime_config(channels_config: dict[str, Any], app_config: AppConfig) -> None:
connection_config = getattr(app_config, "channel_connections", None)
merge_runtime_channel_configs(channels_config, connection_config)
def _make_connection_repo(connection_config: ChannelConnectionsConfig | None):
if connection_config is None or not getattr(connection_config, "enabled", False):
return None
try:
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory
except Exception:
logger.exception("Failed to import channel connection repository")
return None
session_factory = get_session_factory()
if session_factory is None:
logger.warning("Channel connections are enabled but database persistence is not available")
return None
return ChannelConnectionRepository(session_factory)
class ChannelService: class ChannelService:
"""Manages the lifecycle of all configured IM channels. """Manages the lifecycle of all configured IM channels.
@ -93,26 +44,14 @@ class ChannelService:
instantiates enabled channels, and starts the ChannelManager dispatcher. instantiates enabled channels, and starts the ChannelManager dispatcher.
""" """
def __init__( def __init__(self, channels_config: dict[str, Any] | None = None) -> None:
self,
channels_config: dict[str, Any] | None = None,
*,
connection_repo: Any | None = None,
require_bound_identity: bool = False,
app_config: AppConfig | None = None,
get_stream_bridge: Callable[[], StreamBridge | None] | None = None,
) -> None:
self.bus = MessageBus() self.bus = MessageBus()
self.store = ChannelStore() self.store = ChannelStore()
self._connection_repo = connection_repo
self._get_stream_bridge = get_stream_bridge
config = dict(channels_config or {}) config = dict(channels_config or {})
langgraph_url = _resolve_service_url(config, "langgraph_url", _CHANNELS_LANGGRAPH_URL_ENV, DEFAULT_LANGGRAPH_URL) langgraph_url = _resolve_service_url(config, "langgraph_url", _CHANNELS_LANGGRAPH_URL_ENV, DEFAULT_LANGGRAPH_URL)
gateway_url = _resolve_service_url(config, "gateway_url", _CHANNELS_GATEWAY_URL_ENV, DEFAULT_GATEWAY_URL) gateway_url = _resolve_service_url(config, "gateway_url", _CHANNELS_GATEWAY_URL_ENV, DEFAULT_GATEWAY_URL)
default_session = config.pop("session", None) default_session = config.pop("session", None)
channel_sessions = {name: channel_config.get("session") for name, channel_config in config.items() if isinstance(channel_config, dict)} channel_sessions = {name: channel_config.get("session") for name, channel_config in config.items() if isinstance(channel_config, dict)}
from app.channels.dedupe_store import make_inbound_dedupe_store
self.manager = ChannelManager( self.manager = ChannelManager(
bus=self.bus, bus=self.bus,
store=self.store, store=self.store,
@ -120,50 +59,23 @@ class ChannelService:
gateway_url=gateway_url, gateway_url=gateway_url,
default_session=default_session if isinstance(default_session, dict) else None, default_session=default_session if isinstance(default_session, dict) else None,
channel_sessions=channel_sessions, channel_sessions=channel_sessions,
connection_repo=connection_repo,
require_bound_identity=require_bound_identity,
inbound_dedupe_store=make_inbound_dedupe_store(app_config),
get_stream_bridge=get_stream_bridge,
) )
self._channels: dict[str, Any] = {} # name -> Channel instance self._channels: dict[str, Any] = {} # name -> Channel instance
self._config = config self._config = config
self._running = False self._running = False
self._readiness_locks: dict[str, asyncio.Lock] = {}
@classmethod @classmethod
def from_app_config( def from_app_config(cls) -> ChannelService:
cls, """Create a ChannelService from the application config."""
app_config: AppConfig | None = None, from deerflow.config.app_config import get_app_config
*,
get_stream_bridge: Callable[[], StreamBridge | None] | None = None,
) -> ChannelService:
"""Create a ChannelService from the application config.
``get_stream_bridge`` is threaded straight through to the config = get_app_config()
``ChannelManager`` (see its docstring); it is optional so direct
callers (including most tests) that don't need follow-up-buffer
auto-draining can omit it.
"""
if app_config is None:
from deerflow.config.app_config import get_app_config
app_config = get_app_config()
channels_config = {} channels_config = {}
# extra fields are allowed by AppConfig (extra="allow") # extra fields are allowed by AppConfig (extra="allow")
extra = app_config.model_extra or {} extra = config.model_extra or {}
if "channels" in extra: if "channels" in extra:
channels_config = dict(extra["channels"] or {}) channels_config = extra["channels"]
_merge_channel_connection_runtime_config(channels_config, app_config) return cls(channels_config=channels_config)
connection_config = getattr(app_config, "channel_connections", None)
connections_enabled = connection_config is not None and getattr(connection_config, "enabled", False)
require_bound_identity = bool(connections_enabled and getattr(connection_config, "require_bound_identity", True))
return cls(
channels_config=channels_config,
connection_repo=_make_connection_repo(connection_config),
require_bound_identity=require_bound_identity,
app_config=app_config,
get_stream_bridge=get_stream_bridge,
)
async def start(self) -> None: async def start(self) -> None:
"""Start the manager and all enabled channels.""" """Start the manager and all enabled channels."""
@ -171,170 +83,54 @@ class ChannelService:
return return
await self.manager.start() await self.manager.start()
self._running = True
ready_status = await self.ensure_ready_channels(attempts=2)
ready_count = sum(1 for ready in ready_status.values() if ready)
logger.info("ChannelService started with %d/%d ready channels", ready_count, len(ready_status))
async def ensure_ready_channels(self, *, attempts: int = 1) -> dict[str, bool]:
"""Start or restart enabled configured channels that are not ready."""
ready_status: dict[str, bool] = {}
for name, channel_config in self._config.items(): for name, channel_config in self._config.items():
if not isinstance(channel_config, dict): if not isinstance(channel_config, dict):
continue continue
if not channel_config.get("enabled", False): if not channel_config.get("enabled", False):
if _channel_has_credentials(name, channel_config): logger.info("Channel %s is disabled, skipping", name)
logger.warning(
"A configured channel has credentials configured but is disabled. Set enabled: true under its channels entry in config.yaml to activate it.",
)
else:
logger.info("A configured channel is disabled, skipping")
continue continue
ready_status[name] = await self.ensure_channel_ready(name, attempts=attempts) await self._start_channel(name, channel_config)
return ready_status
async def ensure_channel_ready( self._running = True
self, logger.info("ChannelService started with channels: %s", list(self._channels.keys()))
name: str,
config: dict[str, Any] | None = None,
*,
attempts: int = 1,
) -> bool:
"""Ensure a single enabled channel is running using its current config."""
if not self._running:
logger.warning("ChannelService is not running; cannot ensure channel readiness")
return False
if config is not None:
self._config[name] = dict(config)
# Serialize per channel: readiness is polled from request handlers, so
# concurrent calls must not stop/start the same channel worker twice.
lock = self._readiness_locks.setdefault(name, asyncio.Lock())
async with lock:
channel_config = self._config.get(name)
if not channel_config or not isinstance(channel_config, dict):
logger.warning("No config for requested channel")
return False
if not channel_config.get("enabled", False):
return False
channel = self._channels.get(name)
if channel is not None and channel.is_running:
return True
if channel is not None:
try:
await channel.stop()
except Exception:
logger.exception("Error stopping non-running channel before readiness retry")
self._channels.pop(name, None)
max_attempts = max(1, attempts)
for attempt in range(max_attempts):
if attempt > 0:
logger.info("Retrying channel startup after readiness check")
if await self._start_channel(name, channel_config):
return True
return False
async def stop(self) -> None: async def stop(self) -> None:
"""Stop all channels and the manager.""" """Stop all channels and the manager."""
for name, channel in list(self._channels.items()): for name, channel in list(self._channels.items()):
try: try:
await channel.stop() await channel.stop()
logger.info("Channel stopped") logger.info("Channel %s stopped", name)
except Exception: except Exception:
logger.exception("Error stopping channel") logger.exception("Error stopping channel %s", name)
self._channels.clear() self._channels.clear()
await self.manager.stop() await self.manager.stop()
self._running = False self._running = False
logger.info("ChannelService stopped") logger.info("ChannelService stopped")
def _load_channel_config(self, name: str) -> dict[str, Any] | None: async def restart_channel(self, name: str) -> bool:
"""Load the latest config for a specific channel from disk.
Uses ``get_app_config()`` which detects file changes via config
signature, so edits to ``config.yaml`` are picked up without a process
restart.
The UI runtime-config overlay applied at startup is re-applied here
so a file-driven reload neither drops credentials entered from the
browser nor resurrects a channel disconnected from it.
Falls back to the cached ``self._config`` when config loading fails.
"""
try:
from deerflow.config.app_config import get_app_config
app_config = get_app_config()
extra = app_config.model_extra or {}
channels_config = dict(extra.get("channels") or {})
_merge_channel_connection_runtime_config(channels_config, app_config)
channel_config = channels_config.get(name)
if isinstance(channel_config, dict):
# Update the cached config so get_status() stays consistent.
self._config[name] = channel_config
return channel_config
except Exception:
logger.exception("Failed to reload config for channel %s, using cached version", name)
return self._config.get(name)
async def restart_channel(self, name: str, *, reload_config: bool = True) -> bool:
"""Restart a specific channel. Returns True if successful.""" """Restart a specific channel. Returns True if successful."""
if name in self._channels: if name in self._channels:
try: try:
await self._channels[name].stop() await self._channels[name].stop()
except Exception: except Exception:
logger.exception("Error stopping channel for restart") logger.exception("Error stopping channel %s for restart", name)
del self._channels[name] del self._channels[name]
if reload_config: config = self._config.get(name)
# Reading config.yaml and the runtime store is disk IO; keep it
# off the event loop.
config = await asyncio.to_thread(self._load_channel_config, name)
else:
config = self._config.get(name)
if not config or not isinstance(config, dict): if not config or not isinstance(config, dict):
logger.warning("No config for requested channel") logger.warning("No config for channel %s", name)
return False return False
if not config.get("enabled", False):
logger.info("Channel %s is disabled, skipping restart", name)
return True
return await self._start_channel(name, config) return await self._start_channel(name, config)
async def configure_channel(self, name: str, config: dict[str, Any]) -> bool:
"""Apply runtime config for a channel and restart it if the service is running."""
self._config[name] = dict(config)
if not self._running:
return True
# The caller just supplied the authoritative config (e.g. credentials
# entered in the browser that are never written to config.yaml) — a
# file reload here would clobber it with the stale on-disk entry.
return await self.restart_channel(name, reload_config=False)
async def remove_channel(self, name: str) -> bool:
"""Remove runtime config for a channel and stop it if currently running."""
self._config.pop(name, None)
channel = self._channels.pop(name, None)
if channel is None:
return True
try:
await channel.stop()
logger.info("Channel stopped and removed")
return True
except Exception:
logger.exception("Error stopping channel for removal")
return False
async def _start_channel(self, name: str, config: dict[str, Any]) -> bool: async def _start_channel(self, name: str, config: dict[str, Any]) -> bool:
"""Instantiate and start a single channel.""" """Instantiate and start a single channel."""
import_path = _CHANNEL_REGISTRY.get(name) import_path = _CHANNEL_REGISTRY.get(name)
if not import_path: if not import_path:
logger.warning("Unknown channel type") logger.warning("Unknown channel type: %s", name)
return False return False
try: try:
@ -342,26 +138,17 @@ class ChannelService:
channel_cls = resolve_class(import_path, base_class=None) channel_cls = resolve_class(import_path, base_class=None)
except Exception: except Exception:
logger.exception("Failed to import channel class") logger.exception("Failed to import channel class for %s", name)
return False return False
try: try:
config = dict(config)
config["channel_store"] = self.store
if self._connection_repo is not None:
config["connection_repo"] = self._connection_repo
channel = channel_cls(bus=self.bus, config=config) channel = channel_cls(bus=self.bus, config=config)
self._channels[name] = channel
await channel.start() await channel.start()
if not channel.is_running: self._channels[name] = channel
self._channels.pop(name, None) logger.info("Channel %s started", name)
logger.error("Channel did not enter a running state after start()")
return False
logger.info("Channel started")
return True return True
except Exception: except Exception:
self._channels.pop(name, None) logger.exception("Failed to start channel %s", name)
logger.exception("Failed to start channel")
return False return False
def get_status(self) -> dict[str, Any]: def get_status(self) -> dict[str, Any]:
@ -384,40 +171,6 @@ class ChannelService:
"""Return a running channel instance by name when available.""" """Return a running channel instance by name when available."""
return self._channels.get(name) return self._channels.get(name)
def is_channel_enabled(self, name: str) -> bool:
"""Return whether ``channels.<name>.enabled`` is truthy in the live config.
Tracks the runtime-authoritative ``_config`` dict, which
:meth:`configure_channel` updates when the UI flips the
enabled flag so callers that read this between requests get
the current effective setting without re-reading config.yaml.
Used by the GitHub webhook router as a fan-out kill-switch:
``channels.github.enabled: false`` skips dispatch even though
the webhook route itself remains mounted (which is governed by
``GITHUB_WEBHOOK_SECRET``, not this flag).
"""
config = self._config.get(name)
if not isinstance(config, dict):
return False
return bool(config.get("enabled", False))
def get_channel_config(self, name: str) -> dict[str, Any] | None:
"""Return a shallow copy of the live ``channels.<name>`` block, or None.
Mirrors :meth:`is_channel_enabled` in tracking the runtime-
authoritative ``_config`` dict, so callers see the same effective
configuration the manager sees including any updates pushed via
:meth:`configure_channel` from the UI. Returns ``None`` when no
config exists for ``name`` (rather than an empty dict) so callers
can distinguish "not configured" from "configured with defaults".
The shallow copy keeps callers from accidentally mutating live
config state.
"""
config = self._config.get(name)
if not isinstance(config, dict):
return None
return dict(config)
# -- singleton access ------------------------------------------------------- # -- singleton access -------------------------------------------------------
@ -429,27 +182,12 @@ def get_channel_service() -> ChannelService | None:
return _channel_service return _channel_service
async def start_channel_service( async def start_channel_service() -> ChannelService:
app_config: AppConfig | None = None, """Create and start the global ChannelService from app config."""
*,
get_stream_bridge: Callable[[], StreamBridge | None] | None = None,
) -> ChannelService:
"""Create and start the global ChannelService from app config.
``get_stream_bridge`` is threaded through to ``ChannelService.from_app_config``
-> ``ChannelManager`` so fire_and_forget channels that opt into
``ChannelRunPolicy.buffer_followups_on_busy`` (currently GitHub) can watch
a run's completion and auto-drain buffered follow-ups. ``app.py``'s
lifespan passes a closure over ``app.state.stream_bridge`` here, the same
pattern it already uses for ``ScheduledTaskService``'s ``launch_run``.
"""
global _channel_service global _channel_service
if _channel_service is not None: if _channel_service is not None:
return _channel_service return _channel_service
# from_app_config reads the JSON channel store and runtime config files; _channel_service = ChannelService.from_app_config()
# keep that disk IO off the event loop. asyncio.to_thread forwards both
# args and kwargs to the target callable.
_channel_service = await asyncio.to_thread(ChannelService.from_app_config, app_config, get_stream_bridge=get_stream_bridge)
await _channel_service.start() await _channel_service.start()
return _channel_service return _channel_service

View File

@ -3,16 +3,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import html
import logging import logging
import re
from typing import Any from typing import Any
from markdown_to_mrkdwn import SlackMarkdownConverter from markdown_to_mrkdwn import SlackMarkdownConverter
from app.channels.base import Channel from app.channels.base import Channel
from app.channels.commands import is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,74 +16,13 @@ logger = logging.getLogger(__name__)
_slack_md_converter = SlackMarkdownConverter() _slack_md_converter = SlackMarkdownConverter()
def _escape_slack_text(text: str) -> str:
"""Escape Slack's reserved characters (``&``, ``<``, ``>``) in raw message text,
except a ``>`` at the very start of a line -- Slack's own blockquote marker.
Slack requires callers to replace these with their HTML entity equivalents
(``&amp;``, ``&lt;``, ``&gt;``) before sending message text -- an unescaped
``<...>`` triggers Slack's own mention/link syntax (e.g. ``<@USERID>``,
``<http://url|label>``). See:
https://api.slack.com/reference/surfaces/formatting#escaping
This MUST run before ``_slack_md_converter.convert()``, not after: the
converter emits its own mrkdwn link syntax (``<url|label>``) for real
markdown links, and that generated syntax must reach Slack unescaped.
Escaping the raw input first -- and leaving the converter's own output
alone -- satisfies both requirements. ``html.escape(..., quote=False)``
replaces ``&`` before ``<``/``>``, so the entities it introduces are never
re-escaped.
Only ``&`` and ``<`` neutralize Slack's ``<...>`` mention/link syntax; a
``>`` is special to Slack only at the start of a line, where the mrkdwn
converter passes it through unchanged as a blockquote marker. Escaping
every ``>`` would turn a quoted line into visible ``&gt;`` text instead of
a rendered blockquote, so a line-leading ``>`` is restored to a literal
``>`` after escaping; a ``>`` anywhere else in the text still escapes.
"""
escaped = html.escape(text, quote=False)
return re.sub(r"(?m)^&gt;", ">", escaped)
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)}
def _strip_leading_slack_bot_mention(text: str, bot_user_id: str | None) -> str:
if not bot_user_id:
return text
if not text.startswith("<@"):
return text
end = text.find(">")
if end <= 2:
return text
mentioned_user_id = text[2:end].split("|", 1)[0].lstrip("!")
if mentioned_user_id != bot_user_id:
return text
return text[end + 1 :].lstrip()
class SlackChannel(Channel): class SlackChannel(Channel):
"""Slack IM channel using Socket Mode (WebSocket, no public IP). """Slack IM channel using Socket Mode (WebSocket, no public IP).
Configuration keys (in ``config.yaml`` under ``channels.slack``): Configuration keys (in ``config.yaml`` under ``channels.slack``):
- ``bot_token``: Slack Bot User OAuth Token (xoxb-...). - ``bot_token``: Slack Bot User OAuth Token (xoxb-...).
- ``app_token``: Slack App-Level Token (xapp-...) for Socket Mode. - ``app_token``: Slack App-Level Token (xapp-...) for Socket Mode.
- ``allowed_users``: (optional) List of allowed Slack user IDs, or a - ``allowed_users``: (optional) List of allowed Slack user IDs. Empty = allow all.
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: def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None:
@ -95,11 +30,7 @@ class SlackChannel(Channel):
self._socket_client = None self._socket_client = None
self._web_client = None self._web_client = None
self._loop: asyncio.AbstractEventLoop | None = None self._loop: asyncio.AbstractEventLoop | None = None
self._allowed_users = _normalize_allowed_users(config.get("allowed_users", [])) self._allowed_users: set[str] = {str(user_id) for user_id in config.get("allowed_users", [])}
self._web_client_factory = config.get("web_client_factory")
self._connection_web_clients: dict[str, tuple[str, Any]] = {}
configured_bot_user_id = config.get("bot_user_id")
self._bot_user_id = str(configured_bot_user_id).lstrip("@") if configured_bot_user_id else None
async def start(self) -> None: async def start(self) -> None:
if self._running: if self._running:
@ -114,21 +45,15 @@ class SlackChannel(Channel):
return return
self._SocketModeResponse = SocketModeResponse self._SocketModeResponse = SocketModeResponse
if self._web_client_factory is None:
self._web_client_factory = WebClient
bot_token = self.config.get("bot_token", "") bot_token = self.config.get("bot_token", "")
app_token = self.config.get("app_token", "") app_token = self.config.get("app_token", "")
if self.config.get("event_delivery") == "http":
logger.error("Slack HTTP Events mode is not supported by this channel adapter; use Socket Mode with app_token")
return
if not bot_token or not app_token: if not bot_token or not app_token:
logger.error("Slack channel requires bot_token and app_token") logger.error("Slack channel requires bot_token and app_token")
return return
await self._initialize_operator_web_client(str(bot_token)) self._web_client = WebClient(token=bot_token)
self._socket_client = SocketModeClient( self._socket_client = SocketModeClient(
app_token=app_token, app_token=app_token,
web_client=self._web_client, web_client=self._web_client,
@ -153,53 +78,60 @@ class SlackChannel(Channel):
logger.info("Slack channel stopped") logger.info("Slack channel stopped")
async def send(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None: async def send(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None:
web_client = await self._get_web_client_for_message(msg) if not self._web_client:
if not web_client:
return return
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"channel": msg.chat_id, "channel": msg.chat_id,
"text": _slack_md_converter.convert(_escape_slack_text(msg.text)), "text": _slack_md_converter.convert(msg.text),
} }
if msg.thread_ts: if msg.thread_ts:
kwargs["thread_ts"] = msg.thread_ts kwargs["thread_ts"] = msg.thread_ts
async def post_message() -> None: last_exc: Exception | None = None
await asyncio.to_thread(web_client.chat_postMessage, **kwargs) for attempt in range(_max_retries):
# Add a completion reaction to the thread root try:
if msg.thread_ts: await asyncio.to_thread(self._web_client.chat_postMessage, **kwargs)
await asyncio.to_thread( # Add a completion reaction to the thread root
self._add_reaction_with_client, if msg.thread_ts:
web_client,
msg.chat_id,
msg.thread_ts,
"white_check_mark",
)
try:
await self._send_with_retry(
post_message,
max_retries=_max_retries,
log_prefix="[Slack]",
)
except Exception:
# Add failure reaction on error
if msg.thread_ts:
try:
await asyncio.to_thread( await asyncio.to_thread(
self._add_reaction_with_client, self._add_reaction,
web_client,
msg.chat_id, msg.chat_id,
msg.thread_ts, msg.thread_ts,
"x", "white_check_mark",
) )
except Exception: return
pass except Exception as exc:
raise last_exc = exc
if attempt < _max_retries - 1:
delay = 2**attempt # 1s, 2s
logger.warning(
"[Slack] send failed (attempt %d/%d), retrying in %ds: %s",
attempt + 1,
_max_retries,
delay,
exc,
)
await asyncio.sleep(delay)
logger.error("[Slack] send failed after %d attempts: %s", _max_retries, last_exc)
# Add failure reaction on error
if msg.thread_ts:
try:
await asyncio.to_thread(
self._add_reaction,
msg.chat_id,
msg.thread_ts,
"x",
)
except Exception:
pass
if last_exc is None:
raise RuntimeError("Slack send failed without an exception from any attempt")
raise last_exc
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
web_client = await self._get_web_client_for_message(msg) if not self._web_client:
if not web_client:
return False return False
try: try:
@ -212,7 +144,7 @@ class SlackChannel(Channel):
if msg.thread_ts: if msg.thread_ts:
kwargs["thread_ts"] = msg.thread_ts kwargs["thread_ts"] = msg.thread_ts
await asyncio.to_thread(web_client.files_upload_v2, **kwargs) await asyncio.to_thread(self._web_client.files_upload_v2, **kwargs)
logger.info("[Slack] file uploaded: %s to channel=%s", attachment.filename, msg.chat_id) logger.info("[Slack] file uploaded: %s to channel=%s", attachment.filename, msg.chat_id)
return True return True
except Exception: except Exception:
@ -221,45 +153,12 @@ class SlackChannel(Channel):
# -- internal ---------------------------------------------------------- # -- internal ----------------------------------------------------------
async def _initialize_operator_web_client(self, bot_token: str) -> None: def _add_reaction(self, channel_id: str, timestamp: str, emoji: str) -> None:
self._web_client = self._web_client_factory(token=bot_token) """Add an emoji reaction to a message (best-effort, non-blocking)."""
if self._bot_user_id is not None: if not self._web_client:
return return
try: try:
auth_info = await asyncio.to_thread(self._web_client.auth_test) self._web_client.reactions_add(
user_id = auth_info.get("user_id") if isinstance(auth_info, dict) else None
if user_id is None:
auth_get = getattr(auth_info, "get", None)
user_id = auth_get("user_id") if callable(auth_get) else None
if isinstance(user_id, str) and user_id:
self._bot_user_id = user_id
except Exception:
logger.warning("[Slack] failed to resolve bot user id; app mention text may include the bot mention", exc_info=True)
async def _get_web_client_for_message(self, msg: OutboundMessage):
if msg.connection_id and self._connection_repo is not None:
credentials = await self._connection_repo.get_credentials(msg.connection_id)
access_token = credentials.get("access_token") if credentials else None
if not access_token:
return self._web_client
# WebClient keeps its own HTTP session and rate-limit state, so
# reuse one per connection until its token changes.
cached = self._connection_web_clients.get(msg.connection_id)
if cached is not None and cached[0] == access_token:
return cached[1]
if self._web_client_factory is None:
from slack_sdk import WebClient
self._web_client_factory = WebClient
web_client = self._web_client_factory(token=access_token)
self._connection_web_clients[msg.connection_id] = (access_token, web_client)
return web_client
return self._web_client
@staticmethod
def _add_reaction_with_client(web_client, channel_id: str, timestamp: str, emoji: str) -> None:
try:
web_client.reactions_add(
channel=channel_id, channel=channel_id,
timestamp=timestamp, timestamp=timestamp,
name=emoji, name=emoji,
@ -268,12 +167,6 @@ class SlackChannel(Channel):
if "already_reacted" not in str(exc): if "already_reacted" not in str(exc):
logger.warning("[Slack] failed to add reaction %s: %s", emoji, exc) logger.warning("[Slack] failed to add reaction %s: %s", emoji, exc)
def _add_reaction(self, channel_id: str, timestamp: str, emoji: str) -> None:
"""Add an emoji reaction to a message (best-effort, non-blocking)."""
if not self._web_client:
return
self._add_reaction_with_client(self._web_client, channel_id, timestamp, emoji)
def _send_running_reply(self, channel_id: str, thread_ts: str) -> None: def _send_running_reply(self, channel_id: str, thread_ts: str) -> None:
"""Send a 'Working on it......' reply in the thread (called from SDK thread).""" """Send a 'Working on it......' reply in the thread (called from SDK thread)."""
if not self._web_client: if not self._web_client:
@ -299,61 +192,36 @@ class SlackChannel(Channel):
if event_type != "events_api": if event_type != "events_api":
return return
if self._bot_user_id is None:
authorization = next((item for item in req.payload.get("authorizations", []) if isinstance(item, dict)), None)
user_id = authorization.get("user_id") if authorization else None
if isinstance(user_id, str) and user_id:
self._bot_user_id = user_id
event = req.payload.get("event", {}) event = req.payload.get("event", {})
etype = event.get("type", "") etype = event.get("type", "")
# Handle message events (DM or @mention) # Handle message events (DM or @mention)
if etype in ("message", "app_mention"): if etype in ("message", "app_mention"):
self._handle_message_event( self._handle_message_event(event)
event,
team_id=req.payload.get("team_id") or req.payload.get("team") or event.get("team"),
)
except Exception: except Exception:
logger.exception("Error processing Slack event") logger.exception("Error processing Slack event")
def _handle_message_event(self, event: dict, *, team_id: str | None = None) -> None: def _handle_message_event(self, event: dict) -> None:
# Ignore bot messages # Ignore bot messages
if event.get("bot_id") or event.get("subtype"): if event.get("bot_id") or event.get("subtype"):
return return
user_id = event.get("user", "") user_id = event.get("user", "")
text = event.get("text", "").strip() # Check allowed users
if event.get("type") == "app_mention":
text = _strip_leading_slack_bot_mention(text, self._bot_user_id)
if not text:
return
connect_code = self._pending_connect_code(text)
if connect_code:
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(
self._bind_connection_from_connect_code(
event=event,
team_id=str(team_id or ""),
code=connect_code,
),
self._loop,
)
return
# Check allowed users after connect-code handling so browser-initiated
# binding can bootstrap a new external identity.
if self._allowed_users and user_id not in self._allowed_users: if self._allowed_users and user_id not in self._allowed_users:
logger.debug("Ignoring message from non-allowed user: %s", user_id) logger.debug("Ignoring message from non-allowed user: %s", user_id)
return return
text = event.get("text", "").strip()
if not text:
return
channel_id = event.get("channel", "") channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts") or event.get("ts", "") thread_ts = event.get("thread_ts") or event.get("ts", "")
if is_known_channel_command(text): if text.startswith("/"):
msg_type = InboundMessageType.COMMAND msg_type = InboundMessageType.COMMAND
else: else:
msg_type = InboundMessageType.CHAT msg_type = InboundMessageType.CHAT
@ -367,12 +235,6 @@ class SlackChannel(Channel):
text=text, text=text,
msg_type=msg_type, msg_type=msg_type,
thread_ts=thread_ts, thread_ts=thread_ts,
metadata={
# team_id is already resolved (payload team_id/team, else event team) by the caller.
"team_id": team_id,
"message_id": event.get("ts"),
"client_msg_id": event.get("client_msg_id"),
},
) )
inbound.topic_id = thread_ts inbound.topic_id = thread_ts
@ -381,61 +243,4 @@ class SlackChannel(Channel):
self._add_reaction(channel_id, event.get("ts", thread_ts), "eyes") self._add_reaction(channel_id, event.get("ts", thread_ts), "eyes")
# Send "running" reply first (fire-and-forget from SDK thread) # Send "running" reply first (fire-and-forget from SDK thread)
self._send_running_reply(channel_id, thread_ts) self._send_running_reply(channel_id, thread_ts)
if self._connection_repo is None: asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._loop)
asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._loop)
else:
asyncio.run_coroutine_threadsafe(self._publish_inbound_with_connection(inbound, team_id=team_id), self._loop)
async def _publish_inbound_with_connection(self, inbound, *, team_id: str | None = None) -> None:
inbound = await self._attach_connection_identity(inbound, team_id=team_id)
await self.bus.publish_inbound(inbound)
async def _attach_connection_identity(self, inbound, *, team_id: str | None = None):
workspace_id = str(team_id or inbound.metadata.get("team_id") or "")
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="slack",
workspace_id=workspace_id,
)
async def _bind_connection_from_connect_code(self, *, event: dict, team_id: str, code: str) -> bool:
if self._connection_repo is None or not code:
return False
channel_id = str(event.get("channel") or "")
thread_ts = str(event.get("thread_ts") or event.get("ts") or "")
state = await self._connection_repo.consume_oauth_state(provider="slack", state=code)
if state is None:
await self._post_connection_reply(channel_id, "Slack connection code is invalid or expired.", thread_ts)
return True
user_id = str(event.get("user") or "")
if not user_id or not team_id:
await self._post_connection_reply(channel_id, "Slack connection could not be completed from this message.", thread_ts)
return True
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="slack",
external_account_id=user_id,
workspace_id=team_id,
metadata={
"team_id": team_id,
"channel_id": channel_id,
},
status="connected",
)
await self._post_connection_reply(channel_id, "Slack connected to DeerFlow.", thread_ts)
return True
async def _post_connection_reply(self, channel_id: str, text: str, thread_ts: str | None = None) -> None:
if not self._web_client or not channel_id:
return
kwargs: dict[str, Any] = {"channel": channel_id, "text": text}
if thread_ts:
kwargs["thread_ts"] = thread_ts
try:
await asyncio.to_thread(self._web_client.chat_postMessage, **kwargs)
except Exception:
logger.exception("[Slack] failed to send connection reply in channel=%s", channel_id)

View File

@ -5,42 +5,13 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import threading import threading
import time
from collections.abc import Coroutine
from typing import Any from typing import Any
from app.channels.base import Channel from app.channels.base import Channel
from app.channels.connection_identity import attach_connection_identity from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
from app.channels.message_bus import (
INBOUND_FILE_CONTENT_KEY,
InboundMessage,
InboundMessageType,
MessageBus,
OutboundMessage,
ResolvedAttachment,
)
from deerflow.uploads.manager import is_upload_staging_file, normalize_filename
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TELEGRAM_MAX_MESSAGE_LENGTH = 4096
TELEGRAM_MAX_RICH_MESSAGE_LENGTH = 32768
# Telegram's hosted Bot API documents this as 20 MB (decimal bytes).
TELEGRAM_MAX_INBOUND_FILE_BYTES = 20_000_000
# Keep Telegram cleanup inside the Gateway's five-second shutdown-hook budget.
TELEGRAM_SHUTDOWN_TIMEOUT_SECONDS = 4.0
TELEGRAM_BRIDGE_DRAIN_TIMEOUT_SECONDS = 1.0
STREAM_EDIT_MIN_INTERVAL_SECONDS = 1.0
# Groups (negative chat_id) are capped at 20 messages/minute by Telegram,
# so stream edits there must pace well below the private-chat 1 msg/s guideline.
STREAM_EDIT_GROUP_MIN_INTERVAL_SECONDS = 3.0
# Bound on tracked in-flight streamed messages; entries normally clear on the
# final update, this only guards against leaks when a final never arrives.
MAX_TRACKED_STREAM_MESSAGES = 256
# Indirection so tests can patch the clock without touching the global time module.
_monotonic = time.monotonic
class TelegramChannel(Channel): class TelegramChannel(Channel):
"""Telegram bot channel using long-polling. """Telegram bot channel using long-polling.
@ -56,9 +27,6 @@ class TelegramChannel(Channel):
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._tg_loop: asyncio.AbstractEventLoop | None = None self._tg_loop: asyncio.AbstractEventLoop | None = None
self._main_loop: asyncio.AbstractEventLoop | None = None self._main_loop: asyncio.AbstractEventLoop | None = None
# Tasks submitted from the main dispatcher loop back to PTB's loop.
# Only the Telegram loop mutates this set.
self._tg_bridge_tasks: set[asyncio.Task[Any]] = set()
self._allowed_users: set[int] = set() self._allowed_users: set[int] = set()
for uid in config.get("allowed_users", []): for uid in config.get("allowed_users", []):
try: try:
@ -67,13 +35,6 @@ class TelegramChannel(Channel):
pass pass
# chat_id -> last sent message_id for threaded replies # chat_id -> last sent message_id for threaded replies
self._last_bot_message: dict[str, int] = {} self._last_bot_message: dict[str, int] = {}
# stream_key ("chat_id:thread_ts") -> state of the in-flight streamed
# bot message being edited in place: {"message_id", "last_edit_at", "last_text"}
self._stream_messages: dict[str, dict[str, Any]] = {}
@property
def supports_streaming(self) -> bool:
return True
async def start(self) -> None: async def start(self) -> None:
if self._running: if self._running:
@ -99,25 +60,15 @@ class TelegramChannel(Channel):
# Command handlers # Command handlers
app.add_handler(CommandHandler("start", self._cmd_start)) app.add_handler(CommandHandler("start", self._cmd_start))
app.add_handler(CommandHandler("bootstrap", self._cmd_generic))
app.add_handler(CommandHandler("new", self._cmd_generic)) app.add_handler(CommandHandler("new", self._cmd_generic))
app.add_handler(CommandHandler("status", self._cmd_generic)) app.add_handler(CommandHandler("status", self._cmd_generic))
app.add_handler(CommandHandler("models", self._cmd_generic)) app.add_handler(CommandHandler("models", self._cmd_generic))
app.add_handler(CommandHandler("memory", self._cmd_generic)) app.add_handler(CommandHandler("memory", self._cmd_generic))
app.add_handler(CommandHandler("goal", self._cmd_generic))
app.add_handler(CommandHandler("help", self._cmd_generic)) app.add_handler(CommandHandler("help", self._cmd_generic))
# Slash skill commands are dynamic and cannot all be pre-registered
# with Telegram, so route unknown slash commands through chat handling.
app.add_handler(MessageHandler(filters.TEXT & filters.COMMAND, self._on_text))
# General message handler # General message handler
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text))
# Telegram keeps attachment captions separate from message.text, and
# photo/document updates do not match filters.TEXT.
app.add_handler(MessageHandler(filters.PHOTO | filters.Document.ALL, self._on_text))
self._application = app self._application = app
# Run polling in a dedicated thread with its own event loop # Run polling in a dedicated thread with its own event loop
@ -128,47 +79,12 @@ class TelegramChannel(Channel):
async def stop(self) -> None: async def stop(self) -> None:
self._running = False self._running = False
self.bus.unsubscribe_outbound(self._on_outbound) self.bus.unsubscribe_outbound(self._on_outbound)
shutdown_loop = asyncio.get_running_loop() if self._tg_loop and self._tg_loop.is_running():
deadline = shutdown_loop.time() + TELEGRAM_SHUTDOWN_TIMEOUT_SECONDS self._tg_loop.call_soon_threadsafe(self._tg_loop.stop)
telegram_loop = self._tg_loop if self._thread:
worker_thread = self._thread self._thread.join(timeout=10)
self._thread = None
try: self._application = None
if telegram_loop and telegram_loop.is_running():
drain_future = asyncio.run_coroutine_threadsafe(self._cancel_telegram_bridge_tasks(), telegram_loop)
try:
remaining = max(0.0, deadline - shutdown_loop.time())
await asyncio.wait_for(asyncio.wrap_future(drain_future), timeout=remaining)
except asyncio.CancelledError:
drain_future.cancel()
raise
except TimeoutError:
drain_future.cancel()
logger.warning("[Telegram] timed out cancelling inbound file downloads during shutdown")
except Exception as exc:
logger.warning("[Telegram] failed to cancel inbound file downloads during shutdown: %s", type(exc).__name__)
finally:
if telegram_loop and telegram_loop.is_running():
try:
telegram_loop.call_soon_threadsafe(telegram_loop.stop)
except RuntimeError:
pass
try:
if worker_thread is not None and worker_thread.is_alive():
remaining = max(0.0, deadline - shutdown_loop.time())
if remaining:
try:
await asyncio.wait_for(
asyncio.to_thread(worker_thread.join, remaining),
timeout=remaining,
)
except TimeoutError:
logger.warning("[Telegram] polling thread did not stop within the shutdown budget")
finally:
if worker_thread is not None and worker_thread.is_alive():
logger.warning("[Telegram] polling thread is still exiting after bounded shutdown")
self._thread = None
self._application = None
logger.info("Telegram channel stopped") logger.info("Telegram channel stopped")
async def send(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None: async def send(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None:
@ -181,190 +97,37 @@ class TelegramChannel(Channel):
logger.error("Invalid Telegram chat_id: %s", msg.chat_id) logger.error("Invalid Telegram chat_id: %s", msg.chat_id)
return return
key = self._stream_key(msg.chat_id, msg.thread_ts) kwargs: dict[str, Any] = {"chat_id": chat_id, "text": msg.text}
if not msg.is_final:
await self._send_stream_update(chat_id, key, msg.text, reply_to=self._parse_message_id(msg.thread_ts))
return
state = self._stream_messages.pop(key, None)
if state is not None:
if self._can_send_rich(msg.text) and await self._edit_rich_message(chat_id, state["message_id"], msg.text):
self._last_bot_message[msg.chat_id] = state["message_id"]
return
await self._finalize_stream_message(chat_id, msg.chat_id, state, msg.text)
return
if self._can_send_rich(msg.text):
try:
message_id = await self._send_new_rich_message(chat_id, msg.chat_id, msg.text, _max_retries=_max_retries)
except Exception as exc:
logger.warning("[Telegram] Rich Message send failed in chat=%s; falling back to plain text: %s", chat_id, exc)
message_id = None
if message_id is not None:
return
for chunk in self._split_message(msg.text):
await self._send_new_message(chat_id, msg.chat_id, chunk, _max_retries=_max_retries)
async def _send_stream_update(self, chat_id: int, key: str, text: str, reply_to: int | None = None) -> None:
"""Edit the in-flight streamed message with accumulated text.
Updates are best-effort: throttled, rate-limit drops are silent. The
manager always publishes a final message afterwards, which guarantees
delivery of the complete text.
"""
if not text:
return
display = text
if len(display) > TELEGRAM_MAX_MESSAGE_LENGTH:
display = display[: TELEGRAM_MAX_MESSAGE_LENGTH - 1] + ""
bot = self._application.bot
state = self._stream_messages.get(key)
send_kwargs: dict[str, Any] = {"chat_id": chat_id, "text": display}
if reply_to:
send_kwargs["reply_to_message_id"] = reply_to
if state is None:
try:
sent = await bot.send_message(**send_kwargs)
except Exception:
logger.exception("[Telegram] failed to start stream message in chat=%s", chat_id)
return
self._register_stream_message(key, message_id=sent.message_id, last_text=display, last_edit_at=_monotonic())
return
now = _monotonic()
min_interval = STREAM_EDIT_GROUP_MIN_INTERVAL_SECONDS if chat_id < 0 else STREAM_EDIT_MIN_INTERVAL_SECONDS
if now - state["last_edit_at"] < min_interval:
return
if display == state["last_text"]:
return
try:
await bot.edit_message_text(chat_id=chat_id, message_id=state["message_id"], text=display)
except Exception as exc:
if self._is_not_modified(exc):
state["last_text"] = display
return
if self._is_retry_after(exc):
logger.debug("[Telegram] stream edit rate-limited in chat=%s, dropping update", chat_id)
return
logger.warning("[Telegram] stream edit failed in chat=%s, sending new message: %s", chat_id, exc)
try:
sent = await bot.send_message(**send_kwargs)
except Exception:
logger.exception("[Telegram] failed to send fallback stream message in chat=%s", chat_id)
return
state["message_id"] = sent.message_id
state["last_edit_at"] = _monotonic()
state["last_text"] = display
async def _finalize_stream_message(self, chat_id: int, chat_key: str, state: dict[str, Any], text: str) -> None:
"""Apply the final text: edit the streamed message, splitting overflow into follow-ups."""
bot = self._application.bot
chunks = self._split_message(text or "")
edited = True
if chunks[0] != state["last_text"]:
edited = await self._edit_final_chunk(bot, chat_id, state["message_id"], chunks[0])
if edited:
self._last_bot_message[chat_key] = state["message_id"]
else:
# Edit could not be applied (e.g. message deleted) — deliver the
# first chunk as a fresh message with the standard retry policy.
await self._send_new_message(chat_id, chat_key, chunks[0])
for chunk in chunks[1:]:
await self._send_new_message(chat_id, chat_key, chunk)
async def _edit_final_chunk(self, bot, chat_id: int, message_id: int, text: str) -> bool:
"""Edit with one rate-limit retry. Returns False if the edit could not be applied."""
for attempt in range(2):
try:
await bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text)
return True
except Exception as exc:
if self._is_not_modified(exc):
return True
if self._is_retry_after(exc) and attempt == 0:
await asyncio.sleep(self._retry_after_seconds(exc))
continue
logger.warning("[Telegram] final edit failed in chat=%s: %s", chat_id, exc)
return False
return False
def _can_send_rich(self, text: str) -> bool:
return bool(self.config.get("rich_messages")) and 0 < len(text) <= TELEGRAM_MAX_RICH_MESSAGE_LENGTH
async def _edit_rich_message(self, chat_id: int, message_id: int, text: str) -> bool:
"""Replace a streamed preview with a persistent Telegram Rich Message."""
from telegram.error import BadRequest, EndPointNotFound
bot = self._application.bot
data = {"chat_id": chat_id, "message_id": message_id, "rich_message": {"markdown": text}}
for attempt in range(2):
try:
await bot.do_api_request("editMessageText", api_kwargs=data)
return True
except Exception as exc:
if self._is_retry_after(exc) and attempt == 0:
await asyncio.sleep(self._retry_after_seconds(exc))
continue
if isinstance(exc, (BadRequest, EndPointNotFound)):
logger.warning("[Telegram] Rich Message rejected in chat=%s; falling back to plain text: %s", chat_id, exc)
return False
logger.warning("[Telegram] final rich edit failed in chat=%s: %s", chat_id, exc)
return False
return False
async def _send_new_rich_message(self, chat_id: int, chat_key: str, text: str, *, _max_retries: int) -> int | None:
"""Send raw agent Markdown through Bot API 10.1 Rich Messages."""
from telegram.error import BadRequest, EndPointNotFound
bot = self._application.bot
async def send_message() -> int | None:
try:
result = await bot.do_api_request(
"sendRichMessage",
api_kwargs={"chat_id": chat_id, "rich_message": {"markdown": text}},
)
except (BadRequest, EndPointNotFound) as exc:
logger.warning("[Telegram] Rich Message rejected in chat=%s; falling back to plain text: %s", chat_id, exc)
return None
message_id = int(result["message_id"])
self._last_bot_message[chat_key] = message_id
return message_id
return await self._send_with_retry(send_message, max_retries=_max_retries, log_prefix="[Telegram]")
async def _send_new_message(self, chat_id: int, chat_key: str, text: str, *, _max_retries: int = 3) -> int | None:
"""Send a fresh message with retry/backoff. Returns the sent message_id."""
kwargs: dict[str, Any] = {"chat_id": chat_id, "text": text}
# Reply to the last bot message in this chat for threading # Reply to the last bot message in this chat for threading
reply_to = self._last_bot_message.get(chat_key) reply_to = self._last_bot_message.get(msg.chat_id)
if reply_to: if reply_to:
kwargs["reply_to_message_id"] = reply_to kwargs["reply_to_message_id"] = reply_to
bot = self._application.bot bot = self._application.bot
last_exc: Exception | None = None
for attempt in range(_max_retries):
try:
sent = await bot.send_message(**kwargs)
self._last_bot_message[msg.chat_id] = sent.message_id
return
except Exception as exc:
last_exc = exc
if attempt < _max_retries - 1:
delay = 2**attempt # 1s, 2s
logger.warning(
"[Telegram] send failed (attempt %d/%d), retrying in %ds: %s",
attempt + 1,
_max_retries,
delay,
exc,
)
await asyncio.sleep(delay)
async def send_message() -> int: logger.error("[Telegram] send failed after %d attempts: %s", _max_retries, last_exc)
sent = await bot.send_message(**kwargs) if last_exc is None:
self._last_bot_message[chat_key] = sent.message_id raise RuntimeError("Telegram send failed without an exception from any attempt")
return sent.message_id raise last_exc
return await self._send_with_retry(
send_message,
max_retries=_max_retries,
log_prefix="[Telegram]",
)
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
if not self._application: if not self._application:
@ -408,287 +171,44 @@ class TelegramChannel(Channel):
logger.exception("[Telegram] failed to send file: %s", attachment.filename) logger.exception("[Telegram] failed to send file: %s", attachment.filename)
return False return False
async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage:
"""Download inbound Telegram attachments for the shared upload pipeline.
The Bot API download URL contains the bot token, so this adapter never
exposes that URL to ``InboundMessage`` or logs. Instead it hands the
downloaded bytes to ``ChannelManager`` through a short-lived private
field that the manager consumes before persisting safe upload metadata.
"""
# Owner/thread scoping is intentionally applied by ChannelManager when
# it persists these bytes through _ingest_inbound_files().
del thread_id, user_id
if not msg.files:
return msg
bot = self._application.bot if self._application is not None else None
materialized: list[dict[str, Any]] = []
unavailable: list[str] = []
for file_info in msg.files:
if not isinstance(file_info, dict):
continue
filename = self._safe_inbound_filename(file_info.get("filename"), "attachment")
file_id = file_info.get("file_id") if isinstance(file_info.get("file_id"), str) else ""
declared_size = self._file_size(file_info.get("size"))
if declared_size is not None and declared_size > TELEGRAM_MAX_INBOUND_FILE_BYTES:
logger.warning("[Telegram] inbound file exceeds 20 MB download limit, skipping: %s", filename)
unavailable.append(f"{filename} (exceeds the 20 MB download limit)")
continue
if bot is None or not file_id:
logger.error("[Telegram] cannot download inbound file: %s", filename)
unavailable.append(f"{filename} (download unavailable)")
continue
try:
resolved_size, content = await self._run_on_telegram_loop(self._download_inbound_file(bot, file_id))
except Exception as exc:
# Exception strings from HTTP clients can contain request URLs.
# Log only the class name so a Bot API token can never leak.
logger.error("[Telegram] failed to download inbound file %s: %s", filename, type(exc).__name__)
unavailable.append(f"{filename} (download failed)")
continue
if content is None:
logger.warning("[Telegram] resolved inbound file exceeds 20 MB download limit, skipping: %s", filename)
unavailable.append(f"{filename} (exceeds the 20 MB download limit)")
continue
if len(content) > TELEGRAM_MAX_INBOUND_FILE_BYTES:
logger.warning("[Telegram] downloaded inbound file exceeds 20 MB limit, skipping: %s", filename)
unavailable.append(f"{filename} (exceeds the 20 MB download limit)")
continue
materialized.append(
{
"type": "image" if file_info.get("type") == "image" else "file",
"filename": filename,
"mime_type": file_info.get("mime_type") if isinstance(file_info.get("mime_type"), str) else "application/octet-stream",
"size": len(content),
INBOUND_FILE_CONTENT_KEY: content,
}
)
msg.files = materialized
if unavailable:
notice = f"[Telegram attachment unavailable: {', '.join(unavailable)}]"
msg.text = f"{msg.text}\n\n{notice}" if msg.text else notice
return msg
# -- helpers ----------------------------------------------------------- # -- helpers -----------------------------------------------------------
async def _download_inbound_file(self, bot: Any, file_id: str) -> tuple[int | None, bytearray | None]:
"""Fetch one file entirely on the event loop that owns PTB's HTTP client."""
telegram_file = await bot.get_file(file_id)
resolved_size = self._file_size(getattr(telegram_file, "file_size", None))
if resolved_size is not None and resolved_size > TELEGRAM_MAX_INBOUND_FILE_BYTES:
return resolved_size, None
return resolved_size, await telegram_file.download_as_bytearray()
async def _track_telegram_bridge_task(self, coroutine: Coroutine[Any, Any, Any]) -> Any:
"""Keep a main-loop submission visible to Telegram-loop shutdown."""
task = asyncio.current_task()
if task is None:
coroutine.close()
raise RuntimeError("Telegram bridge task is unavailable")
self._tg_bridge_tasks.add(task)
try:
return await coroutine
finally:
self._tg_bridge_tasks.discard(task)
async def _cancel_telegram_bridge_tasks(self) -> None:
"""Cancel and drain cross-loop PTB work before its event loop exits."""
current = asyncio.current_task()
pending = [task for task in self._tg_bridge_tasks if task is not current and not task.done()]
for task in pending:
task.cancel()
if pending:
done, still_pending = await asyncio.wait(pending, timeout=TELEGRAM_BRIDGE_DRAIN_TIMEOUT_SECONDS)
if done:
await asyncio.gather(*done, return_exceptions=True)
if still_pending:
logger.warning("[Telegram] %d inbound file download task(s) did not cancel promptly", len(still_pending))
async def _run_on_telegram_loop(self, coroutine: Coroutine[Any, Any, Any]) -> Any:
"""Await a PTB coroutine without using its HTTP client across event loops."""
telegram_loop = self._tg_loop
current_loop = asyncio.get_running_loop()
if telegram_loop is None or telegram_loop is current_loop:
return await coroutine
if not telegram_loop.is_running():
coroutine.close()
raise RuntimeError("Telegram event loop is not running")
tracked_coroutine = self._track_telegram_bridge_task(coroutine)
try:
future = asyncio.run_coroutine_threadsafe(tracked_coroutine, telegram_loop)
except BaseException:
# A concurrent shutdown can close the loop between the running
# check and scheduling. Close the unscheduled coroutine explicitly.
tracked_coroutine.close()
coroutine.close()
raise
try:
return await asyncio.wrap_future(future)
except asyncio.CancelledError:
future.cancel()
raise
@staticmethod
def _stream_key(chat_id: str, thread_ts: str | None) -> str:
return f"{chat_id}:{thread_ts or ''}"
@staticmethod
def _parse_message_id(value: str | None) -> int | None:
try:
return int(value) if value else None
except (OverflowError, TypeError, ValueError):
return None
@staticmethod
def _file_size(value: Any) -> int | None:
if isinstance(value, bool):
return None
try:
size = int(value)
except (TypeError, ValueError):
return None
return size if size >= 0 else None
@staticmethod
def _safe_inbound_filename(value: Any, fallback: str) -> str:
if not isinstance(value, str):
return fallback
try:
candidate = normalize_filename(value.strip())
except (UnicodeError, ValueError):
return fallback
if is_upload_staging_file(candidate) or any(ord(char) < 32 for char in candidate):
return fallback
return candidate
@classmethod
def _extract_inbound_files(cls, message: Any) -> list[dict[str, Any]]:
files: list[dict[str, Any]] = []
message_id = str(getattr(message, "message_id", "message"))
# Materialize the PTB sequence once. Test doubles and partially shaped
# update objects can expose a truthy but empty iterable here.
photo_sizes = tuple(getattr(message, "photo", None) or ())
if photo_sizes:
photo = max(
photo_sizes,
key=lambda item: (
(cls._file_size(getattr(item, "width", None)) or 0) * (cls._file_size(getattr(item, "height", None)) or 0),
cls._file_size(getattr(item, "file_size", None)) or 0,
),
)
file_id = getattr(photo, "file_id", None)
if isinstance(file_id, str) and file_id:
file_unique_id = getattr(photo, "file_unique_id", None)
files.append(
{
"type": "image",
"file_id": file_id,
"file_unique_id": file_unique_id if isinstance(file_unique_id, str) else None,
"filename": f"telegram-photo-{message_id}.jpg",
"mime_type": "image/jpeg",
"size": cls._file_size(getattr(photo, "file_size", None)),
}
)
document = getattr(message, "document", None)
document_id = getattr(document, "file_id", None) if document is not None else None
if isinstance(document_id, str) and document_id:
file_unique_id = getattr(document, "file_unique_id", None)
mime_type = getattr(document, "mime_type", None)
if not isinstance(mime_type, str) or not mime_type:
mime_type = "application/octet-stream"
# Avoid the lazy system MIME database lookup on the Telegram event
# loop. The original name is preferred; an opaque extension is safe.
fallback = f"telegram-document-{message_id}.bin"
files.append(
{
"type": "file",
"file_id": document_id,
"file_unique_id": file_unique_id if isinstance(file_unique_id, str) else None,
"filename": cls._safe_inbound_filename(getattr(document, "file_name", None), fallback),
"mime_type": mime_type,
"size": cls._file_size(getattr(document, "file_size", None)),
}
)
return files
def _register_stream_message(self, key: str, *, message_id: int, last_text: str, last_edit_at: float) -> None:
self._stream_messages.pop(key, None)
while len(self._stream_messages) >= MAX_TRACKED_STREAM_MESSAGES:
self._stream_messages.pop(next(iter(self._stream_messages)))
self._stream_messages[key] = {
"message_id": message_id,
"last_edit_at": last_edit_at,
"last_text": last_text,
}
@staticmethod
def _is_retry_after(exc: Exception) -> bool:
return getattr(exc, "retry_after", None) is not None
@staticmethod
def _retry_after_seconds(exc: Exception) -> float:
value = getattr(exc, "retry_after", 0)
if hasattr(value, "total_seconds"):
return float(value.total_seconds())
return float(value)
@staticmethod
def _is_not_modified(exc: Exception) -> bool:
return "message is not modified" in str(exc).lower()
@staticmethod
def _split_message(text: str) -> list[str]:
return [text[i : i + TELEGRAM_MAX_MESSAGE_LENGTH] for i in range(0, len(text), TELEGRAM_MAX_MESSAGE_LENGTH)] or [text]
async def _send_running_reply(self, chat_id: str, reply_to_message_id: int) -> None: async def _send_running_reply(self, chat_id: str, reply_to_message_id: int) -> None:
"""Send a 'Working on it...' reply and register it as the stream target.""" """Send a 'Working on it...' reply to the user's message."""
if not self._application: if not self._application:
return return
try: try:
bot = self._application.bot bot = self._application.bot
sent = await bot.send_message( await bot.send_message(
chat_id=int(chat_id), chat_id=int(chat_id),
text="Working on it...", text="Working on it...",
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
) )
self._register_stream_message(
self._stream_key(chat_id, str(reply_to_message_id)),
message_id=sent.message_id,
last_text="Working on it...",
last_edit_at=0.0,
)
logger.info("[Telegram] 'Working on it...' reply sent in chat=%s", chat_id) logger.info("[Telegram] 'Working on it...' reply sent in chat=%s", chat_id)
except Exception: except Exception:
logger.exception("[Telegram] failed to send running reply in chat=%s", chat_id) logger.exception("[Telegram] failed to send running reply in chat=%s", chat_id)
# -- internal ----------------------------------------------------------
@staticmethod
def _log_future_error(fut, name: str, msg_id: str):
try:
exc = fut.exception()
if exc:
logger.error("[Telegram] %s failed for msg_id=%s: %s", name, msg_id, exc)
except Exception:
logger.exception("[Telegram] Failed to inspect future for %s (msg_id=%s)", name, msg_id)
def _run_polling(self) -> None: def _run_polling(self) -> None:
"""Run telegram polling in a dedicated thread.""" """Run telegram polling in a dedicated thread."""
application = self._application
if application is None:
return
self._tg_loop = asyncio.new_event_loop() self._tg_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._tg_loop) asyncio.set_event_loop(self._tg_loop)
try: try:
# Cannot use run_polling() because it calls add_signal_handler(), # Cannot use run_polling() because it calls add_signal_handler(),
# which only works in the main thread. Instead, manually # which only works in the main thread. Instead, manually
# initialize the application and start the updater. # initialize the application and start the updater.
self._tg_loop.run_until_complete(application.initialize()) self._tg_loop.run_until_complete(self._application.initialize())
self._tg_loop.run_until_complete(application.start()) self._tg_loop.run_until_complete(self._application.start())
self._tg_loop.run_until_complete(application.updater.start_polling()) self._tg_loop.run_until_complete(self._application.updater.start_polling())
self._tg_loop.run_forever() self._tg_loop.run_forever()
except Exception: except Exception:
if self._running: if self._running:
@ -696,11 +216,10 @@ class TelegramChannel(Channel):
finally: finally:
# Graceful shutdown # Graceful shutdown
try: try:
self._tg_loop.run_until_complete(self._cancel_telegram_bridge_tasks()) if self._application.updater.running:
if application.updater.running: self._tg_loop.run_until_complete(self._application.updater.stop())
self._tg_loop.run_until_complete(application.updater.stop()) self._tg_loop.run_until_complete(self._application.stop())
self._tg_loop.run_until_complete(application.stop()) self._tg_loop.run_until_complete(self._application.shutdown())
self._tg_loop.run_until_complete(application.shutdown())
except Exception: except Exception:
logger.exception("Error during Telegram shutdown") logger.exception("Error during Telegram shutdown")
@ -709,90 +228,8 @@ class TelegramChannel(Channel):
return True return True
return user_id in self._allowed_users return user_id in self._allowed_users
@staticmethod
def _telegram_display_name(user) -> str:
full_name = getattr(user, "full_name", None)
if isinstance(full_name, str) and full_name:
return full_name
username = getattr(user, "username", None)
if isinstance(username, str) and username:
return username
return str(getattr(user, "id", ""))
async def _bind_connection_from_start_token(self, update, state_token: str) -> bool:
if self._connection_repo is None or not state_token:
return False
state = await self._connection_repo.consume_oauth_state(provider="telegram", state=state_token)
if state is None:
await update.message.reply_text("Telegram connection link is invalid or expired.")
return True
owner_user_id = state["owner_user_id"]
user_id = str(update.effective_user.id)
chat_id = str(update.effective_chat.id)
connection = await self._connection_repo.upsert_connection(
owner_user_id=owner_user_id,
provider="telegram",
external_account_id=user_id,
external_account_name=self._telegram_display_name(update.effective_user),
workspace_id=chat_id,
workspace_name=None,
metadata={
"chat_id": chat_id,
"chat_type": update.effective_chat.type,
"telegram_username": getattr(update.effective_user, "username", None),
},
status="connected",
)
logger.info("[Telegram] bound chat=%s user=%s to DeerFlow user=%s connection=%s", chat_id, user_id, owner_user_id, connection["id"])
await update.message.reply_text("Telegram connected to DeerFlow.")
return True
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="telegram",
workspace_id=inbound.chat_id,
)
def _get_bot_username(self, context) -> str | None:
bot = getattr(context, "bot", None)
username = getattr(bot, "username", None)
if not username and self._application is not None:
username = getattr(getattr(self._application, "bot", None), "username", None)
return str(username) if username else None
@staticmethod
def _strip_bot_username_from_leading_command(text: str, bot_username: str | None) -> str:
username = (bot_username or "").lstrip("@").lower()
if not username or not text.startswith("/"):
return text
parts = text.split(maxsplit=1)
command_token = parts[0]
if "@" not in command_token:
return text
command_name, addressed_username = command_token[1:].rsplit("@", 1)
if not command_name or addressed_username.lower() != username:
return text
normalized = f"/{command_name}"
if len(parts) > 1:
normalized = f"{normalized} {parts[1]}"
return normalized
async def _cmd_start(self, update, context) -> None: async def _cmd_start(self, update, context) -> None:
"""Handle /start command.""" """Handle /start command."""
args = getattr(context, "args", []) if context is not None else []
if args:
# Handle the deep-link bind token before applying allowed_users so a
# browser-initiated bind can bootstrap a new external identity.
handled = await self._bind_connection_from_start_token(update, str(args[0]))
if handled:
return
if not self._check_user(update.effective_user.id): if not self._check_user(update.effective_user.id):
return return
await update.message.reply_text("Welcome to DeerFlow! Send me a message to start a conversation.\nType /help for available commands.") await update.message.reply_text("Welcome to DeerFlow! Send me a message to start a conversation.\nType /help for available commands.")
@ -806,7 +243,7 @@ class TelegramChannel(Channel):
if not self._check_user(update.effective_user.id): if not self._check_user(update.effective_user.id):
return return
text = self._strip_bot_username_from_leading_command(update.message.text.strip(), self._get_bot_username(context)) text = update.message.text
chat_id = str(update.effective_chat.id) chat_id = str(update.effective_chat.id)
user_id = str(update.effective_user.id) user_id = str(update.effective_user.id)
msg_id = str(update.message.message_id) msg_id = str(update.message.message_id)
@ -828,10 +265,8 @@ class TelegramChannel(Channel):
text=text, text=text,
msg_type=InboundMessageType.COMMAND, msg_type=InboundMessageType.COMMAND,
thread_ts=msg_id, thread_ts=msg_id,
metadata={"message_id": msg_id},
) )
inbound.topic_id = topic_id inbound.topic_id = topic_id
inbound = await self._attach_connection_identity(inbound)
if self._main_loop and self._main_loop.is_running(): if self._main_loop and self._main_loop.is_running():
fut = asyncio.run_coroutine_threadsafe(self._process_incoming_with_reply(chat_id, update.message.message_id, inbound), self._main_loop) fut = asyncio.run_coroutine_threadsafe(self._process_incoming_with_reply(chat_id, update.message.message_id, inbound), self._main_loop)
@ -840,19 +275,12 @@ class TelegramChannel(Channel):
logger.warning("[Telegram] Main loop not running. Cannot publish inbound message.") logger.warning("[Telegram] Main loop not running. Cannot publish inbound message.")
async def _on_text(self, update, context) -> None: async def _on_text(self, update, context) -> None:
"""Handle regular text, photo, and document messages.""" """Handle regular text messages."""
if not self._check_user(update.effective_user.id): if not self._check_user(update.effective_user.id):
return return
message = update.message text = update.message.text.strip()
message_text = getattr(message, "text", None) if not text:
caption = getattr(message, "caption", None)
raw_text = message_text if isinstance(message_text, str) else caption if isinstance(caption, str) else ""
text = raw_text.strip()
if text:
text = self._strip_bot_username_from_leading_command(text, self._get_bot_username(context))
files = self._extract_inbound_files(message)
if not text and not files:
return return
chat_id = str(update.effective_chat.id) chat_id = str(update.effective_chat.id)
@ -879,11 +307,8 @@ class TelegramChannel(Channel):
text=text, text=text,
msg_type=InboundMessageType.CHAT, msg_type=InboundMessageType.CHAT,
thread_ts=msg_id, thread_ts=msg_id,
files=files,
metadata={"message_id": msg_id},
) )
inbound.topic_id = topic_id inbound.topic_id = topic_id
inbound = await self._attach_connection_identity(inbound)
if self._main_loop and self._main_loop.is_running(): if self._main_loop and self._main_loop.is_running():
fut = asyncio.run_coroutine_threadsafe(self._process_incoming_with_reply(chat_id, update.message.message_id, inbound), self._main_loop) fut = asyncio.run_coroutine_threadsafe(self._process_incoming_with_reply(chat_id, update.message.message_id, inbound), self._main_loop)

View File

@ -8,10 +8,8 @@ import binascii
import hashlib import hashlib
import json import json
import logging import logging
import math
import mimetypes import mimetypes
import secrets import secrets
import tempfile
import time import time
from collections.abc import Mapping from collections.abc import Mapping
from enum import IntEnum from enum import IntEnum
@ -24,9 +22,7 @@ from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.channels.base import Channel from app.channels.base import Channel
from app.channels.commands import is_known_channel_command from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -256,29 +252,19 @@ class WechatChannel(Channel):
self._state_dir = self._resolve_state_dir(config.get("state_dir")) self._state_dir = self._resolve_state_dir(config.get("state_dir"))
self._cursor_path = self._state_dir / "wechat-getupdates.json" if self._state_dir else None self._cursor_path = self._state_dir / "wechat-getupdates.json" if self._state_dir else None
self._auth_path = self._state_dir / "wechat-auth.json" if self._state_dir else None self._auth_path = self._state_dir / "wechat-auth.json" if self._state_dir else None
# NOTE: persisted state (auth token + cursor) is intentionally NOT loaded self._load_state()
# here. ChannelService._start_channel() constructs the channel directly
# on the async path, so filesystem IO in __init__ would block the event
# loop (the strict blocking-IO gate raises BlockingError on os.stat).
# State is loaded in start() via asyncio.to_thread instead.
async def start(self) -> None: async def start(self) -> None:
if self._running: if self._running:
return return
# Load persisted state off the event loop before the bot_token check
# below: a token restored from the auth file must be visible here so
# the qrcode-login fallback isn't taken unnecessarily. __init__ defers
# this load precisely so construction stays IO-free on the async path.
await asyncio.to_thread(self._load_state)
if not self._bot_token and not self._qrcode_login_enabled: if not self._bot_token and not self._qrcode_login_enabled:
logger.error("WeChat channel requires bot_token or qrcode_login_enabled") logger.error("WeChat channel requires bot_token or qrcode_login_enabled")
return return
self._main_loop = asyncio.get_running_loop() self._main_loop = asyncio.get_running_loop()
if self._state_dir: if self._state_dir:
await asyncio.to_thread(self._state_dir.mkdir, parents=True, exist_ok=True) self._state_dir.mkdir(parents=True, exist_ok=True)
await self._ensure_client() await self._ensure_client()
self._running = True self._running = True
@ -353,15 +339,27 @@ class WechatChannel(Channel):
"base_info": self._base_info(), "base_info": self._base_info(),
} }
async def send_message() -> None: last_exc: Exception | None = None
data = await self._request_json("/ilink/bot/sendmessage", payload) for attempt in range(max_retries):
self._ensure_success(data, "sendmessage") try:
data = await self._request_json("/ilink/bot/sendmessage", payload)
self._ensure_success(data, "sendmessage")
return
except Exception as exc:
last_exc = exc
if attempt < max_retries - 1:
delay = 2**attempt
logger.warning(
"[WeChat] send failed (attempt %d/%d), retrying in %ds: %s",
attempt + 1,
max_retries,
delay,
exc,
)
await asyncio.sleep(delay)
await self._send_with_retry( logger.error("[WeChat] send failed after %d attempts: %s", max_retries, last_exc)
send_message, raise last_exc # type: ignore[misc]
max_retries=max_retries,
log_prefix="[WeChat]",
)
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
if attachment.is_image: if attachment.is_image:
@ -383,7 +381,7 @@ class WechatChannel(Channel):
return False return False
try: try:
plaintext = await asyncio.to_thread(attachment.actual_path.read_bytes) plaintext = attachment.actual_path.read_bytes()
except OSError: except OSError:
logger.exception("[WeChat] failed to read outbound image %s", attachment.actual_path) logger.exception("[WeChat] failed to read outbound image %s", attachment.actual_path)
return False return False
@ -473,7 +471,7 @@ class WechatChannel(Channel):
return False return False
try: try:
plaintext = await asyncio.to_thread(attachment.actual_path.read_bytes) plaintext = attachment.actual_path.read_bytes()
except OSError: except OSError:
logger.exception("[WeChat] failed to read outbound file %s", attachment.actual_path) logger.exception("[WeChat] failed to read outbound file %s", attachment.actual_path)
return False return False
@ -566,8 +564,8 @@ class WechatChannel(Channel):
if errcode == -14: if errcode == -14:
self._bot_token = "" self._bot_token = ""
self._get_updates_buf = "" self._get_updates_buf = ""
await asyncio.to_thread(self._save_state) self._save_state()
await asyncio.to_thread(self._save_auth_state, status="expired", bot_token="") self._save_auth_state(status="expired", bot_token="")
logger.error("[WeChat] bot token expired; scan again or update bot_token and restart the channel") logger.error("[WeChat] bot token expired; scan again or update bot_token and restart the channel")
self._running = False self._running = False
break break
@ -582,30 +580,13 @@ class WechatChannel(Channel):
self._update_longpoll_timeout(data) self._update_longpoll_timeout(data)
# Each message is isolated in its own try/except: one message that
# fails to process (e.g. an attachment that fails to decrypt) must
# not abort the whole batch and strand every message after it.
for raw_message in data.get("msgs", []):
try:
await self._handle_update(raw_message)
except asyncio.CancelledError:
raise
except Exception:
message_id = raw_message.get("message_id") or raw_message.get("msg_id") if isinstance(raw_message, dict) else None
logger.exception(
"[WeChat] failed to handle inbound message message_id=%s; skipping it and continuing with the rest of the batch",
message_id,
)
# The cursor is advanced only after the whole batch has been
# attempted (not before the loop above), so a hard crash mid-batch
# leaves it unmoved -- the worst case on restart is re-fetching and
# re-processing this batch, not silently skipping past messages
# that were never actually handled.
next_buf = data.get("get_updates_buf") next_buf = data.get("get_updates_buf")
if isinstance(next_buf, str) and next_buf != self._get_updates_buf: if isinstance(next_buf, str) and next_buf != self._get_updates_buf:
self._get_updates_buf = next_buf self._get_updates_buf = next_buf
await asyncio.to_thread(self._save_state) self._save_state()
for raw_message in data.get("msgs", []):
await self._handle_update(raw_message)
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception: except Exception:
@ -619,31 +600,15 @@ class WechatChannel(Channel):
return return
chat_id = str(raw_message.get("from_user_id") or raw_message.get("ilink_user_id") or "").strip() chat_id = str(raw_message.get("from_user_id") or raw_message.get("ilink_user_id") or "").strip()
if not chat_id: if not chat_id or not self._check_user(chat_id):
return return
text = self._extract_text(raw_message) text = self._extract_text(raw_message)
context_token = str(raw_message.get("context_token") or "").strip()
# Handle the connect code before applying allowed_users so a browser-initiated
# bind can bootstrap an external identity that is not yet whitelisted.
connect_code = self._pending_connect_code(text)
if connect_code:
handled = await self._bind_connection_from_connect_code(
chat_id=chat_id,
context_token=context_token,
code=connect_code,
)
if handled:
return
if not self._check_user(chat_id):
return
files = await self._extract_inbound_files(raw_message) files = await self._extract_inbound_files(raw_message)
if not text and not files: if not text and not files:
return return
context_token = str(raw_message.get("context_token") or "").strip()
thread_ts = context_token or str(raw_message.get("client_id") or raw_message.get("msg_id") or "").strip() or None thread_ts = context_token or str(raw_message.get("client_id") or raw_message.get("msg_id") or "").strip() or None
if context_token: if context_token:
@ -655,72 +620,25 @@ class WechatChannel(Channel):
chat_id=chat_id, chat_id=chat_id,
user_id=chat_id, user_id=chat_id,
text=text, text=text,
msg_type=InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT, msg_type=InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT,
thread_ts=thread_ts, thread_ts=thread_ts,
files=files, files=files,
metadata={ metadata={
"context_token": context_token, "context_token": context_token,
"ilink_user_id": chat_id, "ilink_user_id": chat_id,
"message_id": str(raw_message.get("message_id") or raw_message.get("msg_id") or "").strip(),
"ref_msg": self._extract_ref_message(raw_message), "ref_msg": self._extract_ref_message(raw_message),
"raw_message": raw_message, "raw_message": raw_message,
}, },
) )
inbound.topic_id = None inbound.topic_id = None
inbound = await self._attach_connection_identity(inbound)
await self.bus.publish_inbound(inbound) await self.bus.publish_inbound(inbound)
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="wechat",
workspace_id=inbound.chat_id,
)
async def _bind_connection_from_connect_code(self, *, chat_id: str, context_token: str, code: str) -> bool:
if self._connection_repo is None or not code:
return False
state = await self._connection_repo.consume_oauth_state(provider="wechat", state=code)
if state is None:
await self._send_connection_reply(chat_id, context_token, "WeChat connection code is invalid or expired.")
return True
if not chat_id:
await self._send_connection_reply(chat_id, context_token, "WeChat connection could not be completed from this message.")
return True
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="wechat",
external_account_id=chat_id,
workspace_id=chat_id,
metadata={
"context_token": context_token,
},
status="connected",
)
await self._send_connection_reply(chat_id, context_token, "WeChat connected to DeerFlow.")
return True
async def _send_connection_reply(self, chat_id: str, context_token: str, text: str) -> None:
if not context_token:
return
await self._send_text_message(
chat_id=chat_id,
context_token=context_token,
text=text,
client_id_prefix="deerflow-connect",
max_retries=1,
)
async def _ensure_authenticated(self) -> bool: async def _ensure_authenticated(self) -> bool:
async with self._auth_lock: async with self._auth_lock:
if self._bot_token: if self._bot_token:
return True return True
await asyncio.to_thread(self._load_auth_state) self._load_auth_state()
if self._bot_token: if self._bot_token:
return True return True
@ -748,8 +666,7 @@ class WechatChannel(Channel):
if qrcode_img_content: if qrcode_img_content:
logger.warning("[WeChat] qrcode_img_content=%s", qrcode_img_content) logger.warning("[WeChat] qrcode_img_content=%s", qrcode_img_content)
await asyncio.to_thread( self._save_auth_state(
self._save_auth_state,
status="pending", status="pending",
qrcode=qrcode, qrcode=qrcode,
qrcode_img_content=qrcode_img_content or None, qrcode_img_content=qrcode_img_content or None,
@ -771,8 +688,7 @@ class WechatChannel(Channel):
if ilink_bot_id: if ilink_bot_id:
self._ilink_bot_id = ilink_bot_id self._ilink_bot_id = ilink_bot_id
return await asyncio.to_thread( return self._save_auth_state(
self._save_auth_state,
status="confirmed", status="confirmed",
bot_token=token, bot_token=token,
ilink_bot_id=self._ilink_bot_id, ilink_bot_id=self._ilink_bot_id,
@ -781,8 +697,7 @@ class WechatChannel(Channel):
) )
if status in {"expired", "canceled", "cancelled", "invalid", "failed"}: if status in {"expired", "canceled", "cancelled", "invalid", "failed"}:
await asyncio.to_thread( self._save_auth_state(
self._save_auth_state,
status=status, status=status,
qrcode=qrcode, qrcode=qrcode,
qrcode_img_content=qrcode_img_content or None, qrcode_img_content=qrcode_img_content or None,
@ -791,8 +706,7 @@ class WechatChannel(Channel):
await asyncio.sleep(max(self._qrcode_poll_interval, 0.1)) await asyncio.sleep(max(self._qrcode_poll_interval, 0.1))
await asyncio.to_thread( self._save_auth_state(
self._save_auth_state,
status="timeout", status="timeout",
qrcode=qrcode, qrcode=qrcode,
qrcode_img_content=qrcode_img_content or None, qrcode_img_content=qrcode_img_content or None,
@ -1078,7 +992,7 @@ class WechatChannel(Channel):
detected_image = _detect_image_extension_and_mime(decrypted) detected_image = _detect_image_extension_and_mime(decrypted)
image_extension = detected_image[0] if detected_image else ".jpg" image_extension = detected_image[0] if detected_image else ".jpg"
filename = _safe_media_filename("wechat-image", image_extension, message_id=message_id, index=index) filename = _safe_media_filename("wechat-image", image_extension, message_id=message_id, index=index)
stored_path = await asyncio.to_thread(self._stage_downloaded_file, filename, decrypted) stored_path = self._stage_downloaded_file(filename, decrypted)
if stored_path is None: if stored_path is None:
return None return None
@ -1129,7 +1043,7 @@ class WechatChannel(Channel):
logger.warning("[WeChat] inbound file exceeds size limit (%d bytes), skipping message_id=%s", len(decrypted), message_id) logger.warning("[WeChat] inbound file exceeds size limit (%d bytes), skipping message_id=%s", len(decrypted), message_id)
return None return None
stored_path = await asyncio.to_thread(self._stage_downloaded_file, filename, decrypted) stored_path = self._stage_downloaded_file(filename, decrypted)
if stored_path is None: if stored_path is None:
return None return None
@ -1409,29 +1323,9 @@ class WechatChannel(Channel):
if self._auth_path: if self._auth_path:
try: try:
self._auth_path.parent.mkdir(parents=True, exist_ok=True) self._auth_path.parent.mkdir(parents=True, exist_ok=True)
# Write through a 0o600 temp file and atomically rename so the self._auth_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
# iLink bot_token is never briefly readable at umask defaults
# (mirrors ChannelRuntimeConfigStore._save). NamedTemporaryFile
# uses mkstemp, which creates the file at 0o600 from the start.
fd = tempfile.NamedTemporaryFile(mode="w", dir=self._auth_path.parent, suffix=".tmp", delete=False, encoding="utf-8")
try:
json.dump(data, fd, ensure_ascii=False, indent=2)
fd.close()
Path(fd.name).replace(self._auth_path)
except BaseException:
fd.close()
Path(fd.name).unlink(missing_ok=True)
raise
except OSError: except OSError:
logger.warning("[WeChat] failed to persist auth state to %s", self._auth_path) logger.warning("[WeChat] failed to persist auth state to %s", self._auth_path)
else:
# Hardening only; the destination already inherits 0o600 from the
# temp file. A chmod failure on filesystems without POSIX perms
# must not masquerade as a persist failure.
try:
self._auth_path.chmod(0o600)
except OSError:
logger.debug("[WeChat] unable to chmod auth state at %s", self._auth_path, exc_info=True)
return data return data
@staticmethod @staticmethod
@ -1457,10 +1351,9 @@ class WechatChannel(Channel):
@staticmethod @staticmethod
def _coerce_float(value: Any, default: float) -> float: def _coerce_float(value: Any, default: float) -> float:
try: try:
parsed = float(value) return float(value)
except (OverflowError, TypeError, ValueError): except (TypeError, ValueError):
return default return default
return parsed if math.isfinite(parsed) and parsed > 0 else default
@staticmethod @staticmethod
def _coerce_int(value: Any, default: int) -> int: def _coerce_int(value: Any, default: int) -> int:

View File

@ -8,10 +8,7 @@ from collections.abc import Awaitable, Callable
from typing import Any, cast from typing import Any, cast
from app.channels.base import Channel from app.channels.base import Channel
from app.channels.commands import is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import ( from app.channels.message_bus import (
InboundMessage,
InboundMessageType, InboundMessageType,
MessageBus, MessageBus,
OutboundMessage, OutboundMessage,
@ -32,10 +29,6 @@ class WeComChannel(Channel):
self._ws_stream_ids: dict[str, str] = {} self._ws_stream_ids: dict[str, str] = {}
self._working_message = "Working on it..." self._working_message = "Working on it..."
@property
def supports_streaming(self) -> bool:
return True
def _clear_ws_context(self, thread_ts: str | None) -> None: def _clear_ws_context(self, thread_ts: str | None) -> None:
if not thread_ts: if not thread_ts:
return return
@ -81,33 +74,12 @@ class WeComChannel(Channel):
self._ws_client.on("message.mixed", self._on_ws_mixed) self._ws_client.on("message.mixed", self._on_ws_mixed)
self._ws_client.on("message.image", self._on_ws_image) self._ws_client.on("message.image", self._on_ws_image)
self._ws_client.on("message.file", self._on_ws_file) self._ws_client.on("message.file", self._on_ws_file)
self._ws_client.on("error", self._on_ws_error)
self._ws_client.on("disconnected", self._on_ws_disconnected)
self._ws_task = asyncio.create_task(self._ws_client.connect()) self._ws_task = asyncio.create_task(self._ws_client.connect())
self._ws_task.add_done_callback(self._on_ws_task_done)
self._running = True self._running = True
self.bus.subscribe_outbound(self._on_outbound) self.bus.subscribe_outbound(self._on_outbound)
logger.info("WeCom channel started") logger.info("WeCom channel started")
def _on_ws_task_done(self, task: asyncio.Task) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is None:
return
logger.error(
"WeCom WebSocket connection task failed: %s. Check that the network/proxy allows wss://openws.work.weixin.qq.com and that bot_id/bot_secret are valid.",
exc,
)
def _on_ws_error(self, error: Any) -> None:
logger.error("WeCom WebSocket error: %s", error)
def _on_ws_disconnected(self, *args: Any) -> None:
detail = f" ({args[0]})" if args else ""
logger.warning("WeCom WebSocket disconnected%s; SDK will attempt to reconnect", detail)
async def stop(self) -> None: async def stop(self) -> None:
self._running = False self._running = False
self.bus.unsubscribe_outbound(self._on_outbound) self.bus.unsubscribe_outbound(self._on_outbound)
@ -199,7 +171,7 @@ class WeComChannel(Channel):
async def _on_ws_text(self, frame: dict[str, Any]) -> None: async def _on_ws_text(self, frame: dict[str, Any]) -> None:
body = frame.get("body", {}) or {} body = frame.get("body", {}) or {}
text = ((body.get("text") or {}).get("content") or "").strip() text = ((body.get("text") or {}).get("content") or "").strip()
quote = (((body.get("quote") or {}).get("text") or {}).get("content") or "").strip() quote = body.get("quote", {}).get("text", {}).get("content", "").strip()
if not text and not quote: if not text and not quote:
return return
await self._publish_ws_inbound(frame, text + (f"\nQuote message: {quote}" if quote else "")) await self._publish_ws_inbound(frame, text + (f"\nQuote message: {quote}" if quote else ""))
@ -294,17 +266,7 @@ class WeComChannel(Channel):
user_id = (body.get("from") or {}).get("userid") user_id = (body.get("from") or {}).get("userid")
connect_code = self._pending_connect_code(text) inbound_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
if connect_code:
handled = await self._bind_connection_from_connect_code(
frame=frame,
user_id=str(user_id or ""),
code=connect_code,
)
if handled:
return
inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
inbound = self._make_inbound( inbound = self._make_inbound(
chat_id=user_id, # keep user's conversation in memory chat_id=user_id, # keep user's conversation in memory
user_id=user_id, user_id=user_id,
@ -312,11 +274,7 @@ class WeComChannel(Channel):
msg_type=inbound_type, msg_type=inbound_type,
thread_ts=msg_id, thread_ts=msg_id,
files=files or [], files=files or [],
metadata={ metadata={"aibotid": body.get("aibotid"), "chattype": body.get("chattype")},
"aibotid": body.get("aibotid"),
"chattype": body.get("chattype"),
"message_id": msg_id,
},
) )
inbound.topic_id = user_id # keep the same thread inbound.topic_id = user_id # keep the same thread
@ -329,52 +287,8 @@ class WeComChannel(Channel):
except Exception: except Exception:
pass pass
inbound = await self._attach_connection_identity(inbound)
await self.bus.publish_inbound(inbound) await self.bus.publish_inbound(inbound)
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="wecom",
workspace_id=str(inbound.metadata.get("aibotid") or "") or None,
fallback_without_workspace=True,
)
async def _bind_connection_from_connect_code(self, *, frame: dict[str, Any], user_id: str, code: str) -> bool:
if self._connection_repo is None or not code:
return False
state = await self._connection_repo.consume_oauth_state(provider="wecom", state=code)
if state is None:
await self._send_connection_reply(frame, "WeCom connection code is invalid or expired.")
return True
if not user_id:
await self._send_connection_reply(frame, "WeCom connection could not be completed from this message.")
return True
body = frame.get("body", {}) or {}
workspace_id = str(body.get("aibotid") or "") or None
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="wecom",
external_account_id=user_id,
workspace_id=workspace_id,
metadata={
"aibotid": workspace_id,
"chattype": body.get("chattype"),
},
status="connected",
)
await self._send_connection_reply(frame, "WeCom connected to DeerFlow.")
return True
async def _send_connection_reply(self, frame: dict[str, Any], text: str) -> None:
if not self._ws_client:
return
await self._ws_client.reply(frame, {"msgtype": "text", "text": {"content": text}})
async def _send_ws(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None: async def _send_ws(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None:
if not self._ws_client: if not self._ws_client:
return return
@ -392,20 +306,30 @@ class WeComChannel(Channel):
if not stream_id: if not stream_id:
return return
await self._send_with_retry( last_exc: Exception | None = None
lambda: self._ws_client.reply_stream(frame, stream_id, msg.text, bool(msg.is_final)), for attempt in range(_max_retries):
max_retries=_max_retries, try:
log_prefix="[WeCom]", await self._ws_client.reply_stream(frame, stream_id, msg.text, bool(msg.is_final))
operation_name="stream send", return
) except Exception as exc:
return last_exc = exc
if attempt < _max_retries - 1:
await asyncio.sleep(2**attempt)
if last_exc:
raise last_exc
body = {"msgtype": "markdown", "markdown": {"content": msg.text}} body = {"msgtype": "markdown", "markdown": {"content": msg.text}}
await self._send_with_retry( last_exc = None
lambda: self._ws_client.send_message(msg.chat_id, body), for attempt in range(_max_retries):
max_retries=_max_retries, try:
log_prefix="[WeCom]", await self._ws_client.send_message(msg.chat_id, body)
) return
except Exception as exc:
last_exc = exc
if attempt < _max_retries - 1:
await asyncio.sleep(2**attempt)
if last_exc:
raise last_exc
async def _upload_media_ws( async def _upload_media_ws(
self, self,

View File

@ -1,12 +1,4 @@
from .app import app, create_app
from .config import GatewayConfig, get_gateway_config from .config import GatewayConfig, get_gateway_config
__all__ = ["app", "create_app", "GatewayConfig", "get_gateway_config"] __all__ = ["app", "create_app", "GatewayConfig", "get_gateway_config"]
def __getattr__(name: str):
"""Lazily expose the FastAPI app without initializing it on package import."""
if name in {"app", "create_app"}:
from .app import app, create_app
return app if name == "app" else create_app
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@ -1,208 +1,46 @@
import asyncio
import logging import logging
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.browser_capability import ensure_browser_runtime_available
from app.gateway.config import get_gateway_config from app.gateway.config import get_gateway_config
from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins
from app.gateway.deps import langgraph_runtime from app.gateway.deps import langgraph_runtime
from app.gateway.routers import ( from app.gateway.routers import (
agents, agents,
artifacts, artifacts,
assistants_compat, assistants_compat,
auth,
browser,
channel_connections,
channels, channels,
console,
features,
feedback,
github_webhooks,
input_polish,
integrations,
mcp, mcp,
memory, memory,
models, models,
runs, runs,
scheduled_tasks,
skills, skills,
suggestions, suggestions,
thread_runs, thread_runs,
threads, threads,
uploads, uploads,
) )
from app.gateway.trace_middleware import TraceMiddleware, resolve_trace_enabled from deerflow.config.app_config import get_app_config
from deerflow.config import app_config as deerflow_app_config
from deerflow.logging_config import DEFAULT_LOG_DATE_FORMAT, DEFAULT_LOG_FORMAT, configure_logging
from deerflow.tracing.monocle import setup_monocle_tracing_if_enabled
from deerflow.uploads.manager import cleanup_stale_upload_staging_files
AppConfig = deerflow_app_config.AppConfig # Configure logging
get_app_config = deerflow_app_config.get_app_config
# Default logging; lifespan overrides from config.yaml log_level.
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format=DEFAULT_LOG_FORMAT, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt=DEFAULT_LOG_DATE_FORMAT, datefmt="%Y-%m-%d %H:%M:%S",
) )
logger = logging.getLogger(__name__) 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
# The retrieval index is derived state, so shutdown only waits briefly for its
# startup rebuild. The canonical memory flush keeps its full configured budget.
_RETRIEVAL_WARM_SHUTDOWN_TIMEOUT_SECONDS = 1.0
async def _ensure_admin_user(app: FastAPI) -> None:
"""Startup hook: handle first boot and migrate orphan threads otherwise.
After admin creation, migrate orphan threads from the LangGraph
store (metadata.user_id unset) to the admin account. This is the
"no-auth → with-auth" upgrade path: users who ran DeerFlow without
authentication have existing LangGraph thread data that needs an
owner assigned.
First boot (no admin exists):
- Does NOT create any user accounts automatically.
- The operator must visit ``/setup`` to create the first admin.
Subsequent boots (admin already exists):
- Runs the one-time "no-auth → with-auth" orphan thread migration for
existing LangGraph thread metadata that has no user_id.
No SQL persistence migration is needed: the four user_id columns
(threads_meta, runs, run_events, feedback) only come into existence
alongside the auth module via create_all, so freshly created tables
never contain NULL-owner rows.
"""
from sqlalchemy import select
from app.gateway.deps import get_local_provider
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.user.model import UserRow
try:
provider = get_local_provider()
except RuntimeError:
# Auth persistence may not be initialized in some test/boot paths.
# Skip admin migration work rather than failing gateway startup.
logger.warning("Auth persistence not ready; skipping admin bootstrap check")
return
sf = get_session_factory()
if sf is None:
return
admin_count = await provider.count_admin_users()
if admin_count == 0:
logger.info("=" * 60)
logger.info(" First boot detected — no admin account exists.")
logger.info(" Visit /setup to complete admin account creation.")
logger.info("=" * 60)
return
# Admin already exists — run orphan thread migration for any
# LangGraph thread metadata that pre-dates the auth module.
async with sf() as session:
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None:
return # Should not happen (admin_count > 0 above), but be safe.
admin_id = str(row.id)
# LangGraph store orphan migration — non-fatal.
# This covers the "no-auth → with-auth" upgrade path for users
# whose existing LangGraph thread metadata has no user_id set.
store = getattr(app.state, "store", None)
if store is not None:
try:
migrated = await _migrate_orphaned_threads(store, admin_id)
if migrated:
logger.info("Migrated %d orphan LangGraph thread(s) to admin", migrated)
except Exception:
logger.exception("LangGraph thread migration failed (non-fatal)")
async def _iter_store_items(store, namespace, *, page_size: int = 500):
"""Paginated async iterator over a LangGraph store namespace.
Replaces the old hardcoded ``limit=1000`` call with a cursor-style
loop so that environments with more than one page of orphans do
not silently lose data. Terminates when a page is empty OR when a
short page arrives (indicating the last page).
"""
offset = 0
while True:
batch = await store.asearch(namespace, limit=page_size, offset=offset)
if not batch:
return
for item in batch:
yield item
if len(batch) < page_size:
return
offset += page_size
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
"""Migrate LangGraph store threads with no user_id to the given admin.
Uses cursor pagination so all orphans are migrated regardless of
count. Returns the number of rows migrated.
"""
migrated = 0
async for item in _iter_store_items(store, ("threads",)):
metadata = item.value.get("metadata", {})
if not metadata.get("user_id"):
metadata["user_id"] = admin_user_id
item.value["metadata"] = metadata
await store.aput(("threads",), item.key, item.value)
migrated += 1
return migrated
async def _warm_memory_retrieval(manager) -> None:
"""Rebuild the derived retrieval index without delaying Gateway readiness."""
try:
rebuilt = await asyncio.to_thread(manager.warm_retrieval)
if rebuilt:
logger.info("Memory retrieval index rebuilt successfully")
else:
logger.warning("Memory retrieval index rebuild failed; scoped searches will retry lazily")
except Exception:
logger.warning("Memory retrieval index rebuild skipped", exc_info=True)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler.""" """Application lifespan handler."""
# Load config and check necessary environment variables at startup. # Load config and check necessary environment variables at startup
# `startup_config` is a local snapshot used only for one-shot bootstrap
# work (logging level, langgraph_runtime engines, channels). Request-time
# config resolution always routes through `get_app_config()` in
# `app/gateway/deps.py::get_config()` so `config.yaml` edits become
# visible without a process restart. We deliberately do NOT cache this
# snapshot on `app.state` to keep that contract enforceable.
try: try:
startup_config = get_app_config() get_app_config()
configure_logging(startup_config)
ensure_browser_runtime_available(startup_config)
logger.info("Configuration loaded successfully") logger.info("Configuration loaded successfully")
warn_if_auth_disabled_enabled()
except Exception as e: except Exception as e:
error_msg = f"Failed to load configuration during gateway startup: {e}" error_msg = f"Failed to load configuration during gateway startup: {e}"
logger.exception(error_msg) logger.exception(error_msg)
@ -210,227 +48,29 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
config = get_gateway_config() config = get_gateway_config()
logger.info(f"Starting API Gateway on {config.host}:{config.port}") logger.info(f"Starting API Gateway on {config.host}:{config.port}")
# Agent observability (Monocle). Off by default; enabled with
# MONOCLE_TRACING. Initialized here at startup — not at import time — so a
# plain `import deerflow.agents` never installs a process-global tracer.
# Unlike LangSmith/Langfuse, whose validation failures abort the agent run,
# a bad Monocle config only logs: the Gateway keeps serving without tracing.
try:
setup_monocle_tracing_if_enabled()
except Exception: # observability must never break startup
logger.exception("Monocle tracing setup failed; continuing without it")
# Rebuild the derived memory retrieval index in the background. Scoped
# searches remain correct while this runs because DeerMem lazily rebuilds
# the requested scope when the full warm-up has not completed yet.
retrieval_warm_task: asyncio.Task[None] | None = None
try:
from deerflow.agents.memory import get_memory_manager
if startup_config.memory.enabled:
manager = get_memory_manager()
warm_retrieval = getattr(manager, "warm_retrieval", None)
if callable(warm_retrieval):
retrieval_warm_task = asyncio.create_task(
_warm_memory_retrieval(manager),
name="memory-retrieval-warm-up",
)
else:
logger.info("Memory is disabled; skipping retrieval index rebuild")
except Exception:
logger.warning("Memory retrieval index rebuild skipped", exc_info=True)
# Pre-warm tiktoken encoding cache so the first memory-injection request
# never blocks on the BPE data download (which hits an OpenAI/Azure URL
# that may be unreachable in restricted networks — see issue #3402).
# Warm-up runs via the manager's `warm()` tier-3 hook. DeerMem.warm re-checks
# token_counting=="char" and returns early, so char-mode backends never touch
# tiktoken (avoids even the 5s probe in network-restricted deployments - see
# issue #3429). A backend with nothing to warm (e.g. noop) returns None from
# the base default -- log "skipping" instead of the misleading "warmed
# successfully" so the log reflects what actually happened.
try:
from deerflow.agents.memory import get_memory_manager
manager = get_memory_manager()
warmed = await asyncio.wait_for(
asyncio.to_thread(manager.warm),
timeout=5,
)
if warmed is None:
logger.info("Memory backend %s has nothing to warm; skipping tiktoken warm-up", type(manager).__name__)
elif warmed:
logger.info("tiktoken encoding cache warmed successfully")
else:
logger.warning("tiktoken encoding cache warm-up failed; token counting will use character-based fallback until tiktoken loads successfully")
except TimeoutError:
logger.warning("tiktoken encoding cache warm-up timed out; token counting will use character-based fallback until tiktoken loads successfully")
except Exception:
logger.warning("tiktoken warm-up skipped", exc_info=True)
try:
removed_upload_staging_files = await asyncio.to_thread(cleanup_stale_upload_staging_files)
if removed_upload_staging_files:
logger.info("Removed %d stale upload staging file(s)", removed_upload_staging_files)
except Exception:
logger.warning("Upload staging file cleanup skipped", exc_info=True)
# Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store) # Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
async with langgraph_runtime(app, startup_config): async with langgraph_runtime(app):
logger.info("LangGraph runtime initialised") logger.info("LangGraph runtime initialised")
# Check admin bootstrap state and migrate orphan threads after admin exists.
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
await _ensure_admin_user(app)
# Start IM channel service if any channels are configured # Start IM channel service if any channels are configured
try: try:
from app.channels.service import start_channel_service from app.channels.service import start_channel_service
# Closure over `app` (mirrors ScheduledTaskService's `launch_run` channel_service = await start_channel_service()
# below) rather than resolving `app.state.stream_bridge` here
# directly: `stream_bridge` is a STARTUP_ONLY_FIELDS singleton set
# once, above, by `langgraph_runtime(app, startup_config)`, so
# either shape is safe by construction — the closure is just the
# more defensive/consistent-with-precedent form, and it is what
# ChannelManager's follow-up-drain watcher (issue #4121 Slice 2)
# uses to reach the same StreamBridge every other run consumer
# goes through `get_stream_bridge(request)` for.
channel_service = await start_channel_service(
startup_config,
get_stream_bridge=lambda: getattr(app.state, "stream_bridge", None),
)
logger.info("Channel service started: %s", channel_service.get_status()) logger.info("Channel service started: %s", channel_service.get_status())
except Exception: except Exception:
logger.exception("No IM channels configured or channel service failed to start") logger.exception("No IM channels configured or channel service failed to start")
try:
from app.gateway.services import launch_scheduled_thread_run
from app.scheduler import ScheduledTaskService
if getattr(app.state, "scheduled_task_repo", None) is not None and getattr(app.state, "scheduled_task_run_repo", None) is not None:
scheduled_task_service = ScheduledTaskService(
task_repo=app.state.scheduled_task_repo,
task_run_repo=app.state.scheduled_task_run_repo,
launch_run=lambda **kwargs: launch_scheduled_thread_run(app=app, **kwargs),
poll_interval_seconds=startup_config.scheduler.poll_interval_seconds,
lease_seconds=startup_config.scheduler.lease_seconds,
max_concurrent_runs=startup_config.scheduler.max_concurrent_runs,
)
app.state.scheduled_task_service = scheduled_task_service
if startup_config.scheduler.enabled:
await scheduled_task_service.start()
except Exception:
logger.exception("Failed to initialize scheduled task service")
yield yield
try: # Stop channel service on shutdown
await auth.close_oidc_service()
except Exception:
logger.exception("Failed to close OIDC service")
# Stop channel service on shutdown (bounded to prevent worker hang)
try: try:
from app.channels.service import stop_channel_service from app.channels.service import stop_channel_service
await asyncio.wait_for( await stop_channel_service()
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: except Exception:
logger.exception("Failed to stop channel service") logger.exception("Failed to stop channel service")
if getattr(app.state, "scheduled_task_service", None) is not None:
try:
await app.state.scheduled_task_service.stop()
except Exception:
logger.exception("Failed to stop scheduled task service")
try:
from deerflow.community.browser_automation import get_browser_session_manager
closed = await asyncio.wait_for(
get_browser_session_manager().close_all_sessions(),
timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
)
if closed:
logger.info("Closed %d browser session(s)", closed)
except TimeoutError:
logger.warning(
"Browser session shutdown exceeded %.1fs; proceeding with worker exit.",
_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
)
except Exception:
logger.exception("Failed to close browser sessions")
# Drain the memory backend's pending-update buffer before the worker
# exits (best-effort, bounded). IM channels and the scheduler are
# already stopped above, so no new IM/scheduler updates arrive during
# the drain; the LangGraph runtime / in-flight HTTP requests can still
# complete memory enqueues in a narrow window, but anything added after
# the drain copies the buffer only resets the debounce Timer
# (best-effort, same as today).
#
# No host-level pending/processing guard: ``shutdown_flush``
# short-circuits on a truly idle buffer (returns True immediately), so
# calling it unconditionally is cheap and keeps the in-flight-worker
# race entirely inside the backend (where the buffer lives) -- the host
# cannot "forget" that case the way a ``pending_count > 0``-only guard
# would (review #6 on the original PR).
#
# K8s caveat: ``shutdown_flush_timeout_seconds`` must fit inside the
# pod's ``terminationGracePeriodSeconds`` (channel stop + browser
# session close + the brief retrieval-warm wait + this drain + buffer),
# set on the gateway Helm deployment -- or K8s SIGKILLs the drain
# mid-flight and the loss this is fixing is silently re-introduced.
# The retrieval index is derived from canonical memory files, so its
# wait is independently capped and never consumes the flush budget.
retrieval_warm_finished = True
if retrieval_warm_task is not None and not retrieval_warm_task.done():
try:
await asyncio.wait_for(
asyncio.shield(retrieval_warm_task),
timeout=min(
_RETRIEVAL_WARM_SHUTDOWN_TIMEOUT_SECONDS,
startup_config.memory.shutdown_flush_timeout_seconds,
),
)
except TimeoutError:
retrieval_warm_finished = False
logger.warning("Memory retrieval index rebuild is still running; leaving its connection open during shutdown")
manager = None
try:
app_cfg = get_app_config()
if app_cfg.memory.enabled:
from deerflow.agents.memory import get_memory_manager
manager = get_memory_manager()
flush_timeout = app_cfg.memory.shutdown_flush_timeout_seconds
completed = await asyncio.to_thread(manager.shutdown_flush, flush_timeout)
if completed:
logger.info("Memory queue flush completed within %.1fs", flush_timeout)
else:
logger.warning(
"Memory queue flush did not finish within %.1fs; remaining updates may be lost",
flush_timeout,
)
except Exception:
logger.exception("Failed to flush memory queue on shutdown")
finally:
close = getattr(manager, "close", None)
if callable(close) and retrieval_warm_finished:
try:
await asyncio.to_thread(close)
except Exception:
logger.exception("Failed to close memory backend on shutdown")
logger.info("Shutting down API Gateway") logger.info("Shutting down API Gateway")
@ -440,10 +80,6 @@ def create_app() -> FastAPI:
Returns: Returns:
Configured FastAPI application instance. Configured FastAPI application instance.
""" """
config = get_gateway_config()
docs_url = "/docs" if config.enable_docs else None
redoc_url = "/redoc" if config.enable_docs else None
openapi_url = "/openapi.json" if config.enable_docs else None
app = FastAPI( app = FastAPI(
title="DeerFlow API Gateway", title="DeerFlow API Gateway",
@ -463,14 +99,14 @@ API Gateway for DeerFlow - A LangGraph-based AI agent backend with sandbox execu
### Architecture ### Architecture
LangGraph-compatible requests are routed through nginx to this gateway. LangGraph requests are handled by nginx reverse proxy.
This gateway provides runtime endpoints for agent runs plus custom endpoints for models, MCP configuration, skills, and artifacts. This gateway provides custom endpoints for models, MCP configuration, skills, and artifacts.
""", """,
version="0.1.0", version="0.1.0",
lifespan=lifespan, lifespan=lifespan,
docs_url=docs_url, docs_url="/docs",
redoc_url=redoc_url, redoc_url="/redoc",
openapi_url=openapi_url, openapi_url="/openapi.json",
openapi_tags=[ openapi_tags=[
{ {
"name": "models", "name": "models",
@ -508,10 +144,6 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
"name": "suggestions", "name": "suggestions",
"description": "Generate follow-up question suggestions for conversations", "description": "Generate follow-up question suggestions for conversations",
}, },
{
"name": "input-polish",
"description": "Polish composer draft input before sending",
},
{ {
"name": "channels", "name": "channels",
"description": "Manage IM channel integrations (Feishu, Slack, Telegram)", "description": "Manage IM channel integrations (Feishu, Slack, Telegram)",
@ -531,43 +163,12 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
], ],
) )
# Auth: reject unauthenticated requests to non-public paths (fail-closed safety net) # CORS is handled by nginx - no need for FastAPI middleware
app.add_middleware(AuthMiddleware)
# CSRF: Double Submit Cookie pattern for state-changing requests
app.add_middleware(CSRFMiddleware)
# CORS: the unified nginx endpoint is same-origin by default. Split-origin
# browser clients must opt in with this explicit Gateway allowlist so CORS
# and CSRF origin checks share the same source of truth.
cors_origins = sorted(get_configured_cors_origins())
if cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Request trace correlation: when logging.enhance.enabled=true, bind one
# trace id per Gateway HTTP request and write it to response start headers.
# `logging` is registered as restart-required (see reload_boundary.py) so we
# snapshot the flag from the startup AppConfig instead of reading live; a
# runtime toggle would otherwise leave the log formatter (installed once by
# configure_logging() at lifespan startup) out of sync with the middleware.
app.add_middleware(TraceMiddleware, enabled=_resolve_trace_enabled_for_app_construction())
# Include routers # Include routers
# Models API is mounted at /api/models # Models API is mounted at /api/models
app.include_router(models.router) app.include_router(models.router)
# Features API is mounted at /api/features
app.include_router(features.router)
# Console API (cross-thread observability) is mounted at /api/console
app.include_router(console.router)
# MCP API is mounted at /api/mcp # MCP API is mounted at /api/mcp
app.include_router(mcp.router) app.include_router(mcp.router)
@ -577,74 +178,35 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Skills API is mounted at /api/skills # Skills API is mounted at /api/skills
app.include_router(skills.router) app.include_router(skills.router)
# First-party integrations API is mounted at /api/integrations
app.include_router(integrations.router)
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts # Artifacts API is mounted at /api/threads/{thread_id}/artifacts
app.include_router(artifacts.router) app.include_router(artifacts.router)
# Browser API is mounted at /api/threads/{thread_id}/browser
app.include_router(browser.router)
# Uploads API is mounted at /api/threads/{thread_id}/uploads # Uploads API is mounted at /api/threads/{thread_id}/uploads
app.include_router(uploads.router) app.include_router(uploads.router)
# Thread cleanup API is mounted at /api/threads/{thread_id} # Thread cleanup API is mounted at /api/threads/{thread_id}
app.include_router(threads.router) app.include_router(threads.router)
# Scheduled tasks API is mounted at /api/scheduled-tasks
app.include_router(scheduled_tasks.router)
# Agents API is mounted at /api/agents # Agents API is mounted at /api/agents
app.include_router(agents.router) app.include_router(agents.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions # Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router) app.include_router(suggestions.router)
# Input polishing API is mounted at /api/input-polish
app.include_router(input_polish.router)
# User-facing IM channel connection API is mounted at /api/channels
app.include_router(channel_connections.router)
# Channels API is mounted at /api/channels # Channels API is mounted at /api/channels
app.include_router(channels.router) app.include_router(channels.router)
# Assistants compatibility API (LangGraph Platform stub) # Assistants compatibility API (LangGraph Platform stub)
app.include_router(assistants_compat.router) app.include_router(assistants_compat.router)
# Auth API is mounted at /api/v1/auth
app.include_router(auth.router)
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
app.include_router(feedback.router)
# Thread Runs API (LangGraph Platform-compatible runs lifecycle) # Thread Runs API (LangGraph Platform-compatible runs lifecycle)
app.include_router(thread_runs.router) app.include_router(thread_runs.router)
# Stateless Runs API (stream/wait without a pre-existing thread) # Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router) app.include_router(runs.router)
# GitHub webhooks API is mounted at /api/webhooks/github
# Exempt from auth and CSRF middleware (see auth_middleware._PUBLIC_PATH_PREFIXES
# and csrf_middleware.should_check_csrf); authenticity is enforced via the
# X-Hub-Signature-256 HMAC against GITHUB_WEBHOOK_SECRET.
# Including this router transitively imports app.gateway.github, which
# registers the GitHub channel's ChannelRunPolicy as an import side-effect.
#
# Fail-closed: only mount the route when a webhook secret is configured
# (or when the explicit DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1
# dev opt-in is set). A misconfigured deployment without a secret cannot
# serve forged deliveries because the URL responds 404 — there is no
# handler to reach.
if github_webhooks.is_route_enabled():
app.include_router(github_webhooks.router)
logger.info("GitHub webhooks route mounted at /api/webhooks/github")
else:
logger.warning("GitHub webhooks route NOT mounted: GITHUB_WEBHOOK_SECRET unset and DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS not set. /api/webhooks/github will respond 404. Configure either env var to enable the route.")
@app.get("/health", tags=["health"]) @app.get("/health", tags=["health"])
async def health_check() -> dict[str, str]: async def health_check() -> dict:
"""Health check endpoint. """Health check endpoint.
Returns: Returns:
@ -655,15 +217,5 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
return app return app
def _resolve_trace_enabled_for_app_construction() -> bool:
"""Resolve the trace middleware flag without making imports require config.yaml."""
try:
return resolve_trace_enabled(get_app_config())
except FileNotFoundError:
# Startup lifespan still performs strict config loading before serving.
logger.debug("config.yaml not found while constructing Gateway app; TraceMiddleware disabled for this app instance")
return False
# Create app instance for uvicorn # Create app instance for uvicorn
app = create_app() app = create_app()

View File

@ -1,42 +0,0 @@
"""Authentication module for DeerFlow.
This module provides:
- JWT-based authentication
- Provider Factory pattern for extensible auth methods
- UserRepository interface for storage backends (SQLite)
"""
from app.gateway.auth.config import AuthConfig, get_auth_config, set_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError
from app.gateway.auth.jwt import TokenPayload, create_access_token, decode_token
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.models import User, UserResponse
from app.gateway.auth.password import hash_password, verify_password
from app.gateway.auth.providers import AuthProvider
from app.gateway.auth.repositories.base import UserRepository
__all__ = [
# Config
"AuthConfig",
"get_auth_config",
"set_auth_config",
# Errors
"AuthErrorCode",
"AuthErrorResponse",
"TokenError",
# JWT
"TokenPayload",
"create_access_token",
"decode_token",
# Password
"hash_password",
"verify_password",
# Models
"User",
"UserResponse",
# Providers
"AuthProvider",
"LocalAuthProvider",
# Repository
"UserRepository",
]

View File

@ -1,85 +0,0 @@
"""Authentication configuration for DeerFlow."""
import logging
import os
import secrets
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
_SECRET_FILE = ".jwt_secret"
class AuthConfig(BaseModel):
"""JWT and auth-related configuration. Parsed once at startup.
Note: the ``users`` table now lives in the shared persistence
database managed by ``deerflow.persistence.engine``. The old
``users_db_path`` config key has been removed user storage is
configured through ``config.database`` like every other table.
"""
jwt_secret: str = Field(
...,
description="Secret key for JWT signing. MUST be set via AUTH_JWT_SECRET.",
)
token_expiry_days: int = Field(default=7, ge=1, le=30)
oauth_github_client_id: str | None = Field(default=None)
oauth_github_client_secret: str | None = Field(default=None)
_auth_config: AuthConfig | None = None
def _load_or_create_secret() -> str:
"""Load persisted JWT secret from ``{base_dir}/.jwt_secret``, or generate and persist a new one."""
from deerflow.config.paths import get_paths
paths = get_paths()
secret_file = paths.base_dir / _SECRET_FILE
try:
if secret_file.exists():
secret = secret_file.read_text(encoding="utf-8").strip()
if secret:
return secret
except OSError as exc:
raise RuntimeError(f"Failed to read JWT secret from {secret_file}. Set AUTH_JWT_SECRET explicitly or fix DEER_FLOW_HOME/base directory permissions so DeerFlow can read its persisted auth secret.") from exc
secret = secrets.token_urlsafe(32)
try:
secret_file.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(secret_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(secret)
except OSError as exc:
raise RuntimeError(f"Failed to persist JWT secret to {secret_file}. Set AUTH_JWT_SECRET explicitly or fix DEER_FLOW_HOME/base directory permissions so DeerFlow can store a stable auth secret.") from exc
return secret
def get_auth_config() -> AuthConfig:
"""Get the global AuthConfig instance. Parses from env on first call."""
global _auth_config
if _auth_config is None:
from dotenv import load_dotenv
load_dotenv()
jwt_secret = os.environ.get("AUTH_JWT_SECRET")
if not jwt_secret:
jwt_secret = _load_or_create_secret()
os.environ["AUTH_JWT_SECRET"] = jwt_secret
logger.warning(
"⚠ AUTH_JWT_SECRET is not set — using an auto-generated secret "
"persisted to .jwt_secret. Sessions will survive restarts. "
"For production, add AUTH_JWT_SECRET to your .env file: "
'python -c "import secrets; print(secrets.token_urlsafe(32))"'
)
_auth_config = AuthConfig(jwt_secret=jwt_secret)
return _auth_config
def set_auth_config(config: AuthConfig) -> None:
"""Set the global AuthConfig instance (for testing)."""
global _auth_config
_auth_config = config

View File

@ -1,48 +0,0 @@
"""Write initial admin credentials to a restricted file instead of logs.
Logging secrets to stdout/stderr is a well-known CodeQL finding
(py/clear-text-logging-sensitive-data) in production those logs
get collected into ELK/Splunk/etc and become a secret sprawl
source. This helper writes the credential to a 0600 file that only
the process user can read, and returns the path so the caller can
log **the path** (not the password) for the operator to pick up.
"""
from __future__ import annotations
import os
from pathlib import Path
from deerflow.config.paths import get_paths
_CREDENTIAL_FILENAME = "admin_initial_credentials.txt"
def write_initial_credentials(email: str, password: str, *, label: str = "initial") -> Path:
"""Write the admin email + password to ``{base_dir}/admin_initial_credentials.txt``.
The file is created **atomically** with mode 0600 via ``os.open``
so the password is never world-readable, even for the single syscall
window between ``write_text`` and ``chmod``.
``label`` distinguishes "initial" (fresh creation) from "reset"
(password reset) in the file header so an operator picking up the
file after a restart can tell which event produced it.
Returns the absolute :class:`Path` to the file.
"""
target = get_paths().base_dir / _CREDENTIAL_FILENAME
target.parent.mkdir(parents=True, exist_ok=True)
content = (
f"# DeerFlow admin {label} credentials\n# This file is generated on first boot or password reset.\n# Change the password after login via Settings -> Account,\n# then delete this file.\n#\nemail: {email}\npassword: {password}\n"
)
# Atomic 0600 create-or-truncate. O_TRUNC (not O_EXCL) so the
# reset-password path can rewrite an existing file without a
# separate unlink-then-create dance.
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
return target.resolve()

View File

@ -1,46 +0,0 @@
"""Typed error definitions for auth module.
AuthErrorCode: exhaustive enum of all auth failure conditions.
TokenError: exhaustive enum of JWT decode failures.
AuthErrorResponse: structured error payload for HTTP responses.
"""
from enum import StrEnum
from pydantic import BaseModel
class AuthErrorCode(StrEnum):
"""Exhaustive list of auth error conditions."""
INVALID_CREDENTIALS = "invalid_credentials"
TOKEN_EXPIRED = "token_expired"
TOKEN_INVALID = "token_invalid"
USER_NOT_FOUND = "user_not_found"
EMAIL_ALREADY_EXISTS = "email_already_exists"
PROVIDER_NOT_FOUND = "provider_not_found"
NOT_AUTHENTICATED = "not_authenticated"
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
REGISTRATION_DISABLED = "registration_disabled"
class TokenError(StrEnum):
"""Exhaustive list of JWT decode failure reasons."""
EXPIRED = "expired"
INVALID_SIGNATURE = "invalid_signature"
MALFORMED = "malformed"
class AuthErrorResponse(BaseModel):
"""Structured error response — replaces bare `detail` strings."""
code: AuthErrorCode
message: str
def token_error_to_code(err: TokenError) -> AuthErrorCode:
"""Map TokenError to AuthErrorCode — single source of truth."""
if err == TokenError.EXPIRED:
return AuthErrorCode.TOKEN_EXPIRED
return AuthErrorCode.TOKEN_INVALID

View File

@ -1,55 +0,0 @@
"""JWT token creation and verification."""
from datetime import UTC, datetime, timedelta
import jwt
from pydantic import BaseModel
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import TokenError
class TokenPayload(BaseModel):
"""JWT token payload."""
sub: str # user_id
exp: datetime
iat: datetime | None = None
ver: int = 0 # token_version — must match User.token_version
def create_access_token(user_id: str, expires_delta: timedelta | None = None, token_version: int = 0) -> str:
"""Create a JWT access token.
Args:
user_id: The user's UUID as string
expires_delta: Optional custom expiry, defaults to 7 days
token_version: User's current token_version for invalidation
Returns:
Encoded JWT string
"""
config = get_auth_config()
expiry = expires_delta or timedelta(days=config.token_expiry_days)
now = datetime.now(UTC)
payload = {"sub": user_id, "exp": now + expiry, "iat": now, "ver": token_version}
return jwt.encode(payload, config.jwt_secret, algorithm="HS256")
def decode_token(token: str) -> TokenPayload | TokenError:
"""Decode and validate a JWT token.
Returns:
TokenPayload if valid, or a specific TokenError variant.
"""
config = get_auth_config()
try:
payload = jwt.decode(token, config.jwt_secret, algorithms=["HS256"])
return TokenPayload(**payload)
except jwt.ExpiredSignatureError:
return TokenError.EXPIRED
except jwt.InvalidSignatureError:
return TokenError.INVALID_SIGNATURE
except jwt.PyJWTError:
return TokenError.MALFORMED

View File

@ -1,132 +0,0 @@
"""Local email/password authentication provider."""
import logging
from app.gateway.auth.models import User
from app.gateway.auth.password import hash_password_async, needs_rehash, verify_password_async
from app.gateway.auth.providers import AuthProvider
from app.gateway.auth.repositories.base import UserRepository
logger = logging.getLogger(__name__)
class LocalAuthProvider(AuthProvider):
"""Email/password authentication provider using local database."""
def __init__(self, repository: UserRepository):
"""Initialize with a UserRepository.
Args:
repository: UserRepository implementation (SQLite)
"""
self._repo = repository
async def authenticate(self, credentials: dict) -> User | None:
"""Authenticate with email and password.
Args:
credentials: dict with 'email' and 'password' keys
Returns:
User if authentication succeeds, None otherwise
"""
email = credentials.get("email")
password = credentials.get("password")
if not email or not password:
return None
user = await self._repo.get_user_by_email(email)
if user is None:
return None
if user.password_hash is None:
# OAuth user without local password
return None
if not await verify_password_async(password, user.password_hash):
return None
if needs_rehash(user.password_hash):
try:
user.password_hash = await hash_password_async(password)
await self._repo.update_user(user)
except Exception:
# Rehash is an opportunistic upgrade; a transient DB error must not
# prevent an otherwise-valid login from succeeding.
logger.warning("Failed to rehash password for user %s; login will still succeed", user.email, exc_info=True)
return user
async def get_user(self, user_id: str) -> User | None:
"""Get user by ID."""
return await self._repo.get_user_by_id(user_id)
async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User:
"""Create a new local user.
Args:
email: User email address
password: Plain text password (will be hashed)
system_role: Role to assign ("admin" or "user")
needs_setup: If True, user must complete setup on first login
Returns:
Created User instance
"""
password_hash = await hash_password_async(password) if password else None
user = User(
email=email,
password_hash=password_hash,
system_role=system_role,
needs_setup=needs_setup,
)
return await self._repo.create_user(user)
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
"""Get user by OAuth provider and ID."""
return await self._repo.get_user_by_oauth(provider, oauth_id)
async def count_users(self) -> int:
"""Return total number of registered users."""
return await self._repo.count_users()
async def count_admin_users(self) -> int:
"""Return number of admin users."""
return await self._repo.count_admin_users()
async def update_user(self, user: User) -> User:
"""Update an existing user."""
return await self._repo.update_user(user)
async def get_user_by_email(self, email: str) -> User | None:
"""Get user by email."""
return await self._repo.get_user_by_email(email)
async def create_oauth_user(
self,
email: str,
oauth_provider: str,
oauth_id: str,
system_role: str = "user",
) -> User:
"""Create a new user from an OAuth/OIDC login.
Args:
email: Verified email from the OIDC provider
oauth_provider: Provider ID (e.g. 'keycloak', 'google')
oauth_id: User's subject claim from the ID token
system_role: Role to assign ("admin" or "user")
Returns:
Created User instance
"""
user = User(
email=email,
password_hash=None,
system_role=system_role,
needs_setup=False,
oauth_provider=oauth_provider,
oauth_id=oauth_id,
)
return await self._repo.create_user(user)

View File

@ -1,42 +0,0 @@
"""User Pydantic models for authentication."""
from datetime import UTC, datetime
from typing import Literal
from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, EmailStr, Field
def _utc_now() -> datetime:
"""Return current UTC time (timezone-aware)."""
return datetime.now(UTC)
class User(BaseModel):
"""Internal user representation."""
model_config = ConfigDict(from_attributes=True)
id: UUID = Field(default_factory=uuid4, description="Primary key")
email: EmailStr = Field(..., description="Unique email address")
password_hash: str | None = Field(None, description="bcrypt hash, nullable for OAuth users")
system_role: Literal["admin", "user"] = Field(default="user")
created_at: datetime = Field(default_factory=_utc_now)
# OAuth linkage (optional)
oauth_provider: str | None = Field(None, description="e.g. 'github', 'google'")
oauth_id: str | None = Field(None, description="User ID from OAuth provider")
# Auth lifecycle
needs_setup: bool = Field(default=False, description="True when a reset account must complete setup")
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
class UserResponse(BaseModel):
"""Response model for user info endpoint."""
id: str
email: str
system_role: Literal["admin", "user"]
needs_setup: bool = False
oauth_provider: str | None = Field(None, description="OAuth/SSO provider ID if the user logged in via SSO (e.g. 'keycloak')")

View File

@ -1,433 +0,0 @@
"""OIDC (OpenID Connect) authentication service.
Provides provider-agnostic OIDC operations: discovery, authorization URL
generation, token exchange, ID token validation, and userinfo retrieval.
"""
from __future__ import annotations
import logging
import secrets
import time
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
import httpx
import jwt
from jwt import PyJWK
logger = logging.getLogger(__name__)
# ── Data types ────────────────────────────────────────────────────────────
OIDC_DISCOVERY_PATH = "/.well-known/openid-configuration"
METADATA_CACHE_TTL = 300 # 5 minutes
JWKS_CACHE_TTL = 300
@dataclass(frozen=True)
class OIDCMetadata:
"""Resolved OIDC provider metadata after discovery."""
issuer: str
authorization_endpoint: str
token_endpoint: str
userinfo_endpoint: str | None
jwks_uri: str
@dataclass(frozen=True)
class OIDCIdentity:
"""Normalized identity extracted from an OIDC provider response."""
provider: str
subject: str
email: str
email_verified: bool
name: str | None
claims: dict[str, Any]
class OIDCError(Exception):
"""Base error for OIDC operations. Message is safe for API responses."""
class OIDCProviderError(OIDCError):
"""The OIDC provider returned an error (e.g. access_denied)."""
class OIDCValidationError(OIDCError):
"""ID token validation failed."""
class OIDCUserInfoMismatch(OIDCError):
"""UserInfo sub does not match ID token sub."""
# ── Service ────────────────────────────────────────────────────────────────
class OIDCService:
"""OIDC authentication service.
Uses in-process caching for provider metadata and JWKS. The cache is
keyed by the provider's ``issuer`` — different providers get separate
entries. TTLs are configurable via constructor arguments.
"""
def __init__(
self,
metadata_cache_ttl: float = METADATA_CACHE_TTL,
jwks_cache_ttl: float = JWKS_CACHE_TTL,
) -> None:
self._metadata_cache: dict[str, tuple[float, dict[str, Any]]] = {}
self._jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {}
self._metadata_ttl = metadata_cache_ttl
self._jwks_ttl = jwks_cache_ttl
self._http = httpx.AsyncClient(timeout=httpx.Timeout(15.0))
async def close(self) -> None:
"""Close the underlying HTTP client."""
await self._http.aclose()
# ── Discovery ──────────────────────────────────────────────────────────
async def discover(self, issuer: str, overrides: dict[str, str | None] | None = None) -> OIDCMetadata:
"""Fetch and cache OIDC discovery metadata from the issuer.
``overrides`` may contain endpoint URIs to override discovery values
(e.g. for providers with non-standard endpoints).
"""
now = time.time()
cached = self._metadata_cache.get(issuer)
if cached and now - cached[0] < self._metadata_ttl:
return self._metadata_from_dict(cached[1], overrides)
discovery_url = issuer.rstrip("/") + OIDC_DISCOVERY_PATH
try:
resp = await self._http.get(discovery_url)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
except httpx.HTTPStatusError as exc:
raise OIDCError(f"OIDC discovery failed for issuer {issuer}: HTTP {exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"OIDC discovery failed for issuer {issuer}: {exc}") from exc
discovered_issuer = data.get("issuer")
if not discovered_issuer:
raise OIDCError(f"OIDC discovery response from {issuer} is missing the issuer field")
# RFC 8414 §4: the metadata issuer must equal the configured issuer.
# Pinning it prevents a tampered/rogue discovery document from steering
# the accepted `iss` (and thus the ID-token forgery surface) to an
# attacker-chosen value.
if discovered_issuer.rstrip("/") != issuer.rstrip("/"):
raise OIDCError(f"OIDC discovered issuer '{discovered_issuer}' does not match configured issuer '{issuer}'")
self._metadata_cache[issuer] = (now, data)
return self._metadata_from_dict(data, overrides)
def _metadata_from_dict(self, data: dict[str, Any], overrides: dict[str, str | None] | None) -> OIDCMetadata:
"""Build OIDCMetadata from a discovery dict, applying endpoint overrides."""
overrides = overrides or {}
return OIDCMetadata(
issuer=data["issuer"],
authorization_endpoint=overrides.get("authorization_endpoint") or data["authorization_endpoint"],
token_endpoint=overrides.get("token_endpoint") or data["token_endpoint"],
userinfo_endpoint=overrides.get("userinfo_endpoint") or data.get("userinfo_endpoint"),
jwks_uri=overrides.get("jwks_uri") or data["jwks_uri"],
)
# ── Authorization URL ──────────────────────────────────────────────────
def build_authorization_url(
self,
metadata: OIDCMetadata,
client_id: str,
redirect_uri: str,
scopes: list[str],
state: str,
nonce: str | None = None,
code_challenge: str | None = None,
) -> str:
"""Build the OIDC authorization URL for the provider.
Returns a URL the browser should be redirected to.
"""
params: dict[str, str] = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": " ".join(scopes),
"state": state,
}
if nonce:
params["nonce"] = nonce
if code_challenge:
params["code_challenge"] = code_challenge
params["code_challenge_method"] = "S256"
return f"{metadata.authorization_endpoint}?{urlencode(params)}"
# ── Token exchange ─────────────────────────────────────────────────────
async def exchange_code(
self,
metadata: OIDCMetadata,
client_id: str,
client_secret: str | None,
code: str,
redirect_uri: str,
code_verifier: str | None = None,
auth_method: str = "client_secret_post",
) -> dict[str, Any]:
"""Exchange the authorization code for tokens at the token endpoint."""
data: dict[str, str] = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
}
if code_verifier:
data["code_verifier"] = code_verifier
headers: dict[str, str] = {"Accept": "application/json"}
if auth_method == "client_secret_basic" and client_secret:
import base64
creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode("ascii")
headers["Authorization"] = f"Basic {creds}"
elif auth_method == "client_secret_post" and client_secret:
data["client_secret"] = client_secret
try:
resp = await self._http.post(metadata.token_endpoint, data=data, headers=headers)
resp.raise_for_status()
return resp.json()
except httpx.HTTPStatusError as exc:
body = "unknown"
try:
body = exc.response.text[:200]
except Exception:
pass
raise OIDCError(f"Token exchange failed: HTTP {exc.response.status_code}{body}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"Token exchange failed: {exc}") from exc
# ── JWKS loading ───────────────────────────────────────────────────────
async def _load_jwks(self, jwks_uri: str, force_refresh: bool = False) -> dict[str, Any]:
"""Load (and cache) JWKS from the provider.
Set ``force_refresh=True`` to bypass the cache (e.g. on a kid miss).
"""
now = time.time()
cached = self._jwks_cache.get(jwks_uri)
if not force_refresh and cached and now - cached[0] < self._jwks_ttl:
return cached[1]
try:
resp = await self._http.get(jwks_uri)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
except httpx.HTTPStatusError as exc:
raise OIDCError(f"JWKS fetch failed: HTTP {exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"JWKS fetch failed: {exc}") from exc
self._jwks_cache[jwks_uri] = (now, data)
return data
async def _resolve_signing_key(
self,
jwks_data: dict[str, Any],
kid: str | None,
algorithm: str,
jwks_uri: str,
) -> Any | None:
"""Find the signing key matching ``kid`` in the JWKS.
Returns the key object or ``None`` if no match is found. Catches
invalid JWK entries (e.g. wrong key type for the algorithm) and
logs a warning so a single bad entry does not crash validation.
"""
for jwk_dict in jwks_data.get("keys", []):
if kid and jwk_dict.get("kid") != kid:
continue
try:
jwk = PyJWK(jwk_dict, algorithm=algorithm)
return jwk.key
except jwt.PyJWTError as exc:
logger.warning("Skipping invalid JWK (kid=%s) from %s: %s", kid, jwks_uri, exc)
if not kid:
# No kid in token — try next key
continue
# kid was specified and this key is the one — fail fast
raise OIDCValidationError(f"JWK for kid={kid} is invalid: {exc}") from exc
return None
# ── ID token validation ────────────────────────────────────────────────
async def validate_id_token(
self,
metadata: OIDCMetadata,
client_id: str,
id_token: str,
nonce: str | None = None,
) -> dict[str, Any]:
"""Validate the ID token and return its claims.
Validates: signature (via JWKS), issuer, audience, expiration,
issued-at, and nonce (if provided).
"""
jwks_data = await self._load_jwks(metadata.jwks_uri)
# Resolve the signing key from the JWKS using the token's kid header
jwt_header = jwt.get_unverified_header(id_token)
kid = jwt_header.get("kid")
alg = jwt_header.get("alg", "RS256")
allowed_algorithms = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]
if alg not in allowed_algorithms:
raise OIDCValidationError(f"ID token uses unsupported algorithm '{alg}'")
# Resolve signing key, refetching JWKS once on kid miss for key rotation
signing_key = await self._resolve_signing_key(jwks_data, kid, alg, metadata.jwks_uri)
if signing_key is None:
jwks_data = await self._load_jwks(metadata.jwks_uri, force_refresh=True)
signing_key = await self._resolve_signing_key(jwks_data, kid, alg, metadata.jwks_uri)
if signing_key is None:
raise OIDCValidationError(f"No matching JWK found for kid={kid} after JWKS refresh")
try:
claims = jwt.decode(
id_token,
key=signing_key,
algorithms=allowed_algorithms,
audience=client_id,
issuer=metadata.issuer,
options={
"verify_exp": True,
"verify_iat": True,
"require": ["exp", "iss", "sub", "aud"],
},
)
except jwt.ExpiredSignatureError:
raise OIDCValidationError("ID token has expired")
except jwt.InvalidIssuerError:
raise OIDCValidationError("ID token has an invalid issuer")
except jwt.InvalidAudienceError:
raise OIDCValidationError("ID token has an invalid audience")
except jwt.PyJWTError as exc:
raise OIDCValidationError(f"ID token validation failed: {exc}") from exc
# Validate nonce if expected
if nonce is not None:
token_nonce = claims.get("nonce")
if not token_nonce:
raise OIDCValidationError("ID token is missing the nonce claim")
if not _constant_time_compare(nonce, token_nonce):
raise OIDCValidationError("ID token nonce does not match")
return claims
# ── UserInfo ────────────────────────────────────────────────────────────
async def fetch_userinfo(self, metadata: OIDCMetadata, access_token: str, expected_sub: str) -> dict[str, Any]:
"""Fetch userinfo from the UserInfo endpoint.
Validates that the ``sub`` claim matches ``expected_sub``
(from the ID token) to prevent userinfo injection.
"""
if not metadata.userinfo_endpoint:
return {}
headers = {"Authorization": f"Bearer {access_token}"}
try:
resp = await self._http.get(metadata.userinfo_endpoint, headers=headers)
resp.raise_for_status()
userinfo: dict[str, Any] = resp.json()
except httpx.HTTPStatusError as exc:
raise OIDCError(f"UserInfo fetch failed: HTTP {exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"UserInfo fetch failed: {exc}") from exc
if userinfo.get("sub") and userinfo["sub"] != expected_sub:
raise OIDCUserInfoMismatch("UserInfo sub does not match ID token sub")
return userinfo
# ── Orchestrated callback ──────────────────────────────────────────────
async def authenticate_callback(
self,
provider_id: str,
metadata: OIDCMetadata,
client_id: str,
client_secret: str | None,
code: str,
redirect_uri: str,
code_verifier: str | None = None,
nonce: str | None = None,
auth_method: str = "client_secret_post",
) -> OIDCIdentity:
"""Orchestrate the full OIDC callback: token exchange, ID token validation, userinfo.
Returns a normalized ``OIDCIdentity``.
"""
token_response = await self.exchange_code(
metadata=metadata,
client_id=client_id,
client_secret=client_secret,
code=code,
redirect_uri=redirect_uri,
code_verifier=code_verifier,
auth_method=auth_method,
)
id_token = token_response.get("id_token")
if not id_token:
raise OIDCError("Token response is missing id_token")
access_token = token_response.get("access_token", "")
claims = await self.validate_id_token(
metadata=metadata,
client_id=client_id,
id_token=id_token,
nonce=nonce,
)
# Fetch userinfo for email/name if not present in ID token
userinfo: dict[str, Any] = {}
if metadata.userinfo_endpoint and access_token:
try:
userinfo = await self.fetch_userinfo(
metadata=metadata,
access_token=access_token,
expected_sub=claims["sub"],
)
except OIDCError as exc:
logger.warning("OIDC userinfo fetch failed (continuing with ID token): %s", exc)
# Merge userinfo into claims (userinfo takes precedence for email)
merged = {**claims, **userinfo}
email = merged.get("email") or ""
email_verified = merged.get("email_verified") is True
return OIDCIdentity(
provider=provider_id,
subject=claims["sub"],
email=email,
email_verified=email_verified,
name=merged.get("name"),
claims=merged,
)
def _constant_time_compare(a: str, b: str) -> bool:
"""Constant-time string comparison."""
return secrets.compare_digest(a, b)

View File

@ -1,122 +0,0 @@
"""OIDC state management via signed HttpOnly cookies.
Stores OIDC state, nonce, and PKCE verifier in a short-lived signed cookie
instead of server-side storage. This keeps the implementation stateless and
compatible with multi-worker deployments without Redis.
"""
from __future__ import annotations
import secrets
import time
import jwt
from fastapi import Request, Response
from pydantic import BaseModel, Field
from app.gateway.auth.config import get_auth_config
from app.gateway.csrf_middleware import is_secure_request
OIDC_STATE_COOKIE_PREFIX = "df_oidc_state_"
OIDC_STATE_MAX_AGE = 300 # 5 minutes
OIDC_STATE_BYTES = 32
OIDC_NONCE_BYTES = 16
OIDC_CODE_VERIFIER_BYTES = 32
class OIDCStatePayload(BaseModel):
"""Payload stored inside the signed OIDC state cookie."""
provider: str = Field(description="OIDC provider ID (must match the state cookie)") # noqa: E501
state: str = Field(description="Cryptographically random state value — compared in constant time with the query param") # noqa: E501
nonce: str | None = Field(default=None, description="OIDC nonce, verified against the ID token nonce claim")
code_verifier: str | None = Field(default=None, description="PKCE code verifier, sent during token exchange")
next_path: str = Field(default="/workspace", description="Redirect target after successful auth")
remember_me: bool = Field(default=True, description="Whether the resulting DeerFlow session should be persistent")
issued_at: float = Field(default_factory=time.time, description="Unix timestamp of cookie creation")
def _sign_state_payload(payload: OIDCStatePayload) -> str:
"""Sign the state payload with the JWT secret to prevent tampering."""
secret = get_auth_config().jwt_secret
return jwt.encode(payload.model_dump(), secret, algorithm="HS256")
def _verify_state_signed(signed: str, max_age: int = OIDC_STATE_MAX_AGE) -> OIDCStatePayload | None:
"""Verify a signed state payload and return it, or None if invalid/expired."""
secret = get_auth_config().jwt_secret
try:
decoded = jwt.decode(signed, secret, algorithms=["HS256"])
payload = OIDCStatePayload(**decoded)
if time.time() - payload.issued_at > max_age:
return None
return payload
except jwt.PyJWTError:
return None
def generate_oidc_state() -> str:
"""Generate a cryptographically random state string."""
return secrets.token_urlsafe(OIDC_STATE_BYTES)
def generate_nonce() -> str:
"""Generate a cryptographically random nonce for ID token validation."""
return secrets.token_urlsafe(OIDC_NONCE_BYTES)
def generate_code_verifier() -> str:
"""Generate a PKCE code verifier (plain random string)."""
return secrets.token_urlsafe(OIDC_CODE_VERIFIER_BYTES)
def compute_code_challenge(verifier: str) -> str:
"""Compute the S256 PKCE code challenge from a verifier."""
import hashlib
return _base64url_encode(hashlib.sha256(verifier.encode("ascii")).digest())
def _base64url_encode(data: bytes) -> str:
"""Base64url-encode without padding, as required by RFC 7636 and OIDC."""
import base64
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _cookie_name(provider: str) -> str:
return f"{OIDC_STATE_COOKIE_PREFIX}{provider}"
def set_state_cookie(response: Response, request: Request, payload: OIDCStatePayload) -> None:
"""Set the signed OIDC state cookie on the response."""
signed = _sign_state_payload(payload)
is_https = is_secure_request(request)
response.set_cookie(
key=_cookie_name(payload.provider),
value=signed,
httponly=True,
secure=is_https,
samesite="lax",
max_age=OIDC_STATE_MAX_AGE,
path=f"/api/v1/auth/callback/{payload.provider}",
)
def get_state_cookie(request: Request, provider: str) -> OIDCStatePayload | None:
"""Read and verify the signed OIDC state cookie for the given provider."""
signed = request.cookies.get(_cookie_name(provider))
if not signed:
return None
return _verify_state_signed(signed)
def delete_state_cookie(response: Response, request: Request, provider: str) -> None:
"""Delete the OIDC state cookie."""
is_https = is_secure_request(request)
response.delete_cookie(
key=_cookie_name(provider),
secure=is_https,
samesite="lax",
path=f"/api/v1/auth/callback/{provider}",
)

View File

@ -1,81 +0,0 @@
"""Password hashing utilities with versioned hash format.
Hash format: ``$dfv<N>$<bcrypt_hash>`` where ``<N>`` is the version.
- **v1** (legacy): ``bcrypt(password)`` plain bcrypt, susceptible to
72-byte silent truncation.
- **v2** (current): ``bcrypt(b64(sha256(password)))`` SHA-256 pre-hash
avoids the 72-byte truncation limit so the full password contributes
to the hash.
Verification auto-detects the version and falls back to v1 for hashes
without a prefix, so existing deployments upgrade transparently on next
login.
"""
import asyncio
import base64
import hashlib
import bcrypt
_CURRENT_VERSION = 2
_PREFIX_V2 = "$dfv2$"
_PREFIX_V1 = "$dfv1$"
def _pre_hash_v2(password: str) -> bytes:
"""SHA-256 pre-hash to bypass bcrypt's 72-byte limit."""
return base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())
def hash_password(password: str) -> str:
"""Hash a password (current version: v2 — SHA-256 + bcrypt)."""
raw = bcrypt.hashpw(_pre_hash_v2(password), bcrypt.gensalt()).decode("utf-8")
return f"{_PREFIX_V2}{raw}"
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password, auto-detecting the hash version.
Accepts v2 (``$dfv2$``), v1 (``$dfv1$``), and bare bcrypt hashes
(treated as v1 for backward compatibility with pre-versioning data).
"""
try:
if hashed_password.startswith(_PREFIX_V2):
bcrypt_hash = hashed_password[len(_PREFIX_V2) :]
return bcrypt.checkpw(_pre_hash_v2(plain_password), bcrypt_hash.encode("utf-8"))
if hashed_password.startswith(_PREFIX_V1):
bcrypt_hash = hashed_password[len(_PREFIX_V1) :]
else:
bcrypt_hash = hashed_password
return bcrypt.checkpw(plain_password.encode("utf-8"), bcrypt_hash.encode("utf-8"))
except ValueError:
# bcrypt raises ValueError for malformed or corrupt hashes (e.g., invalid salt).
# Fail closed rather than crashing the request.
return False
def needs_rehash(hashed_password: str) -> bool:
"""Return True if the hash uses an older version and should be rehashed."""
return not hashed_password.startswith(_PREFIX_V2)
async def hash_password_async(password: str) -> str:
"""Hash a password using bcrypt (non-blocking).
Wraps the blocking bcrypt operation in a thread pool to avoid
blocking the event loop during password hashing.
"""
return await asyncio.to_thread(hash_password, password)
async def verify_password_async(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash (non-blocking).
Wraps the blocking bcrypt operation in a thread pool to avoid
blocking the event loop during password verification.
"""
return await asyncio.to_thread(verify_password, plain_password, hashed_password)

View File

@ -1,24 +0,0 @@
"""Auth provider abstraction."""
from abc import ABC, abstractmethod
class AuthProvider(ABC):
"""Abstract base class for authentication providers."""
@abstractmethod
async def authenticate(self, credentials: dict) -> "User | None":
"""Authenticate user with given credentials.
Returns User if authentication succeeds, None otherwise.
"""
raise NotImplementedError
@abstractmethod
async def get_user(self, user_id: str) -> "User | None":
"""Retrieve user by ID."""
raise NotImplementedError
# Import User at runtime to avoid circular imports
from app.gateway.auth.models import User # noqa: E402

View File

@ -1,107 +0,0 @@
"""User repository interface for abstracting database operations."""
from abc import ABC, abstractmethod
from app.gateway.auth.models import User
class UserNotFoundError(LookupError):
"""Raised when a user repository operation targets a non-existent row.
Subclass of :class:`LookupError` so callers that already catch
``LookupError`` for "missing entity" can keep working unchanged,
while specific call sites can pin to this class to distinguish
"concurrent delete during update" from other lookups.
"""
class UserRepository(ABC):
"""Abstract interface for user data storage.
Implement this interface to support different storage backends
(SQLite)
"""
@abstractmethod
async def create_user(self, user: User) -> User:
"""Create a new user.
Args:
user: User object to create
Returns:
Created User with ID assigned. ``email`` is the canonical
(lowercase) stored form, which may differ in case from what was
passed in -- implementations mutate the input ``user`` in place
to reflect this rather than returning a fresh object.
Raises:
ValueError: If email already exists
"""
raise NotImplementedError
@abstractmethod
async def get_user_by_id(self, user_id: str) -> User | None:
"""Get user by ID.
Args:
user_id: User UUID as string
Returns:
User if found, None otherwise
"""
raise NotImplementedError
@abstractmethod
async def get_user_by_email(self, email: str) -> User | None:
"""Get user by email.
Args:
email: User email address
Returns:
User if found, None otherwise
"""
raise NotImplementedError
@abstractmethod
async def update_user(self, user: User) -> User:
"""Update an existing user.
Args:
user: User object with updated fields
Returns:
Updated User. ``email`` is the canonical (lowercase) stored
form -- implementations mutate the input ``user`` in place to
reflect this rather than returning a fresh object.
Raises:
UserNotFoundError: If no row exists for ``user.id``. This is
a hard failure (not a no-op) so callers cannot mistake a
concurrent-delete race for a successful update.
"""
raise NotImplementedError
@abstractmethod
async def count_users(self) -> int:
"""Return total number of registered users."""
raise NotImplementedError
@abstractmethod
async def count_admin_users(self) -> int:
"""Return number of users with system_role == 'admin'."""
raise NotImplementedError
@abstractmethod
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
"""Get user by OAuth provider and ID.
Args:
provider: OAuth provider name (e.g. 'github', 'google')
oauth_id: User ID from the OAuth provider
Returns:
User if found, None otherwise
"""
raise NotImplementedError

View File

@ -1,181 +0,0 @@
"""SQLAlchemy-backed UserRepository implementation.
Uses the shared async session factory from
``deerflow.persistence.engine`` the ``users`` table lives in the
same database as ``threads_meta``, ``runs``, ``run_events``, and
``feedback``.
Constructor takes the session factory directly (same pattern as the
other four repositories in ``deerflow.persistence.*``). Callers
construct this after ``init_engine_from_config()`` has run.
"""
from __future__ import annotations
from datetime import UTC
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.gateway.auth.models import User
from app.gateway.auth.repositories.base import UserNotFoundError, UserRepository
from deerflow.persistence.user.model import UserRow
def _normalize_email(email: str) -> str:
"""Canonicalise an email address for storage and lookup.
An email identifies exactly one account regardless of the case the client
sends. The two write paths would otherwise disagree: local registration
normalises through ``EmailStr``, which lowercases only the *domain* and
keeps the local-part case (``Victim@X.COM`` -> ``Victim@x.com``), while OIDC
provisioning lowercases the whole address (``-> victim@x.com``). Combined
with the previous case-sensitive lookup, ``Victim@x.com`` and
``victim@x.com`` resolved to two separate rows, defeating the invariant
that a local account blocks an SSO login on the same email.
Canonicalising to lowercase at every write site and matching
case-insensitively on read closes that gap for new accounts while letting
existing mixed-case rows keep resolving, without a destructive bulk rewrite.
"""
return email.lower()
class SQLiteUserRepository(UserRepository):
"""Async user repository backed by the shared SQLAlchemy engine."""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
# ── Converters ────────────────────────────────────────────────────
@staticmethod
def _row_to_user(row: UserRow) -> User:
return User(
id=UUID(row.id),
email=row.email,
password_hash=row.password_hash,
system_role=row.system_role, # type: ignore[arg-type]
# SQLite loses tzinfo on read; reattach UTC so downstream
# code can compare timestamps reliably.
created_at=row.created_at if row.created_at.tzinfo else row.created_at.replace(tzinfo=UTC),
oauth_provider=row.oauth_provider,
oauth_id=row.oauth_id,
needs_setup=row.needs_setup,
token_version=row.token_version,
)
@staticmethod
def _user_to_row(user: User) -> UserRow:
return UserRow(
id=str(user.id),
email=user.email,
password_hash=user.password_hash,
system_role=user.system_role,
created_at=user.created_at,
oauth_provider=user.oauth_provider,
oauth_id=user.oauth_id,
needs_setup=user.needs_setup,
token_version=user.token_version,
)
# ── CRUD ──────────────────────────────────────────────────────────
async def create_user(self, user: User) -> User:
"""Insert a new user. Raises ``ValueError`` on duplicate email.
The email is canonicalised to lowercase before insert so the existing
unique constraint enforces case-insensitive uniqueness for new rows and
the returned ``User`` reflects the stored form.
"""
user.email = _normalize_email(user.email)
row = self._user_to_row(user)
async with self._sf() as session:
# The unique constraint is case-sensitive, so it cannot catch a
# canonical address colliding with a mixed-case legacy row.
existing = select(UserRow.id).where(func.lower(UserRow.email) == user.email).limit(1)
if await session.scalar(existing) is not None:
raise ValueError(f"Email already registered: {user.email}")
session.add(row)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ValueError(f"Email already registered: {user.email}") from exc
return user
async def get_user_by_id(self, user_id: str) -> User | None:
async with self._sf() as session:
row = await session.get(UserRow, user_id)
return self._row_to_user(row) if row is not None else None
async def get_user_by_email(self, email: str) -> User | None:
# Case-insensitive match: an account is keyed by its email regardless of
# the case the caller supplies (see ``_normalize_email``). ``.first()``
# with a deterministic ``created_at`` ordering resolves to the oldest
# account instead of raising if a pre-fix database already holds two
# rows differing only in case, so the fix never turns a legacy duplicate
# pair into a 500. ``id`` is a secondary tiebreaker so the choice stays
# deterministic even if two legacy rows share the same ``created_at``.
stmt = select(UserRow).where(func.lower(UserRow.email) == _normalize_email(email)).order_by(UserRow.created_at, UserRow.id).limit(1)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalars().first()
return self._row_to_user(row) if row is not None else None
async def update_user(self, user: User) -> User:
async with self._sf() as session:
row = await session.get(UserRow, str(user.id))
if row is None:
# Hard fail on concurrent delete: callers (reset_admin,
# password change handlers, _ensure_admin_user) all
# fetched the user just before this call, so a missing
# row here means the row vanished underneath us. Silent
# success would let the caller log "password reset" for
# a row that no longer exists.
raise UserNotFoundError(f"User {user.id} no longer exists")
# Canonicalise the email only when it actually changes, comparing
# case-insensitively against the stored value, then mirror the
# persisted value back onto the returned object. Re-lowercasing an
# *unchanged* legacy mixed-case email — e.g. a password-only update
# on a pre-fix ``Victim@x.com`` row while a canonical ``victim@x.com``
# row also exists — would rewrite it onto the other row's unique
# email and raise IntegrityError, surfacing as a 500 on the
# change-password / reset-admin paths that do not catch it. Guarding
# against the *raw* stored value (``canonical != row.email``) is not
# enough: the mixed-case row's canonical form still differs from its
# own stored casing, so it would rewrite and collide anyway. A genuine
# change still normalises, so the unique constraint keeps enforcing
# case-insensitive uniqueness for updated rows; get_user_by_email
# already resolves legacy mixed-case rows case-insensitively on read.
canonical_email = _normalize_email(user.email)
if canonical_email != _normalize_email(row.email):
row.email = canonical_email
user.email = row.email
row.password_hash = user.password_hash
row.system_role = user.system_role
row.oauth_provider = user.oauth_provider
row.oauth_id = user.oauth_id
row.needs_setup = user.needs_setup
row.token_version = user.token_version
await session.commit()
return user
async def count_users(self) -> int:
stmt = select(func.count()).select_from(UserRow)
async with self._sf() as session:
return await session.scalar(stmt) or 0
async def count_admin_users(self) -> int:
stmt = select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin")
async with self._sf() as session:
return await session.scalar(stmt) or 0
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return self._row_to_user(row) if row is not None else None

View File

@ -1,91 +0,0 @@
"""CLI tool to reset an admin password.
Usage:
python -m app.gateway.auth.reset_admin
python -m app.gateway.auth.reset_admin --email admin@example.com
Writes the new password to ``.deer-flow/admin_initial_credentials.txt``
(mode 0600) instead of printing it, so CI / log aggregators never see
the cleartext secret.
"""
from __future__ import annotations
import argparse
import asyncio
import secrets
import sys
from sqlalchemy import select
from app.gateway.auth.credential_file import write_initial_credentials
from app.gateway.auth.password import hash_password
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from deerflow.persistence.user.model import UserRow
async def _run(email: str | None) -> int:
from deerflow.config import get_app_config
from deerflow.persistence.engine import (
close_engine,
get_session_factory,
init_engine_from_config,
)
config = get_app_config()
await init_engine_from_config(config.database)
try:
sf = get_session_factory()
if sf is None:
print("Error: persistence engine not available (check config.database).", file=sys.stderr)
return 1
repo = SQLiteUserRepository(sf)
if email:
user = await repo.get_user_by_email(email)
else:
# Find first admin via direct SELECT — repository does not
# expose a "first admin" helper and we do not want to add
# one just for this CLI.
async with sf() as session:
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None:
user = None
else:
user = await repo.get_user_by_id(row.id)
if user is None:
if email:
print(f"Error: user '{email}' not found.", file=sys.stderr)
else:
print("Error: no admin user found.", file=sys.stderr)
return 1
new_password = secrets.token_urlsafe(16)
user.password_hash = hash_password(new_password)
user.token_version += 1
user.needs_setup = True
await repo.update_user(user)
cred_path = write_initial_credentials(user.email, new_password, label="reset")
print(f"Password reset for: {user.email}")
print(f"Credentials written to: {cred_path} (mode 0600)")
print("Next login will require setup (new email + password).")
return 0
finally:
await close_engine()
def main() -> None:
parser = argparse.ArgumentParser(description="Reset admin password")
parser.add_argument("--email", help="Admin email (default: first admin found)")
args = parser.parse_args()
exit_code = asyncio.run(_run(args.email))
sys.exit(exit_code)
if __name__ == "__main__":
main()

View File

@ -1,110 +0,0 @@
"""Browser session cookie policy for Gateway authentication."""
import logging
import os
from dataclasses import dataclass
from ipaddress import ip_address
from fastapi import Request, Response
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.session_cookie_state import (
SESSION_COOKIE_ISSUED_STATE_ATTR,
SESSION_COOKIE_MAX_AGE_STATE_ATTR,
SESSION_COOKIE_SECURE_STATE_ATTR,
)
from app.gateway.csrf_middleware import is_secure_request
ACCESS_TOKEN_COOKIE_NAME = "access_token"
SESSION_PERSISTENCE_COOKIE_NAME = "deerflow_session_persistent"
ALLOW_INSECURE_PERSISTENT_COOKIE_ENV = "DEER_FLOW_AUTH_ALLOW_INSECURE_PERSISTENT_COOKIE"
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class SessionCookiePolicy:
"""Resolved cookie settings for a session-creating auth response."""
secure: bool
max_age: int | None
reason: str
def _env_flag_enabled(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _request_hostname(request: Request) -> str:
"""Return the direct request host without trusting forwarded host headers."""
if request.url.hostname:
return request.url.hostname.lower()
return ""
def is_local_browser_origin(request: Request) -> bool:
"""Return True for loopback browser origins where HTTP persistence is acceptable."""
host = _request_hostname(request)
if host == "localhost" or host.endswith(".localhost"):
return True
try:
return ip_address(host).is_loopback
except ValueError:
return False
def _remember_me_from_cookie(request: Request, *, default: bool) -> bool:
value = request.cookies.get(SESSION_PERSISTENCE_COOKIE_NAME)
if value == "1":
return True
if value == "0":
return False
return default
def resolve_session_cookie_policy(request: Request, *, remember_me: bool | None = None, default_remember_me: bool = True) -> SessionCookiePolicy:
"""Resolve session cookie settings from user intent and deployment context."""
remember = _remember_me_from_cookie(request, default=default_remember_me) if remember_me is None else remember_me
secure = is_secure_request(request)
lifetime_seconds = get_auth_config().token_expiry_days * 24 * 3600
if not remember:
return SessionCookiePolicy(secure=secure, max_age=None, reason="session_requested")
if secure:
return SessionCookiePolicy(secure=True, max_age=lifetime_seconds, reason="secure_persistent")
if is_local_browser_origin(request):
return SessionCookiePolicy(secure=False, max_age=lifetime_seconds, reason="localhost_persistent")
if _env_flag_enabled(ALLOW_INSECURE_PERSISTENT_COOKIE_ENV):
return SessionCookiePolicy(secure=False, max_age=lifetime_seconds, reason="operator_insecure_persistent")
return SessionCookiePolicy(secure=False, max_age=None, reason="public_http_session")
def set_session_cookie(response: Response, request: Request, token: str, *, remember_me: bool | None = None, default_remember_me: bool = True) -> SessionCookiePolicy:
"""Set the HttpOnly access-token cookie and stamp its lifetime on request state."""
resolved_remember_me = _remember_me_from_cookie(request, default=default_remember_me) if remember_me is None else remember_me
policy = resolve_session_cookie_policy(request, remember_me=resolved_remember_me, default_remember_me=default_remember_me)
response.set_cookie(
key=ACCESS_TOKEN_COOKIE_NAME,
value=token,
httponly=True,
secure=policy.secure,
samesite="lax",
max_age=policy.max_age,
)
response.set_cookie(
key=SESSION_PERSISTENCE_COOKIE_NAME,
value="1" if resolved_remember_me else "0",
httponly=True,
secure=policy.secure,
samesite="lax",
max_age=policy.max_age,
)
setattr(request.state, SESSION_COOKIE_MAX_AGE_STATE_ATTR, policy.max_age)
setattr(request.state, SESSION_COOKIE_SECURE_STATE_ATTR, policy.secure)
setattr(request.state, SESSION_COOKIE_ISSUED_STATE_ATTR, True)
logger.debug("Resolved auth session cookie policy: reason=%s secure=%s max_age=%s", policy.reason, policy.secure, policy.max_age)
return policy

View File

@ -1,6 +0,0 @@
"""Request-state keys shared by session and CSRF cookie handling."""
SESSION_COOKIE_ISSUED_STATE_ATTR = "deerflow_session_cookie_issued"
SESSION_COOKIE_MAX_AGE_STATE_ATTR = "deerflow_session_cookie_max_age"
SESSION_COOKIE_SECURE_STATE_ATTR = "deerflow_session_cookie_secure"
SKIP_AUTH_CSRF_COOKIE_STATE_ATTR = "deerflow_skip_auth_csrf_cookie"

View File

@ -1,112 +0,0 @@
"""User provisioning for OIDC logins.
Handles the logic of finding existing users, auto-creating new ones, and
enforcing email domain restrictions. A pre-existing local account is never
auto-linked to an OIDC identity: an email collision blocks the SSO login with
a 409 instead, so an SSO login can never seize a local password account.
"""
from __future__ import annotations
import logging
from fastapi import HTTPException, status
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.oidc import OIDCIdentity
from deerflow.config.auth_config import OIDCProviderConfig
logger = logging.getLogger(__name__)
async def get_or_provision_oidc_user(
provider_id: str,
provider_config: OIDCProviderConfig,
identity: OIDCIdentity,
local_provider: LocalAuthProvider,
) -> dict:
"""Resolve an OIDC identity to a DeerFlow user.
Flow:
1. Look up existing user by (provider, subject)
2. If not found, enforce domain/email-verified rules
3. Block if a local account already owns the email (never auto-link)
4. Auto-create if enabled
Returns a dict with ``user`` (the User model instance) and ``created`` (bool).
"""
# 1. Existing OAuth link
existing = await local_provider.get_user_by_oauth(provider_id, identity.subject)
if existing:
return {"user": existing, "created": False}
# 2. Verified email requirement
if provider_config.require_verified_email and not identity.email_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=("Your email could not be verified by the identity provider. Please contact your administrator."),
)
if not identity.email:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The identity provider did not provide an email address.",
)
email = identity.email.lower()
# 3. Domain restriction
if provider_config.allowed_email_domains:
domain = email.rsplit("@", 1)[-1]
if domain not in {d.lower().lstrip("@") for d in provider_config.allowed_email_domains}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your email domain is not allowed. Please use an approved email address.",
)
# 4. Block if a local account already owns this email. We never auto-link an
# SSO identity onto a pre-existing local account, since that would let an SSO
# login take over a password account that happens to share the email.
local_user = await local_provider.get_user_by_email(email)
if local_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=("An account with this email already exists. Contact your administrator to link it to your SSO account."),
)
# 5. Auto-create
if not provider_config.auto_create_users:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Automatic account creation is disabled. Contact your administrator.",
)
role = _resolve_role(email, provider_config.admin_emails)
try:
user = await local_provider.create_oauth_user(
email=email,
oauth_provider=provider_id,
oauth_id=identity.subject,
system_role=role,
)
except ValueError:
# Lost a race: a concurrent callback (double-click, replayed code) already
# inserted a row that collides on the unique index. Re-resolve instead of
# bubbling a raw 500. If the winner created this same identity, return it;
# otherwise the email now belongs to a different account → 409.
existing = await local_provider.get_user_by_oauth(provider_id, identity.subject)
if existing:
return {"user": existing, "created": False}
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=("An account with this email already exists. Contact your administrator to link it to your SSO account."),
) from None
logger.info("Auto-created OIDC user %s (provider=%s, role=%s)", email, provider_id, role)
return {"user": user, "created": True}
def _resolve_role(email: str, admin_emails: list[str]) -> str:
"""Return ``admin`` if the email is in the admin list, otherwise ``user``."""
email_lower = email.lower()
return "admin" if any(e.lower() == email_lower for e in admin_emails) else "user"

View File

@ -1,57 +0,0 @@
"""Shared helpers for local/E2E auth-disabled mode."""
from __future__ import annotations
import logging
import os
from types import SimpleNamespace
from deerflow.runtime.user_context import DEFAULT_USER_ID
AUTH_DISABLED_ENV_VAR = "DEER_FLOW_AUTH_DISABLED"
AUTH_DISABLED_USER_ID = DEFAULT_USER_ID
AUTH_DISABLED_USER_EMAIL = "default@test.local"
AUTH_SOURCE_SESSION = "session"
AUTH_SOURCE_INTERNAL = "internal"
AUTH_SOURCE_AUTH_DISABLED = "auth_disabled"
_PRODUCTION_ENV_VARS: tuple[str, ...] = ("DEER_FLOW_ENV", "ENVIRONMENT")
_PRODUCTION_ENV_VALUES: frozenset[str] = frozenset({"prod", "production"})
logger = logging.getLogger(__name__)
def is_explicit_production_environment() -> bool:
return any(os.environ.get(name, "").strip().lower() in _PRODUCTION_ENV_VALUES for name in _PRODUCTION_ENV_VARS)
def is_auth_disabled_requested() -> bool:
return os.environ.get(AUTH_DISABLED_ENV_VAR) == "1"
def is_auth_disabled() -> bool:
return is_auth_disabled_requested() and not is_explicit_production_environment()
def warn_if_auth_disabled_enabled() -> None:
if not is_auth_disabled():
return
logger.warning(
"%s=1 is active: authentication is bypassed and anonymous requests run as synthetic admin user %r. Do not enable this in shared or production deployments.",
AUTH_DISABLED_ENV_VAR,
AUTH_DISABLED_USER_ID,
)
def get_auth_disabled_user():
return SimpleNamespace(
id=AUTH_DISABLED_USER_ID,
email=AUTH_DISABLED_USER_EMAIL,
password_hash=None,
system_role="admin",
needs_setup=False,
token_version=0,
oauth_provider=None,
)

View File

@ -1,163 +0,0 @@
"""Global authentication middleware — fail-closed safety net.
Rejects unauthenticated requests to non-public paths with 401. When a
request passes the cookie check, resolves the JWT payload to a real
``User`` object and stamps it into both ``request.state.user`` and the
``deerflow.runtime.user_context`` contextvar so that repository-layer
owner filtering works automatically via the sentinel pattern.
Fine-grained permission checks remain in authz.py decorators.
"""
from collections.abc import Callable
from fastapi import HTTPException, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.types import ASGIApp
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.auth_disabled import (
AUTH_SOURCE_AUTH_DISABLED,
AUTH_SOURCE_INTERNAL,
AUTH_SOURCE_SESSION,
get_auth_disabled_user,
is_auth_disabled,
)
from app.gateway.authz import AuthContext, resolve_route_permissions
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
from deerflow.runtime.user_context import reset_current_user, set_current_user
# Paths that never require authentication.
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/api/v1/auth/oauth/",
"/api/v1/auth/callback/",
# Inbound webhooks authenticate themselves via provider-specific signatures
# (e.g. GitHub's X-Hub-Signature-256), not session cookies.
"/api/webhooks/",
)
# Exact auth paths that are public (login/register/status check).
# /api/v1/auth/me, /api/v1/auth/change-password etc. are NOT public.
_PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
{
"/api/v1/auth/login/local",
"/api/v1/auth/register",
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
"/api/v1/auth/initialize",
"/api/v1/auth/providers",
}
)
def _is_public(path: str) -> bool:
stripped = path.rstrip("/")
if stripped in _PUBLIC_EXACT_PATHS:
return True
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
class AuthMiddleware(BaseHTTPMiddleware):
"""Strict auth gate: reject requests without a valid session.
Two-stage check for non-public paths:
1. Cookie presence return 401 NOT_AUTHENTICATED if missing
2. JWT validation via ``get_optional_user_from_request`` return 401
TOKEN_INVALID if the token is absent, malformed, expired, or the
signed user does not exist / is stale
On success, stamps ``request.state.user`` and the
``deerflow.runtime.user_context`` contextvar so that repository-layer
owner filters work downstream without every route needing a
``@require_auth`` decorator. Routes that need per-resource
authorization (e.g. "user A cannot read user B's thread by guessing
the URL") should additionally use ``@require_permission(...,
owner_check=True)`` for explicit enforcement but authentication
itself is fully handled here.
"""
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
if _is_public(request.url.path):
return await call_next(request)
internal_user = None
if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)):
# Extract the channel owner user ID from the trusted header.
# When present, the synthetic internal user carries the actual
# owner identity so that get_effective_user_id() and per-user
# filesystem paths (custom skills, memory, thread data) resolve
# to the IM channel user instead of falling back to "default".
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME
owner_user_id = request.headers.get(INTERNAL_OWNER_USER_ID_HEADER_NAME)
if owner_user_id:
owner_user_id = owner_user_id.strip()
internal_user = get_internal_user(owner_user_id=owner_user_id or None)
auth_source = AUTH_SOURCE_SESSION
access_token = request.cookies.get("access_token")
# Non-public path: require session cookie
if internal_user is not None:
user = internal_user
auth_source = AUTH_SOURCE_INTERNAL
elif access_token:
# Strict JWT validation: reject junk/expired tokens with 401
# right here instead of silently passing through. This closes
# the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8):
# without this, non-isolation routes like /api/models would
# accept any cookie-shaped string as authentication.
#
# We call the *strict* resolver so that fine-grained error
# codes (token_expired, token_invalid, user_not_found, …)
# propagate from AuthErrorCode, not get flattened into one
# generic code. BaseHTTPMiddleware doesn't let HTTPException
# bubble up, so we catch and render it as JSONResponse here.
from app.gateway.deps import get_current_user_from_request
try:
user = await get_current_user_from_request(request)
except HTTPException as exc:
if not is_auth_disabled():
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
user = get_auth_disabled_user()
auth_source = AUTH_SOURCE_AUTH_DISABLED
elif is_auth_disabled():
user = get_auth_disabled_user()
auth_source = AUTH_SOURCE_AUTH_DISABLED
else:
return JSONResponse(
status_code=401,
content={
"detail": AuthErrorResponse(
code=AuthErrorCode.NOT_AUTHENTICATED,
message="Authentication required",
).model_dump()
},
)
# Stamp both request.state.user (for the contextvar pattern)
# and request.state.auth (so @require_permission's "auth is
# None" branch short-circuits instead of running the entire
# JWT-decode + DB-lookup pipeline a second time per request).
request.state.user = user
request.state.auth_source = auth_source
permissions = await resolve_route_permissions(
user,
is_internal=auth_source == AUTH_SOURCE_INTERNAL,
)
request.state.auth = AuthContext(user=user, permissions=permissions)
token = set_current_user(user)
try:
return await call_next(request)
finally:
reset_current_user(token)

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