mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 08:56:13 +00:00
Compare commits
No commits in common. "main" and "v2.0-m0" have entirely different histories.
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.")
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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!`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)..."
|
||||||
|
|||||||
@ -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 "=========================================="
|
||||||
|
|||||||
@ -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}} |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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`
|
||||||
|
|
||||||
|
|||||||
58
.env.example
58
.env.example
@ -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
|
|
||||||
|
|||||||
170
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
170
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -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.
|
|
||||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
11
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -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.
|
|
||||||
67
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
67
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@ -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.
|
|
||||||
128
.github/ISSUE_TEMPLATE/runtime-information.yml
vendored
Normal file
128
.github/ISSUE_TEMPLATE/runtime-information.yml
vendored
Normal 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.
|
||||||
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@ -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
119
.github/labels.yml
vendored
@ -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
|
|
||||||
75
.github/pull_request_template.md
vendored
75
.github/pull_request_template.md
vendored
@ -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.
|
|
||||||
|
|
||||||
46
.github/workflows/backend-blocking-io-tests.yml
vendored
46
.github/workflows/backend-blocking-io-tests.yml
vendored
@ -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
|
|
||||||
56
.github/workflows/backend-unit-tests.yml
vendored
56
.github/workflows/backend-unit-tests.yml
vendored
@ -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
|
||||||
|
|||||||
94
.github/workflows/chart.yaml
vendored
94
.github/workflows/chart.yaml
vendored
@ -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
|
|
||||||
162
.github/workflows/container.yaml
vendored
162
.github/workflows/container.yaml
vendored
@ -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
|
|
||||||
69
.github/workflows/e2e-tests.yml
vendored
69
.github/workflows/e2e-tests.yml
vendored
@ -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
|
|
||||||
43
.github/workflows/frontend-unit-tests.yml
vendored
43
.github/workflows/frontend-unit-tests.yml
vendored
@ -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
|
|
||||||
38
.github/workflows/label-sync.yml
vendored
38
.github/workflows/label-sync.yml
vendored
@ -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 }}
|
|
||||||
8
.github/workflows/lint-check.yml
vendored
8
.github/workflows/lint-check.yml
vendored
@ -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: |
|
||||||
|
|||||||
234
.github/workflows/nightly.yaml
vendored
234
.github/workflows/nightly.yaml
vendored
@ -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
|
|
||||||
108
.github/workflows/replay-e2e.yml
vendored
108
.github/workflows/replay-e2e.yml
vendored
@ -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
|
|
||||||
70
.github/workflows/skill-review-ci.yml
vendored
70
.github/workflows/skill-review-ci.yml
vendored
@ -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 }}"
|
|
||||||
223
.github/workflows/triage.yml
vendored
223
.github/workflows/triage.yml
vendored
@ -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}.`);
|
|
||||||
27
.github/workflows/verify-versions.yml
vendored
27
.github/workflows/verify-versions.yml
vendored
@ -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
12
.gitignore
vendored
@ -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/
|
|
||||||
|
|||||||
@ -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
138
AGENTS.md
@ -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.
|
|
||||||
1345
CHANGELOG.md
1345
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
1168
CHANGELOG_zh.md
1168
CHANGELOG_zh.md
File diff suppressed because it is too large
Load Diff
@ -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
|
|
||||||
@ -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
|
||||||
|
|||||||
110
CONTRIBUTING.md
110
CONTRIBUTING.md
@ -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)
|
||||||
|
|||||||
91
Makefile
91
Makefile
@ -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
446
README.md
@ -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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
```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
|
||||||
|
|||||||
264
README_fr.md
264
README_fr.md
@ -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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
```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
|
||||||
|
|||||||
282
README_ja.md
282
README_ja.md
@ -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 Push(WebSocket) | 中程度 |
|
|
||||||
|
|
||||||
**`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、サンドボックス設定を尊重します。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
```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) - 開発環境のセットアップとワークフロー
|
||||||
|
|||||||
264
README_ru.md
264
README_ru.md
@ -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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
```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) — настройка среды разработки, воркфлоу и гайдлайны
|
||||||
|
|||||||
293
README_zh.md
293
README_zh.md
@ -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 provider(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 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_url,OpenRouter 也会读取 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 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) | 中等 |
|
|
||||||
| 企业微信智能机器人 | WebSocket | 中等 |
|
| 企业微信智能机器人 | WebSocket | 中等 |
|
||||||
| 钉钉 | Stream Push(WebSocket) | 中等 |
|
|
||||||
|
|
||||||
**`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 和 Langfuse,DeerFlow 会挂载两个追踪 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-task)MVP。
|
|
||||||
|
|
||||||
当前 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 和沙箱配置。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
```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) - 开发环境搭建与协作流程
|
||||||
|
|||||||
168
RELEASING.md
168
RELEASING.md
@ -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.
|
|
||||||
@ -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
9
backend/.gitignore
vendored
@ -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/
|
|
||||||
|
|||||||
1293
backend/AGENTS.md
1293
backend/AGENTS.md
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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)"
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
@ -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):
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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()
|
|
||||||
@ -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
@ -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."""
|
||||||
|
|||||||
@ -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] = {}
|
|
||||||
@ -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
|
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
(``&``, ``<``, ``>``) 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 ``>`` 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)^>", ">", 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)
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}")
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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",
|
|
||||||
]
|
|
||||||
@ -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
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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)
|
|
||||||
@ -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')")
|
|
||||||
@ -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)
|
|
||||||
@ -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}",
|
|
||||||
)
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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()
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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"
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user