mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-13 19:06:01 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e75a2ff29a | |||
| 185f5649dd |
@@ -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,237 +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, 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. 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.
|
|
||||||
|
|
||||||
## Issue Flow
|
|
||||||
|
|
||||||
Use Issue Flow for GitHub issues, bug reports, feature requests, support questions, and issue batches.
|
|
||||||
|
|
||||||
Start every issue with a cheap duplicate-opinion 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. If an existing maintainer or trusted-agent issue comment already gives a materially equivalent diagnosis, modification suggestion, information request, or blocking decision, skip deep analysis and do not post anything public for that issue.
|
|
||||||
4. Treat ordinary reporter replies, thanks, unrelated discussion, or incomplete guesses as non-blocking.
|
|
||||||
5. Report skipped issues to the maintainer only as compact identifiers plus the skipped 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 and skip if an equivalent maintainer or trusted-agent comment appeared during analysis.
|
|
||||||
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 duplicate-review precheck:
|
|
||||||
|
|
||||||
1. Fetch PR metadata, changed file list, checks summary, existing PR reviews, existing PR comments, and review threads when available.
|
|
||||||
2. If an existing maintainer or trusted-agent review already gives materially equivalent findings or a blocking decision, skip deep review and do not post anything public for that PR.
|
|
||||||
3. Treat author replies, thanks, unrelated discussion, or incomplete guesses as non-blocking.
|
|
||||||
4. Report skipped PRs to the maintainer only as compact identifiers plus 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.
|
|
||||||
- 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 skip if an equivalent maintainer or trusted-agent review appeared during analysis.
|
|
||||||
7. If there are high-confidence findings, post a PR review comment using the PR language. If there are no high-confidence findings, do not post a public PR review/comment; report `No high-confidence review findings.` to the maintainer in the run result.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Do not produce compliments, summaries, or general advice. For sensitive security issues, describe impact and remediation without exploit instructions.
|
|
||||||
|
|
||||||
## 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:
|
|
||||||
Failed:
|
|
||||||
Per issue:
|
|
||||||
Issue:
|
|
||||||
Surface:
|
|
||||||
Actionability:
|
|
||||||
Risk:
|
|
||||||
Comment:
|
|
||||||
Validation:
|
|
||||||
Comment status:
|
|
||||||
```
|
|
||||||
|
|
||||||
For PR Review Flow:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Run result:
|
|
||||||
Reviewed:
|
|
||||||
Skipped:
|
|
||||||
Clean:
|
|
||||||
Failed:
|
|
||||||
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 | skipped | none | existing maintainer comment |
|
|
||||||
```
|
|
||||||
|
|
||||||
Omit empty categories, no-op fields, routine command output, and raw logs. Report meaningful changes, evidence, and options.
|
|
||||||
@@ -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`
|
||||||
@@ -93,17 +93,17 @@ smoke-test/
|
|||||||
### 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`
|
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`
|
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,7 +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
|
||||||
|
|
||||||
## Key Tools
|
## Key Tools
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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,11 +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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -371,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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -403,11 +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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
+2
-29
@@ -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,7 +17,6 @@ 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
|
||||||
# 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
|
||||||
@@ -29,7 +24,6 @@ INFOQUEST_API_KEY=your-infoquest-api-key
|
|||||||
# SLACK_BOT_TOKEN=your-slack-bot-token
|
# SLACK_BOT_TOKEN=your-slack-bot-token
|
||||||
# SLACK_APP_TOKEN=your-slack-app-token
|
# SLACK_APP_TOKEN=your-slack-app-token
|
||||||
# TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
# TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||||
# DISCORD_BOT_TOKEN=your-discord-bot-token
|
|
||||||
|
|
||||||
# Enable LangSmith to monitor and debug your LLM calls, agent runs, and tool executions.
|
# Enable LangSmith to monitor and debug your LLM calls, agent runs, and tool executions.
|
||||||
# LANGSMITH_TRACING=true
|
# LANGSMITH_TRACING=true
|
||||||
@@ -45,24 +39,3 @@ INFOQUEST_API_KEY=your-infoquest-api-key
|
|||||||
#
|
#
|
||||||
# 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
|
|
||||||
|
|
||||||
# ── 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
|
|
||||||
|
|||||||
@@ -1,159 +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
|
|
||||||
- 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: additional
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Screenshots, related issues, config snippets (redacted), or anything else that helps triage.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
name: Backend Blocking IO
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
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
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
name: Publish Containers
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
backend-container:
|
|
||||||
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@v6
|
|
||||||
- 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 }}
|
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
|
||||||
uses: actions/attest-build-provenance@v2
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
|
|
||||||
frontend-container:
|
|
||||||
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@v6
|
|
||||||
- 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@v2
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
name: E2E Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ 'main' ]
|
|
||||||
paths:
|
|
||||||
- 'frontend/**'
|
|
||||||
- '.github/workflows/e2e-tests.yml'
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
paths:
|
|
||||||
- 'frontend/**'
|
|
||||||
- '.github/workflows/e2e-tests.yml'
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: e2e-tests-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
e2e-tests:
|
|
||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Enable Corepack
|
|
||||||
run: corepack enable
|
|
||||||
|
|
||||||
- name: Use pinned pnpm version
|
|
||||||
run: corepack prepare pnpm@10.26.2 --activate
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
working-directory: frontend
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Install Playwright Chromium
|
|
||||||
working-directory: frontend
|
|
||||||
run: npx playwright install chromium --with-deps
|
|
||||||
|
|
||||||
- name: Run E2E tests
|
|
||||||
working-directory: frontend
|
|
||||||
run: pnpm exec playwright test
|
|
||||||
env:
|
|
||||||
SKIP_ENV_VALIDATION: '1'
|
|
||||||
|
|
||||||
- name: Upload Playwright report
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: ${{ !cancelled() }}
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: frontend/playwright-report/
|
|
||||||
retention-days: 7
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
name: Frontend Unit Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ 'main' ]
|
|
||||||
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
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
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
|
|
||||||
@@ -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}.`);
|
|
||||||
@@ -40,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/
|
||||||
@@ -56,7 +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
|
||||||
|
|||||||
@@ -1,33 +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/
|
|
||||||
|
|
||||||
# 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]
|
|
||||||
+26
-40
@@ -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,13 +184,17 @@ 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
|
||||||
```
|
```
|
||||||
@@ -207,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,8 +235,8 @@ deer-flow/
|
|||||||
│ └── nginx.local.conf # Nginx config for local dev
|
│ └── nginx.local.conf # Nginx config for local dev
|
||||||
├── backend/ # Backend application
|
├── backend/ # Backend application
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── gateway/ # Gateway API and LangGraph-compatible runtime (port 8001)
|
│ │ ├── gateway/ # Gateway API (port 8001)
|
||||||
│ │ ├── agents/ # LangGraph agent runtime used by Gateway
|
│ │ ├── agents/ # LangGraph agents (port 2024)
|
||||||
│ │ ├── mcp/ # Model Context Protocol integration
|
│ │ ├── mcp/ # Model Context Protocol integration
|
||||||
│ │ ├── skills/ # Skills system
|
│ │ ├── skills/ # Skills system
|
||||||
│ │ └── sandbox/ # Sandbox execution
|
│ │ └── sandbox/ # Sandbox execution
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DeerFlow - Unified Development Environment
|
# DeerFlow - Unified Development Environment
|
||||||
|
|
||||||
.PHONY: help config config-upgrade check install setup doctor detect-thread-boundaries detect-blocking-io dev dev-daemon start start-daemon stop up down clean docker-init docker-start docker-stop docker-logs docker-logs-frontend docker-logs-gateway
|
.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
|
||||||
@@ -23,24 +23,28 @@ help:
|
|||||||
@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 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"
|
||||||
@@ -53,12 +57,6 @@ setup:
|
|||||||
doctor:
|
doctor:
|
||||||
@$(BACKEND_UV_RUN) python ../scripts/doctor.py
|
@$(BACKEND_UV_RUN) python ../scripts/doctor.py
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -75,8 +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..."
|
|
||||||
@$(BACKEND_UV_RUN) --with pre-commit pre-commit install
|
|
||||||
@echo "✓ All dependencies installed"
|
@echo "✓ All dependencies installed"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=========================================="
|
@echo "=========================================="
|
||||||
@@ -89,28 +85,77 @@ 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 prod daemon + Gateway mode (experimental)
|
||||||
|
start-daemon-pro:
|
||||||
|
@$(PYTHON) ./scripts/check.py
|
||||||
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --prod --gateway --daemon
|
||||||
|
|
||||||
# Stop all services
|
# Stop all services
|
||||||
stop:
|
stop:
|
||||||
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --stop
|
@$(RUN_WITH_GIT_BASH) ./scripts/serve.sh --stop
|
||||||
@@ -119,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"
|
||||||
|
|
||||||
@@ -134,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
|
||||||
@@ -156,6 +206,10 @@ docker-logs-gateway:
|
|||||||
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
|
||||||
|
|||||||
@@ -243,20 +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
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> The Gateway holds run state (RunManager and the stream bridge) in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Raising the worker count without a shared cross-worker stream bridge — which is not yet available — breaks run cancellation, SSE reconnects, request de-duplication, and IM channels, because nginx uses no sticky sessions and each worker keeps its own run state. Scale a single worker up with more CPU/RAM (or move the database and sandbox onto dedicated tiers) instead of raising `GATEWAY_WORKERS`.
|
|
||||||
|
|
||||||
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**:
|
||||||
@@ -266,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**:
|
||||||
@@ -291,31 +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.
|
||||||
|
|
||||||
|
#### Why Gateway Mode?
|
||||||
|
|
||||||
|
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
|
||||||
@@ -343,8 +363,6 @@ See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions
|
|||||||
|
|
||||||
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 |
|
||||||
@@ -352,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
|
||||||
|
|
||||||
@@ -422,19 +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.
|
|
||||||
|
|
||||||
Set the corresponding API keys in your `.env` file:
|
Set the corresponding API keys in your `.env` file:
|
||||||
|
|
||||||
@@ -457,10 +466,6 @@ 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**
|
||||||
@@ -499,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**
|
||||||
|
|
||||||
@@ -551,15 +548,6 @@ 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:
|
|
||||||
|
|
||||||
- `session_id` = LangGraph `thread_id` — groups every trace of the same conversation
|
|
||||||
- `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)
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
#### Using Both Providers
|
#### Using Both Providers
|
||||||
|
|
||||||
If both LangSmith and Langfuse are enabled, DeerFlow attaches both tracing callbacks and reports the same model activity to both systems.
|
If both LangSmith and Langfuse are enabled, DeerFlow attaches both tracing callbacks and reports the same model activity to both systems.
|
||||||
@@ -590,8 +578,6 @@ 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.
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -644,7 +630,7 @@ See [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerfl
|
|||||||
|
|
||||||
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. When token usage tracking is enabled, completed sub-agent usage is 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.
|
||||||
|
|
||||||
@@ -672,8 +658,6 @@ This is the difference between a chatbot with tool access and an agent with an a
|
|||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -747,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
|
||||||
|
|||||||
+3
-22
@@ -228,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
|
||||||
|
|
||||||
@@ -290,14 +290,13 @@ DeerFlow peut recevoir des tâches depuis des applications de messagerie. Les ca
|
|||||||
| 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 |
|
||||||
| 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
|
||||||
|
|
||||||
@@ -342,13 +341,6 @@ channels:
|
|||||||
context:
|
context:
|
||||||
thinking_enabled: true
|
thinking_enabled: true
|
||||||
subagent_enabled: true
|
subagent_enabled: true
|
||||||
|
|
||||||
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` :
|
||||||
@@ -364,10 +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
|
||||||
|
|
||||||
# DingTalk
|
|
||||||
DINGTALK_CLIENT_ID=your_client_id
|
|
||||||
DINGTALK_CLIENT_SECRET=your_client_secret
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration Telegram**
|
**Configuration Telegram**
|
||||||
@@ -390,13 +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 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 :
|
||||||
|
|||||||
+3
-22
@@ -181,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
|
||||||
|
|
||||||
@@ -243,14 +243,13 @@ DeerFlowはメッセージングアプリからのタスク受信をサポート
|
|||||||
| Telegram | Bot API(ロングポーリング) | 簡単 |
|
| Telegram | Bot API(ロングポーリング) | 簡単 |
|
||||||
| Slack | Socket Mode | 中程度 |
|
| Slack | Socket Mode | 中程度 |
|
||||||
| Feishu / Lark | WebSocket | 中程度 |
|
| Feishu / Lark | 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
|
||||||
|
|
||||||
@@ -295,13 +294,6 @@ channels:
|
|||||||
context:
|
context:
|
||||||
thinking_enabled: true
|
thinking_enabled: true
|
||||||
subagent_enabled: true
|
subagent_enabled: true
|
||||||
|
|
||||||
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`ファイルに設定します:
|
||||||
@@ -317,10 +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
|
||||||
|
|
||||||
# DingTalk
|
|
||||||
DINGTALK_CLIENT_ID=your_client_id
|
|
||||||
DINGTALK_CLIENT_SECRET=your_client_secret
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Telegramのセットアップ**
|
**Telegramのセットアップ**
|
||||||
@@ -343,13 +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`でチャネルを有効にします。
|
||||||
|
|
||||||
**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と対話できます:
|
||||||
|
|||||||
@@ -256,7 +256,6 @@ DeerFlow принимает задачи прямо из мессенджеро
|
|||||||
| Telegram | Bot API (long-polling) | Просто |
|
| Telegram | Bot API (long-polling) | Просто |
|
||||||
| Slack | Socket Mode | Средне |
|
| Slack | Socket Mode | Средне |
|
||||||
| Feishu / Lark | WebSocket | Средне |
|
| Feishu / Lark | WebSocket | Средне |
|
||||||
| DingTalk | Stream Push (WebSocket) | Средне |
|
|
||||||
|
|
||||||
**Конфигурация в `config.yaml`:**
|
**Конфигурация в `config.yaml`:**
|
||||||
|
|
||||||
@@ -279,13 +278,6 @@ channels:
|
|||||||
enabled: true
|
enabled: true
|
||||||
bot_token: $TELEGRAM_BOT_TOKEN
|
bot_token: $TELEGRAM_BOT_TOKEN
|
||||||
allowed_users: []
|
allowed_users: []
|
||||||
|
|
||||||
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 для потокового эффекта печатной машинки
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Настройка Telegram**
|
**Настройка Telegram**
|
||||||
@@ -293,13 +285,6 @@ channels:
|
|||||||
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`.
|
||||||
|
|
||||||
**Настройка 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`.
|
|
||||||
|
|
||||||
**Доступные команды**
|
**Доступные команды**
|
||||||
|
|
||||||
| Команда | Описание |
|
| Команда | Описание |
|
||||||
|
|||||||
+4
-23
@@ -184,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
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ make down # 停止并移除容器
|
|||||||
|
|
||||||
如果你更希望直接在本地启动各个服务:
|
如果你更希望直接在本地启动各个服务:
|
||||||
|
|
||||||
前提:先完成上面的“配置”步骤(`make config` 和模型 API key 配置)。`make dev` 需要有效配置文件,默认读取项目根目录下的 `config.yaml`。可以用 `DEER_FLOW_PROJECT_ROOT` 显式指定项目根目录,也可以用 `DEER_FLOW_CONFIG_PATH` 指向某个具体配置文件。运行期状态默认写到项目根目录下的 `.deer-flow`,可用 `DEER_FLOW_HOME` 覆盖;skills 默认读取项目根目录下的 `skills/`,可用 `DEER_FLOW_SKILLS_PATH` 覆盖。
|
前提:先完成上面的“配置”步骤(`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. **检查依赖环境**:
|
||||||
@@ -248,14 +248,13 @@ DeerFlow 支持从即时通讯应用接收任务。只要配置完成,对应
|
|||||||
| Slack | Socket Mode | 中等 |
|
| Slack | Socket Mode | 中等 |
|
||||||
| Feishu / Lark | WebSocket | 中等 |
|
| Feishu / Lark | WebSocket | 中等 |
|
||||||
| 企业微信智能机器人 | 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
|
||||||
|
|
||||||
@@ -305,13 +304,6 @@ channels:
|
|||||||
context:
|
context:
|
||||||
thinking_enabled: true
|
thinking_enabled: true
|
||||||
subagent_enabled: true
|
subagent_enabled: true
|
||||||
|
|
||||||
dingtalk:
|
|
||||||
enabled: true
|
|
||||||
client_id: $DINGTALK_CLIENT_ID # 钉钉开放平台 ClientId
|
|
||||||
client_secret: $DINGTALK_CLIENT_SECRET # 钉钉开放平台 ClientSecret
|
|
||||||
allowed_users: [] # 留空表示允许所有人
|
|
||||||
card_template_id: "" # 可选:AI 卡片模板 ID,用于流式打字机效果
|
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
@@ -335,10 +327,6 @@ FEISHU_APP_SECRET=your_app_secret
|
|||||||
# 企业微信智能机器人
|
# 企业微信智能机器人
|
||||||
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 配置**
|
||||||
@@ -369,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 交互:
|
||||||
|
|||||||
@@ -24,10 +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
|
||||||
|
|||||||
+72
-187
@@ -7,13 +7,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
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.
|
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**:
|
**Architecture**:
|
||||||
- **Gateway API** (port 8001): REST API plus embedded LangGraph-compatible agent runtime
|
- **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
|
- **Frontend** (port 3000): Next.js web interface
|
||||||
- **Nginx** (port 2026): Unified reverse proxy entry point
|
- **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
|
- **Provisioner** (port 8002, optional in Docker dev): Started only when sandbox is configured for provisioner/Kubernetes mode
|
||||||
|
|
||||||
**Runtime**:
|
**Runtime Modes**:
|
||||||
- `make dev`, Docker dev, and production all run the agent runtime in Gateway via `RunManager` + `run_agent()` + `StreamBridge` (`packages/harness/deerflow/runtime/`). Nginx exposes that runtime at `/api/langgraph/*` and rewrites it to Gateway's native `/api/*` routers.
|
- **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**:
|
**Project Structure**:
|
||||||
```
|
```
|
||||||
@@ -23,7 +25,7 @@ deer-flow/
|
|||||||
├── extensions_config.json # MCP servers and skills configuration
|
├── extensions_config.json # MCP servers and skills configuration
|
||||||
├── backend/ # Backend application (this directory)
|
├── backend/ # Backend application (this directory)
|
||||||
│ ├── Makefile # Backend-only commands (dev, gateway, lint)
|
│ ├── Makefile # Backend-only commands (dev, gateway, lint)
|
||||||
│ ├── langgraph.json # LangGraph Studio graph configuration
|
│ ├── langgraph.json # LangGraph server configuration
|
||||||
│ ├── packages/
|
│ ├── packages/
|
||||||
│ │ └── harness/ # deerflow-harness package (import: deerflow.*)
|
│ │ └── harness/ # deerflow-harness package (import: deerflow.*)
|
||||||
│ │ ├── pyproject.toml
|
│ │ ├── pyproject.toml
|
||||||
@@ -81,72 +83,26 @@ When making code changes, you MUST update the relevant documentation:
|
|||||||
```bash
|
```bash
|
||||||
make check # Check system requirements
|
make check # Check system requirements
|
||||||
make install # Install all dependencies (frontend + backend)
|
make install # Install all dependencies (frontend + backend)
|
||||||
make dev # Start all services (Gateway + Frontend + Nginx), with config.yaml preflight
|
make dev # Start all services (LangGraph + Gateway + Frontend + Nginx), with config.yaml preflight
|
||||||
make start # Start production services locally
|
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
|
make stop # Stop all services
|
||||||
```
|
```
|
||||||
|
|
||||||
**Backend directory** (for backend development only):
|
**Backend directory** (for backend development only):
|
||||||
```bash
|
```bash
|
||||||
make install # Install backend dependencies
|
make install # Install backend dependencies
|
||||||
make dev # Run Gateway API with reload (port 8001)
|
make dev # Run LangGraph server only (port 2024)
|
||||||
make gateway # Run Gateway API only (port 8001)
|
make gateway # Run Gateway API only (port 8001)
|
||||||
make test # Run all backend tests
|
make test # Run all backend tests
|
||||||
make test-blocking-io # Run strict Blockbuster runtime gate on tests/blocking_io/
|
make lint # Lint with ruff
|
||||||
make lint # Lint with ruff
|
make format # Format code with ruff
|
||||||
make format # Format code with ruff
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `detect-blocking-io` target parses `app/`, `packages/harness/deerflow/`,
|
|
||||||
and `scripts/` with AST. By default it reports only blocking IO candidates that
|
|
||||||
are inside async code, reachable from async code in the same file, or reachable
|
|
||||||
from sync-only `AgentMiddleware` before/after hooks that LangGraph can execute
|
|
||||||
on the async graph path. It prints a concise summary and writes complete JSON
|
|
||||||
findings to `.deer-flow/blocking-io-findings.json` at the repository root
|
|
||||||
(both `make detect-blocking-io` from the repo root and `cd backend && make
|
|
||||||
detect-blocking-io` resolve to the same repo-root path). JSON findings include
|
|
||||||
`priority`, `location`, `blocking_call`, `event_loop_exposure`, `reason`, and
|
|
||||||
`code` for model-assisted or manual review. `priority` is a deterministic
|
|
||||||
review ordering from 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. It is intentionally
|
|
||||||
informational and is not run from CI in this round.
|
|
||||||
|
|
||||||
For a diff-scoped view of the same findings, `scripts/scan_changed_blocking_io.py`
|
|
||||||
(repo root) reports findings on the added lines of `git diff <base>...HEAD`
|
|
||||||
plus findings new versus the merge base (so a new async caller exposing an
|
|
||||||
untouched sync helper in the same file is still reported) — used by the
|
|
||||||
`blocking-io-guard` skill (`.agent/skills/blocking-io-guard/`) as the
|
|
||||||
deterministic scope step before routing each candidate to a fix and/or a
|
|
||||||
`tests/blocking_io/` runtime anchor.
|
|
||||||
|
|
||||||
Regression tests related to Docker/provisioner behavior:
|
Regression tests related to Docker/provisioner behavior:
|
||||||
- `tests/test_docker_sandbox_mode_detection.py` (mode detection from `config.yaml`)
|
- `tests/test_docker_sandbox_mode_detection.py` (mode detection from `config.yaml`)
|
||||||
- `tests/test_provisioner_kubeconfig.py` (kubeconfig file/directory handling)
|
- `tests/test_provisioner_kubeconfig.py` (kubeconfig file/directory handling)
|
||||||
|
|
||||||
Blocking-IO runtime gate (`tests/blocking_io/`):
|
|
||||||
- Wraps every item under `tests/blocking_io/` with a strict Blockbuster
|
|
||||||
context scoped to `app.*` and `deerflow.*` (see
|
|
||||||
`tests/support/detectors/blocking_io_runtime.py`). Any sync blocking IO
|
|
||||||
call whose stack passes through DeerFlow business code while running on
|
|
||||||
the asyncio event loop raises `BlockingError` and fails the test.
|
|
||||||
- Regression anchors live there: `test_skills_load.py` (locks the
|
|
||||||
`asyncio.to_thread` offload around `LocalSkillStorage.load_skills`, fix
|
|
||||||
for #1917); `test_sqlite_lifespan.py` (locks the offload around
|
|
||||||
SQLite path resolution plus `ensure_sqlite_parent_dir`, fix for #1912);
|
|
||||||
`test_jsonl_run_event_store.py` (locks `JsonlRunEventStore`'s async
|
|
||||||
API offloading its file IO via `asyncio.to_thread`, fix #3084); and
|
|
||||||
`test_uploads_middleware.py` (locks `UploadsMiddleware.abefore_agent`
|
|
||||||
offloading the uploads-directory scan off the event loop).
|
|
||||||
- `test_gate_smoke.py` is a meta-test asserting the gate actually catches
|
|
||||||
unoffloaded blocking IO and that the `@pytest.mark.allow_blocking_io`
|
|
||||||
opt-out works.
|
|
||||||
- Coverage boundary: the gate only sees code that test execution actually
|
|
||||||
touches. Static AST coverage is a separate concern (out of scope for
|
|
||||||
this PR).
|
|
||||||
- CI: runs on every PR via `.github/workflows/backend-blocking-io-tests.yml`,
|
|
||||||
hard-fail.
|
|
||||||
|
|
||||||
Boundary check (harness → app import firewall):
|
Boundary check (harness → app import firewall):
|
||||||
- `tests/test_harness_boundary.py` — ensures `packages/harness/deerflow/` never imports from `app.*`
|
- `tests/test_harness_boundary.py` — ensures `packages/harness/deerflow/` never imports from `app.*`
|
||||||
|
|
||||||
@@ -159,7 +115,7 @@ CI runs these regression tests for every pull request via [.github/workflows/bac
|
|||||||
The backend is split into two layers with a strict dependency direction:
|
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.
|
- **Harness** (`packages/harness/deerflow/`): Publishable agent framework package (`deerflow-harness`). Import prefix: `deerflow.*`. Contains agent orchestration, tools, sandbox, models, MCP, skills, config — everything needed to build and run agents.
|
||||||
- **App** (`app/`): Unpublished application code. Import prefix: `app.*`. Contains the FastAPI Gateway API and IM channel integrations (Feishu, Slack, Telegram, DingTalk).
|
- **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.
|
**Dependency rule**: App imports deerflow, but deerflow never imports app. This boundary is enforced by `tests/test_harness_boundary.py` which runs in CI.
|
||||||
|
|
||||||
@@ -200,27 +156,20 @@ from deerflow.config import get_app_config
|
|||||||
|
|
||||||
### Middleware Chain
|
### Middleware Chain
|
||||||
|
|
||||||
Lead-agent middlewares are assembled in strict append order across `packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py` (`build_lead_runtime_middlewares`) and `packages/harness/deerflow/agents/lead_agent/agent.py` (`build_middlewares`):
|
Middlewares execute in strict order in `packages/harness/deerflow/agents/lead_agent/agent.py`:
|
||||||
|
|
||||||
1. **ThreadDataMiddleware** - Creates per-thread directories under the user's isolation scope (`backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}`); resolves `user_id` via `get_effective_user_id()` (falls back to `"default"` in no-auth mode); Web UI thread deletion now follows LangGraph thread removal with Gateway cleanup of the local thread directory
|
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
|
2. **UploadsMiddleware** - Tracks and injects newly uploaded files into conversation
|
||||||
3. **SandboxMiddleware** - Acquires sandbox, stores `sandbox_id` in state
|
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), including raw provider tool-call payloads preserved only in `additional_kwargs["tool_calls"]`
|
4. **DanglingToolCallMiddleware** - Injects placeholder ToolMessages for AIMessage tool_calls that lack responses (e.g., due to user interruption)
|
||||||
5. **LLMErrorHandlingMiddleware** - Normalizes provider/model invocation failures into recoverable assistant-facing errors before later middleware/tool stages run
|
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. **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. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
|
7. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||||
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
8. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
||||||
9. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, reads `SKILL.md` from trusted skill storage, injects the skill body as hidden current-turn model context, and records a `middleware:skill_activation` audit event with skill name, category, path, and content hash
|
9. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
||||||
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
10. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
||||||
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
11. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if subagent_enabled)
|
||||||
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional); subagent usage is cached by `tool_call_id` only while token usage is enabled and merged back into the dispatching AIMessage by message position rather than message id
|
12. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||||
13. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
|
||||||
14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
|
||||||
15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
|
||||||
16. **DeferredToolFilterMiddleware** - Hides deferred (MCP) tool schemas from the bound model using a build-time deferred-name set + catalog hash, reading per-thread promotions from `ThreadState.promoted` (hash-scoped, no ContextVar); a tool becomes bound on subsequent turns after `tool_search` returns its schema (optional, if `tool_search.enabled`)
|
|
||||||
17. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
|
||||||
18. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
|
|
||||||
19. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
|
||||||
|
|
||||||
### Configuration System
|
### Configuration System
|
||||||
|
|
||||||
@@ -232,10 +181,6 @@ Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** direc
|
|||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
**Config Hot-Reload Boundary**: Gateway dependencies route through `get_app_config()` on every request, so per-run fields like `models[*].max_tokens`, `summarization.*`, `title.*`, `memory.*`, `subagents.*`, `tools[*]`, and the agent system prompt pick up `config.yaml` edits on the next message. `AppConfig` is intentionally **not** cached on `app.state` — `lifespan()` keeps a local `startup_config` variable for one-shot bootstrap work and passes it to `langgraph_runtime(app, startup_config)`.
|
|
||||||
|
|
||||||
Infrastructure fields are **restart-required**. The authoritative list lives in `packages/harness/deerflow/config/reload_boundary.py::STARTUP_ONLY_FIELDS` and is mirrored by the standardised `"startup-only:"` prefix on the corresponding `Field(description=...)` in `AppConfig`, so IDE hover on those fields surfaces the reason inline (no need to context-switch into this table). Currently registered: `database`, `checkpointer`, `run_events`, `stream_bridge`, `sandbox`, `log_level`, `channels`, `channel_connections`. Adding a new restart-required field requires updating the registry; drift is pinned by `tests/test_reload_boundary.py`.
|
|
||||||
|
|
||||||
Configuration priority:
|
Configuration priority:
|
||||||
1. Explicit `config_path` argument
|
1. Explicit `config_path` argument
|
||||||
2. `DEER_FLOW_CONFIG_PATH` environment variable
|
2. `DEER_FLOW_CONFIG_PATH` environment variable
|
||||||
@@ -257,9 +202,7 @@ Configuration priority:
|
|||||||
|
|
||||||
### Gateway API (`app/gateway/`)
|
### Gateway API (`app/gateway/`)
|
||||||
|
|
||||||
FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled).
|
FastAPI application on port 8001 with health check at `GET /health`.
|
||||||
|
|
||||||
CORS is same-origin by default when requests enter through nginx on port 2026. Split-origin or port-forwarded browser clients must opt in with `GATEWAY_CORS_ORIGINS` (comma-separated exact origins); Gateway `CORSMiddleware` and `CSRFMiddleware` both read that variable so browser CORS and auth-origin checks stay aligned.
|
|
||||||
|
|
||||||
**Routers**:
|
**Routers**:
|
||||||
|
|
||||||
@@ -272,39 +215,29 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
|
|||||||
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
|
| **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 |
|
| **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 |
|
| **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 and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
|
| **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing |
|
||||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
|
|
||||||
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
|
|
||||||
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
|
||||||
|
|
||||||
**RunManager / RunStore contract**:
|
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
||||||
- `RunManager.get()` is async; direct callers must `await` it.
|
|
||||||
- When a persistent `RunStore` is configured, `get()` and `list_by_thread()` hydrate historical runs from the store. In-memory records win for the same `run_id` so task, abort, and stream-control state stays attached to active local runs.
|
|
||||||
- `cancel()` and `create_or_reject(..., multitask_strategy="interrupt"|"rollback")` persist interrupted status through `RunStore.update_status()`, matching normal `set_status()` transitions.
|
|
||||||
- Store-only hydrated runs are readable history. If the current worker has no in-memory task/control state for that run, cancellation APIs can return 409 because this worker cannot stop the task.
|
|
||||||
- `POST /wait` (both thread-scoped and `/api/runs/wait`) drains the stream bridge via `wait_for_run_completion()` instead of bare `await record.task`, so it honours the run's `on_disconnect` setting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).
|
|
||||||
|
|
||||||
Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
|
|
||||||
|
|
||||||
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
||||||
|
|
||||||
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
|
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
|
||||||
**Provider Pattern**: `SandboxProvider` with `acquire`, `acquire_async`, `get`, `release` lifecycle. Async agent/tool paths call async sandbox lifecycle hooks so Docker sandbox creation, discovery, cross-process locking, readiness polling, and release stay off the event loop.
|
**Provider Pattern**: `SandboxProvider` with `acquire`, `get`, `release` lifecycle
|
||||||
**Implementations**:
|
**Implementations**:
|
||||||
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-thread `LocalSandbox` (id `local:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`.
|
- `LocalSandboxProvider` - Singleton local filesystem execution with path mappings
|
||||||
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths.
|
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation
|
||||||
|
|
||||||
**Virtual Path System**:
|
**Virtual Path System**:
|
||||||
- Agent sees: `/mnt/user-data/{workspace,uploads,outputs}`, `/mnt/skills`
|
- Agent sees: `/mnt/user-data/{workspace,uploads,outputs}`, `/mnt/skills`
|
||||||
- Physical: `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
|
- Physical: `backend/.deer-flow/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
|
||||||
- Translation: `LocalSandboxProvider` builds per-thread `PathMapping`s for the user-data prefixes at acquire time; `tools.py` keeps `replace_virtual_path()` / `replace_virtual_paths_in_command()` as a defense-in-depth layer (and for path validation). AIO has the directories volume-mounted at the same virtual paths inside its container, so both implementations accept `/mnt/user-data/...` natively.
|
- Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()`
|
||||||
- Detection: `is_local_sandbox()` accepts both `sandbox_id == "local"` (legacy / no-thread) and `sandbox_id.startswith("local:")` (per-thread)
|
- Detection: `is_local_sandbox()` checks `sandbox_id == "local"`
|
||||||
|
|
||||||
**Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`):
|
**Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`):
|
||||||
- `bash` - Execute commands with path translation and error handling
|
- `bash` - Execute commands with path translation and error handling
|
||||||
- `ls` - Directory listing (tree format, max 2 levels)
|
- `ls` - Directory listing (tree format, max 2 levels)
|
||||||
- `read_file` - Read file contents with optional line range
|
- `read_file` - Read file contents with optional line range
|
||||||
- `write_file` - Write/append to files, creates directories; overwrites by default and exposes the `append` argument in the model-facing schema for end-of-file writes
|
- `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
|
- `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/`)
|
### Subagent System (`packages/harness/deerflow/subagents/`)
|
||||||
@@ -314,7 +247,6 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
|||||||
**Concurrency**: `MAX_CONCURRENT_SUBAGENTS = 3` enforced by `SubagentLimitMiddleware` (truncates excess tool calls in `after_model`), 15-minute timeout
|
**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
|
**Flow**: `task()` tool → `SubagentExecutor` → background thread → poll 5s → SSE events → result
|
||||||
**Events**: `task_started`, `task_running`, `task_completed`/`task_failed`/`task_timed_out`
|
**Events**: `task_started`, `task_running`, `task_completed`/`task_failed`/`task_timed_out`
|
||||||
**Deferred MCP tools** (if `tool_search.enabled`): `SubagentExecutor._build_initial_state` assembles deferral after policy filtering via the shared `assemble_deferred_tools` (fail-closed), appends the `tool_search` tool, injects the `<available-deferred-tools>` section into the subagent's `SystemMessage`, and threads the setup to `_create_agent`, which attaches `DeferredToolFilterMiddleware` through `build_subagent_runtime_middlewares(deferred_setup=...)`. Subagents thus withhold full MCP schemas until promotion, same as the lead agent; each task run gets a fresh `ThreadState` so promotion is isolated per run
|
|
||||||
|
|
||||||
### Tool System (`packages/harness/deerflow/tools/`)
|
### Tool System (`packages/harness/deerflow/tools/`)
|
||||||
|
|
||||||
@@ -325,10 +257,8 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
|||||||
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
|
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
|
||||||
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware → interrupts)
|
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware → interrupts)
|
||||||
- `view_image` - Read image as base64 (added only if model supports vision)
|
- `view_image` - Read image as base64 (added only if model supports vision)
|
||||||
- `setup_agent` - Bootstrap-only: persist a brand-new custom agent's `SOUL.md` and `config.yaml`. Bound only when `is_bootstrap=True`.
|
|
||||||
- `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`.
|
|
||||||
4. **Subagent tool** (if enabled):
|
4. **Subagent tool** (if enabled):
|
||||||
- `task` - Delegate to subagent (description, prompt, subagent_type)
|
- `task` - Delegate to subagent (description, prompt, subagent_type, max_turns)
|
||||||
|
|
||||||
**Community tools** (`packages/harness/deerflow/community/`):
|
**Community tools** (`packages/harness/deerflow/community/`):
|
||||||
- `tavily/` - Web search (5 results default) and web fetch (4KB limit)
|
- `tavily/` - Web search (5 results default) and web fetch (4KB limit)
|
||||||
@@ -339,7 +269,7 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
|||||||
- `invoke_acp_agent` - Invokes external ACP-compatible agents from `config.yaml`
|
- `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
|
- 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]`
|
- 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}/users/{user_id}/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`
|
- 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
|
- `image_search/` - Image search via DuckDuckGo
|
||||||
|
|
||||||
### MCP System (`packages/harness/deerflow/mcp/`)
|
### MCP System (`packages/harness/deerflow/mcp/`)
|
||||||
@@ -349,7 +279,7 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
|||||||
- **Cache invalidation**: Detects config file changes via mtime comparison
|
- **Cache invalidation**: Detects config file changes via mtime comparison
|
||||||
- **Transports**: stdio (command-based), SSE, HTTP
|
- **Transports**: stdio (command-based), SSE, HTTP
|
||||||
- **OAuth (HTTP/SSE)**: Supports token endpoint flows (`client_credentials`, `refresh_token`) with automatic token refresh + Authorization header injection
|
- **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; the Gateway-embedded runtime detects changes via mtime
|
- **Runtime updates**: Gateway API saves to extensions_config.json; LangGraph detects via mtime
|
||||||
|
|
||||||
### Skills System (`packages/harness/deerflow/skills/`)
|
### Skills System (`packages/harness/deerflow/skills/`)
|
||||||
|
|
||||||
@@ -357,7 +287,6 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
|||||||
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
|
- **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
|
- **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
|
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||||
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`), disabled skills, and skills outside a custom agent's whitelist.
|
|
||||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||||
|
|
||||||
### Model Factory (`packages/harness/deerflow/models/factory.py`)
|
### Model Factory (`packages/harness/deerflow/models/factory.py`)
|
||||||
@@ -377,120 +306,68 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
|||||||
|
|
||||||
### IM Channels System (`app/channels/`)
|
### IM Channels System (`app/channels/`)
|
||||||
|
|
||||||
Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk) to the DeerFlow agent via Gateway's LangGraph-compatible API.
|
Bridges external messaging platforms (Feishu, Slack, Telegram) to the DeerFlow agent via the LangGraph Server.
|
||||||
|
|
||||||
**Architecture**: Channels communicate with Gateway through the `langgraph-sdk` HTTP client (same as the frontend), ensuring threads are created and managed server-side. The internal SDK client injects process-local internal auth plus a matching CSRF cookie/header pair so Gateway accepts state-changing thread/run requests from channel workers without relying on browser session cookies.
|
**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**:
|
**Components**:
|
||||||
- `message_bus.py` - Async pub/sub hub (`InboundMessage` → queue → dispatcher; `OutboundMessage` → callbacks → channels)
|
- `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)
|
- `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/Discord on `client.runs.wait()`, and uses `client.runs.stream(["messages-tuple", "values"])` for Feishu/Telegram incremental outbound updates
|
- `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)
|
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle)
|
||||||
- `service.py` - Manages lifecycle of all configured channels from `config.yaml`
|
- `service.py` - Manages lifecycle of all configured channels from `config.yaml`
|
||||||
- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `telegram.py` registers the "Working on it..." placeholder as the stream target and edits it in place via `editMessageText`; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured)
|
- `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)
|
||||||
- `app/gateway/routers/channel_connections.py` - Browser-facing user connection and disconnect APIs
|
|
||||||
- `deerflow.persistence.channel_connections` - SQL-backed user-owned connection, optional credential, connect state, and conversation store
|
|
||||||
|
|
||||||
**Message Flow**:
|
**Message Flow**:
|
||||||
1. External platform -> Channel impl -> `MessageBus.publish_inbound()`
|
1. External platform -> Channel impl -> `MessageBus.publish_inbound()`
|
||||||
2. `ChannelManager._dispatch_loop()` consumes from queue
|
2. `ChannelManager._dispatch_loop()` consumes from queue
|
||||||
3. For user-owned channel connections, incoming messages carry `connection_id`, `owner_user_id`, and `workspace_id`; `owner_user_id` becomes the DeerFlow run `user_id`, while the raw platform user id remains `channel_user_id`
|
3. For chat: look up/create thread on LangGraph Server
|
||||||
4. For chat: look up/create thread through Gateway's LangGraph-compatible API
|
4. Feishu chat: `runs.stream()` → accumulate AI text → publish multiple outbound updates (`is_final=False`) → publish final outbound (`is_final=True`)
|
||||||
5. Feishu/Telegram 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. Slack/Discord 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. 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. Telegram streaming: the "Working on it..." placeholder message is registered as the stream target; non-final updates `editMessageText` it in place (channel-side throttle: 1s in private chats, 3s in groups due to Telegram's 20 msg/min group cap; 4096-char truncation; rate-limited updates dropped); the final update performs the last edit and splits >4096 texts into follow-up messages
|
8. Outbound → channel callbacks → platform reply
|
||||||
9. DingTalk AI Card mode (when `card_template_id` configured): `runs.stream()` → create card with initial text → stream updates via `PUT /v1.0/card/streaming` → finalize on `is_final=True`. Falls back to `sampleMarkdown` if card creation or streaming fails
|
|
||||||
10. For commands (`/new`, `/status`, `/models`, `/memory`, `/help`): handle locally or query Gateway API
|
|
||||||
11. Outbound → channel callbacks → platform reply
|
|
||||||
|
|
||||||
**Configuration** (`config.yaml` -> `channels`):
|
**Configuration** (`config.yaml` -> `channels`):
|
||||||
- `langgraph_url` - LangGraph-compatible Gateway API base URL (default: `http://localhost:8001/api`)
|
- `langgraph_url` - LangGraph Server URL (default: `http://localhost:2024`)
|
||||||
- `gateway_url` - Gateway API URL for auxiliary commands (default: `http://localhost:8001`)
|
- `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://gateway:8001/api` for `langgraph_url` and `http://gateway:8001` for `gateway_url`, or set `DEER_FLOW_CHANNELS_LANGGRAPH_URL` / `DEER_FLOW_CHANNELS_GATEWAY_URL`.
|
- 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), `dingtalk` (client_id, client_secret, optional `card_template_id` for AI Card streaming)
|
- Per-channel configs: `feishu` (app_id, app_secret), `slack` (bot_token, app_token), `telegram` (bot_token)
|
||||||
|
|
||||||
**User-owned channel connections** (`config.yaml` -> `channel_connections`):
|
|
||||||
- Disabled by default. It is a user-binding layer on top of the existing `channels.*` runtime config, not a replacement for provider bot credentials.
|
|
||||||
- No public IP, OAuth callback URL, or provider webhook route is required by the current implementation.
|
|
||||||
- Telegram uses a deep-link `/start <code>` flow over the existing long-polling worker. Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom use `/connect <code>` over their existing outbound channel workers.
|
|
||||||
- Frontend APIs: `GET /api/channels/providers`, `GET /api/channels/connections`, `POST /api/channels/{provider}/connect`, and `DELETE /api/channels/connections/{connection_id}`.
|
|
||||||
- Browser APIs remain protected by normal Gateway auth/CSRF. Provider messages arrive through the already-configured channel workers.
|
|
||||||
- Provider-level `connection_status` reflects the user's newest connection row. With no binding it is `not_connected`, except in auth-disabled local mode where a configured running channel reports `connected` because all channel messages already route to the default user.
|
|
||||||
- Slack replies use the configured operator bot token from `channels.slack` unless per-connection credentials are present; unreadable or corrupt stored credentials are treated as unavailable.
|
|
||||||
- Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom workers resolve incoming platform identities to connection records before reaching `ChannelManager`.
|
|
||||||
- See `backend/docs/IM_CHANNEL_CONNECTIONS.md` for provider setup and operational notes.
|
|
||||||
|
|
||||||
|
|
||||||
### Memory System (`packages/harness/deerflow/agents/memory/`)
|
### Memory System (`packages/harness/deerflow/agents/memory/`)
|
||||||
|
|
||||||
**Components**:
|
**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
|
- `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); captures `user_id` at enqueue time so it survives the `threading.Timer` boundary
|
- `queue.py` - Debounced update queue (per-thread deduplication, configurable wait time)
|
||||||
- `prompt.py` - Prompt templates for memory updates
|
- `prompt.py` - Prompt templates for memory updates
|
||||||
- `storage.py` - File-based storage with per-user isolation; cache keyed by `(user_id, agent_name)` tuple
|
|
||||||
|
|
||||||
**Per-User Isolation**:
|
**Data Structure** (stored in `backend/.deer-flow/memory.json`):
|
||||||
- Memory is stored per-user at `{base_dir}/users/{user_id}/memory.json`
|
|
||||||
- Per-agent per-user memory at `{base_dir}/users/{user_id}/agents/{agent_name}/memory.json`
|
|
||||||
- Custom agent definitions (`SOUL.md` + `config.yaml`) are also per-user at `{base_dir}/users/{user_id}/agents/{agent_name}/`. The legacy shared layout `{base_dir}/agents/{agent_name}/` remains read-only fallback for unmigrated installations
|
|
||||||
- `user_id` is resolved via `get_effective_user_id()` from `deerflow.runtime.user_context`
|
|
||||||
- In no-auth mode, `user_id` defaults to `"default"` (constant `DEFAULT_USER_ID`)
|
|
||||||
- Absolute `storage_path` in config opts out of per-user isolation
|
|
||||||
- **Migration**: Run `PYTHONPATH=. python scripts/migrate_user_isolation.py` to move legacy `memory.json`, `threads/`, and `agents/` into per-user layout. Supports `--dry-run` (preview changes) and `--user-id USER_ID` (assign unowned legacy data to a user, defaults to `default`).
|
|
||||||
|
|
||||||
**Data Structure** (stored in `{base_dir}/users/{user_id}/memory.json`):
|
|
||||||
- **User Context**: `workContext`, `personalContext`, `topOfMind` (1-3 sentence summaries)
|
- **User Context**: `workContext`, `personalContext`, `topOfMind` (1-3 sentence summaries)
|
||||||
- **History**: `recentMonths`, `earlierContext`, `longTermBackground`
|
- **History**: `recentMonths`, `earlierContext`, `longTermBackground`
|
||||||
- **Facts**: Discrete facts with `id`, `content`, `category` (preference/knowledge/context/behavior/goal), `confidence` (0-1), `createdAt`, `source`
|
- **Facts**: Discrete facts with `id`, `content`, `category` (preference/knowledge/context/behavior/goal), `confidence` (0-1), `createdAt`, `source`
|
||||||
|
|
||||||
**Workflow**:
|
**Workflow**:
|
||||||
1. `MemoryMiddleware` filters messages (user inputs + final AI responses), captures `user_id` via `get_effective_user_id()`, and queues conversation with the captured `user_id`
|
1. `MemoryMiddleware` filters messages (user inputs + final AI responses) and queues conversation
|
||||||
2. Queue debounces (30s default), batches updates, deduplicates per-thread
|
2. Queue debounces (30s default), batches updates, deduplicates per-thread
|
||||||
3. Background thread invokes LLM to extract context updates and facts, using the stored `user_id` (not the contextvar, which is unavailable on timer threads)
|
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
|
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
|
5. Next interaction injects top 15 facts + context into `<memory>` tags in system prompt
|
||||||
|
|
||||||
**Token counting** (`packages/harness/deerflow/agents/memory/prompt.py`):
|
|
||||||
- `_count_tokens` budgets the injection. In default `tiktoken` mode, the encoding is loaded lazily and cached.
|
|
||||||
- Failed tiktoken loads are cached with a timestamp. During the fixed cooldown (`_TIKTOKEN_RETRY_COOLDOWN_S`, 600s), callers fall back to char estimation immediately instead of re-triggering the blocking BPE download; after the cooldown, transient outages can self-heal without a restart.
|
|
||||||
- In-flight loads are cached as a LOADING sentinel so concurrent callers fall back instead of spawning more blocking threads.
|
|
||||||
- Set `memory.token_counting: char` to skip tiktoken entirely and use the network-free CJK-aware char estimate.
|
|
||||||
|
|
||||||
Focused regression coverage for the updater lives in `backend/tests/test_memory_updater.py`.
|
Focused regression coverage for the updater lives in `backend/tests/test_memory_updater.py`.
|
||||||
|
|
||||||
**Configuration** (`config.yaml` → `memory`):
|
**Configuration** (`config.yaml` → `memory`):
|
||||||
- `enabled` / `injection_enabled` - Master switches
|
- `enabled` / `injection_enabled` - Master switches
|
||||||
- `storage_path` - Path to memory.json (absolute path opts out of per-user isolation)
|
- `storage_path` - Path to memory.json
|
||||||
- `debounce_seconds` - Wait time before processing (default: 30)
|
- `debounce_seconds` - Wait time before processing (default: 30)
|
||||||
- `model_name` - LLM for updates (null = default model)
|
- `model_name` - LLM for updates (null = default model)
|
||||||
- `max_facts` / `fact_confidence_threshold` - Fact storage limits (100 / 0.7)
|
- `max_facts` / `fact_confidence_threshold` - Fact storage limits (100 / 0.7)
|
||||||
- `max_injection_tokens` - Token limit for prompt injection (2000)
|
- `max_injection_tokens` - Token limit for prompt injection (2000)
|
||||||
- `token_counting` - Token counting strategy for the injection budget: `tiktoken` (default, accurate but may download BPE data from a public endpoint on first use — can block for a long time in network-restricted environments, see issues #3402/#3429) or `char` (network-free CJK-aware char estimate, never touches tiktoken)
|
|
||||||
|
|
||||||
### Reflection System (`packages/harness/deerflow/reflection/`)
|
### Reflection System (`packages/harness/deerflow/reflection/`)
|
||||||
|
|
||||||
- `resolve_variable(path)` - Import module and return variable (e.g., `module.path:variable_name`)
|
- `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
|
- `resolve_class(path, base_class)` - Import and validate class against base class
|
||||||
|
|
||||||
### Tracing System (`packages/harness/deerflow/tracing/`)
|
|
||||||
|
|
||||||
LangSmith and Langfuse are both supported. The wiring lives in two layers:
|
|
||||||
|
|
||||||
- `factory.py::build_tracing_callbacks()` — returns the LangChain `CallbackHandler` list for the providers currently enabled via env vars (`LANGSMITH_TRACING`, `LANGFUSE_TRACING`, etc.). The handlers are attached at the **graph invocation root** for in-graph runs (`make_lead_agent` and `DeerFlowClient.stream` both append them to `config["callbacks"]` before invoking the graph) so a single run produces one trace with all node / LLM / tool calls as child spans. Standalone callers — anything that invokes a model outside such a graph (e.g. `MemoryUpdater`) — keep `create_chat_model`'s default `attach_tracing=True`, which falls back to model-level callback attachment.
|
|
||||||
- `metadata.py::build_langfuse_trace_metadata()` — builds the Langfuse-reserved trace attributes for `RunnableConfig.metadata`. The Langfuse v4 `langchain.CallbackHandler` lifts these onto the root trace (see its `_parse_langfuse_trace_attributes`), but only when it sees `on_chain_start(parent_run_id=None)` — which is why the callbacks have to live at the graph root, not the model.
|
|
||||||
|
|
||||||
**Trace-attribute injection points**: both `runtime/runs/worker.py::run_agent` (gateway path) and `client.py::DeerFlowClient.stream` (embedded path) merge the metadata into `config["metadata"]` right before constructing the graph. Caller-supplied keys win via `setdefault`, so an external `session_id` override is preserved. Field mapping:
|
|
||||||
|
|
||||||
| Langfuse field | Source |
|
|
||||||
|-----------------------|----------------------------------------------|
|
|
||||||
| `langfuse_session_id` | LangGraph `thread_id` |
|
|
||||||
| `langfuse_user_id` | `get_effective_user_id()` (`default` in no-auth) |
|
|
||||||
| `langfuse_trace_name` | `RunRecord.assistant_id` / client `agent_name` (defaults to `lead-agent`) |
|
|
||||||
| `langfuse_tags` | `env:<DEER_FLOW_ENV>` + `model:<model_name>` |
|
|
||||||
|
|
||||||
Returns `{}` when Langfuse is not in the enabled providers — LangSmith-only deployments are unaffected. Set `DEER_FLOW_ENV` (or `ENVIRONMENT`) to tag traces by deployment environment. Tests live in `tests/test_tracing_factory.py`, `tests/test_tracing_metadata.py`, `tests/test_worker_langfuse_metadata.py`, and `tests/test_client_langfuse_metadata.py`.
|
|
||||||
|
|
||||||
### Config Schema
|
### Config Schema
|
||||||
|
|
||||||
**`config.yaml`** key sections:
|
**`config.yaml`** key sections:
|
||||||
@@ -515,16 +392,16 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
|||||||
|
|
||||||
`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.
|
`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 Gateway API uses. Shares the same config files and data directories. No FastAPI dependency.
|
**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**:
|
**Agent Conversation** (replaces LangGraph Server):
|
||||||
- `chat(message, thread_id)` — synchronous, accumulates streaming deltas per message-id and returns the final AI text
|
- `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`:
|
- `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
|
- `"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
|
- `"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`
|
- `"custom"` — forwarded from `StreamWriter`
|
||||||
- `"end"` — stream finished (carries cumulative `usage` counted once per message id)
|
- `"end"` — stream finished (carries cumulative `usage` counted once per message id)
|
||||||
- Agent created lazily via `create_agent()` + `build_middlewares()`, same as `make_lead_agent`
|
- Agent created lazily via `create_agent()` + `_build_middlewares()`, same as `make_lead_agent`
|
||||||
- Supports `checkpointer` parameter for state persistence across turns
|
- Supports `checkpointer` parameter for state persistence across turns
|
||||||
- `reset_agent()` forces agent recreation (e.g. after memory or skill changes)
|
- `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
|
- 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
|
||||||
@@ -580,15 +457,20 @@ This starts all services and makes the application available at `http://localhos
|
|||||||
| | **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 mode embeds the agent runtime in Gateway, no LangGraph server.
|
||||||
|
|
||||||
**Nginx routing**:
|
**Nginx routing**:
|
||||||
- `/api/langgraph/*` → Gateway embedded runtime (8001), rewritten to `/api/*`
|
- Standard mode: `/api/langgraph/*` → LangGraph Server (2024)
|
||||||
|
- Gateway mode: `/api/langgraph/*` → Gateway embedded runtime (8001) (via envsubst)
|
||||||
- `/api/*` (other) → Gateway API (8001)
|
- `/api/*` (other) → Gateway API (8001)
|
||||||
- `/` (non-API) → Frontend (3000)
|
- `/` (non-API) → Frontend (3000)
|
||||||
|
|
||||||
@@ -597,11 +479,15 @@ This starts all services and makes the application available at `http://localhos
|
|||||||
From the **backend** directory:
|
From the **backend** directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Gateway API
|
# Terminal 1: LangGraph server
|
||||||
|
make dev
|
||||||
|
|
||||||
|
# Terminal 2: Gateway API
|
||||||
make gateway
|
make gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
Direct access (without nginx):
|
Direct access (without nginx):
|
||||||
|
- LangGraph: `http://localhost:2024`
|
||||||
- Gateway: `http://localhost:8001`
|
- Gateway: `http://localhost:8001`
|
||||||
|
|
||||||
### Frontend Configuration
|
### Frontend Configuration
|
||||||
@@ -622,7 +508,6 @@ Multi-file upload with automatic document conversion:
|
|||||||
- Rejects directory inputs before copying so uploads stay all-or-nothing
|
- Rejects directory inputs before copying so uploads stay all-or-nothing
|
||||||
- Reuses one conversion worker per request when called from an active event loop
|
- Reuses one conversion worker per request when called from an active event loop
|
||||||
- Files stored in thread-isolated directories
|
- Files stored in thread-isolated directories
|
||||||
- Duplicate filenames in a single upload request are auto-renamed with `_N` suffixes so later files do not truncate earlier files
|
|
||||||
- Agent receives uploaded file list via `UploadsMiddleware`
|
- Agent receives uploaded file list via `UploadsMiddleware`
|
||||||
|
|
||||||
See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
|
See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
|
||||||
|
|||||||
@@ -56,8 +56,11 @@ 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
|
||||||
|
|||||||
+3
-13
@@ -50,12 +50,6 @@ COPY backend ./backend
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
sh -c "cd backend && UV_INDEX_URL=${UV_INDEX_URL:-https://pypi.org/simple} uv sync ${UV_EXTRAS:+--extra $UV_EXTRAS}"
|
sh -c "cd backend && UV_INDEX_URL=${UV_INDEX_URL:-https://pypi.org/simple} uv sync ${UV_EXTRAS:+--extra $UV_EXTRAS}"
|
||||||
|
|
||||||
# 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
|
||||||
# source distributions in development containers.
|
# source distributions in development containers.
|
||||||
@@ -64,7 +58,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"]
|
||||||
|
|
||||||
@@ -72,10 +66,6 @@ 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
|
||||||
@@ -94,8 +84,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"]
|
||||||
|
|||||||
+3
-9
@@ -2,16 +2,13 @@ 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:
|
||||||
uvx ruff check .
|
uvx ruff check .
|
||||||
@@ -19,6 +16,3 @@ lint:
|
|||||||
|
|
||||||
format:
|
format:
|
||||||
uvx ruff check . --fix && uvx 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
|
|
||||||
|
|||||||
+34
-43
@@ -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,12 +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
|
||||||
- **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
|
||||||
|
|
||||||
@@ -119,7 +124,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
|||||||
| `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 |
|
||||||
| `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) |
|
| `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths) |
|
||||||
| `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 |
|
||||||
@@ -188,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
|
||||||
@@ -196,11 +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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -236,16 +244,12 @@ backend/
|
|||||||
│ └── utils/ # Utilities
|
│ └── utils/ # Utilities
|
||||||
├── 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
|
||||||
@@ -358,11 +362,10 @@ 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
@@ -379,18 +382,6 @@ make detect-blocking-io # Inventory blocking IO that may block the backend even
|
|||||||
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
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ class Channel(ABC):
|
|||||||
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
|
||||||
|
|||||||
@@ -18,21 +18,3 @@ KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset(
|
|||||||
"/help",
|
"/help",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_connect_code(text: str) -> str | None:
|
|
||||||
"""Extract the one-time channel binding code from a connect command."""
|
|
||||||
parts = text.strip().split()
|
|
||||||
if len(parts) < 2:
|
|
||||||
return None
|
|
||||||
command = parts[0].lower()
|
|
||||||
if command in {"/connect", "connect"}:
|
|
||||||
return parts[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,842 +0,0 @@
|
|||||||
"""DingTalk channel implementation."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.channels.base import Channel
|
|
||||||
from app.channels.commands import extract_connect_code, is_known_channel_command
|
|
||||||
from app.channels.connection_identity import attach_connection_identity
|
|
||||||
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
DINGTALK_API_BASE = "https://api.dingtalk.com"
|
|
||||||
|
|
||||||
_TOKEN_REFRESH_MARGIN_SECONDS = 300
|
|
||||||
|
|
||||||
_CONVERSATION_TYPE_P2P = "1"
|
|
||||||
_CONVERSATION_TYPE_GROUP = "2"
|
|
||||||
|
|
||||||
_MAX_UPLOAD_SIZE_BYTES = 20 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_conversation_type(raw: Any) -> str:
|
|
||||||
"""Normalize ``conversationType`` to ``"1"`` (P2P) or ``"2"`` (group).
|
|
||||||
|
|
||||||
Stream payloads may send int or string values.
|
|
||||||
"""
|
|
||||||
if raw is None:
|
|
||||||
return _CONVERSATION_TYPE_P2P
|
|
||||||
s = str(raw).strip()
|
|
||||||
if s == _CONVERSATION_TYPE_GROUP:
|
|
||||||
return _CONVERSATION_TYPE_GROUP
|
|
||||||
return _CONVERSATION_TYPE_P2P
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
|
||||||
"DingTalk allowed_users should be a list of user IDs; treating %s as one string value",
|
|
||||||
type(allowed_users).__name__,
|
|
||||||
)
|
|
||||||
values = [allowed_users]
|
|
||||||
return {str(uid) for uid in values if str(uid)}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_dingtalk_command(text: str) -> bool:
|
|
||||||
return is_known_channel_command(text)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_text_from_rich_text(rich_text_list: list) -> str:
|
|
||||||
parts: list[str] = []
|
|
||||||
for item in rich_text_list:
|
|
||||||
if isinstance(item, dict) and "text" in item:
|
|
||||||
parts.append(item["text"])
|
|
||||||
return " ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
_FENCED_CODE_BLOCK_RE = re.compile(r"```(\w*)\n(.*?)```", re.DOTALL)
|
|
||||||
_INLINE_CODE_RE = re.compile(r"`([^`\n]+)`")
|
|
||||||
_HORIZONTAL_RULE_RE = re.compile(r"^-{3,}$", re.MULTILINE)
|
|
||||||
_TABLE_SEPARATOR_RE = re.compile(r"^\|[-:| ]+\|$", re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_markdown_table(text: str) -> str:
|
|
||||||
# DingTalk sampleMarkdown does not render pipe-delimited tables.
|
|
||||||
lines = text.split("\n")
|
|
||||||
result: list[str] = []
|
|
||||||
i = 0
|
|
||||||
while i < len(lines):
|
|
||||||
line = lines[i]
|
|
||||||
# Detect table: header row followed by separator row
|
|
||||||
if i + 1 < len(lines) and line.strip().startswith("|") and _TABLE_SEPARATOR_RE.match(lines[i + 1].strip()):
|
|
||||||
headers = [h.strip() for h in line.strip().strip("|").split("|")]
|
|
||||||
i += 2 # skip header + separator
|
|
||||||
while i < len(lines) and lines[i].strip().startswith("|"):
|
|
||||||
cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
|
|
||||||
for h, c in zip(headers, cells):
|
|
||||||
result.append(f"> **{h}**: {c}")
|
|
||||||
result.append("")
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
result.append(line)
|
|
||||||
i += 1
|
|
||||||
return "\n".join(result)
|
|
||||||
|
|
||||||
|
|
||||||
def _adapt_markdown_for_dingtalk(text: str) -> str:
|
|
||||||
"""Adapt markdown for DingTalk's limited sampleMarkdown renderer."""
|
|
||||||
|
|
||||||
def _code_block_to_quote(match: re.Match) -> str:
|
|
||||||
lang = match.group(1)
|
|
||||||
code = match.group(2).rstrip("\n")
|
|
||||||
prefix = f"> **{lang}**\n" if lang else ""
|
|
||||||
quoted_lines = "\n".join(f"> {line}" for line in code.split("\n"))
|
|
||||||
return f"{prefix}{quoted_lines}\n"
|
|
||||||
|
|
||||||
text = _FENCED_CODE_BLOCK_RE.sub(_code_block_to_quote, text)
|
|
||||||
text = _INLINE_CODE_RE.sub(r"**\1**", text)
|
|
||||||
text = _convert_markdown_table(text)
|
|
||||||
text = _HORIZONTAL_RULE_RE.sub("───────────", text)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
class DingTalkChannel(Channel):
|
|
||||||
"""DingTalk IM channel using Stream Push (WebSocket, no public IP needed)."""
|
|
||||||
|
|
||||||
def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None:
|
|
||||||
super().__init__(name="dingtalk", bus=bus, config=config)
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
self._main_loop: asyncio.AbstractEventLoop | None = None
|
|
||||||
self._client_id: str = ""
|
|
||||||
self._client_secret: str = ""
|
|
||||||
self._allowed_users: set[str] = _normalize_allowed_users(config.get("allowed_users"))
|
|
||||||
self._cached_token: str = ""
|
|
||||||
self._token_expires_at: float = 0.0
|
|
||||||
self._token_lock = asyncio.Lock()
|
|
||||||
self._card_template_id: str = config.get("card_template_id", "")
|
|
||||||
self._card_track_ids: dict[str, str] = {}
|
|
||||||
self._dingtalk_client: Any = None
|
|
||||||
self._stream_client: Any = None
|
|
||||||
self._incoming_messages: dict[str, Any] = {}
|
|
||||||
self._incoming_messages_lock = threading.Lock()
|
|
||||||
self._card_repliers: dict[str, Any] = {}
|
|
||||||
self._connection_repo = config.get("connection_repo")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def supports_streaming(self) -> bool:
|
|
||||||
return bool(self._card_template_id)
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
import dingtalk_stream # noqa: F401
|
|
||||||
except ImportError:
|
|
||||||
logger.error("dingtalk-stream is not installed. Install it with: uv add dingtalk-stream")
|
|
||||||
return
|
|
||||||
|
|
||||||
client_id = self.config.get("client_id", "")
|
|
||||||
client_secret = self.config.get("client_secret", "")
|
|
||||||
|
|
||||||
if not client_id or not client_secret:
|
|
||||||
logger.error("DingTalk channel requires client_id and client_secret")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._client_id = client_id
|
|
||||||
self._client_secret = client_secret
|
|
||||||
self._main_loop = asyncio.get_running_loop()
|
|
||||||
|
|
||||||
if self._card_template_id:
|
|
||||||
logger.info("[DingTalk] AI Card mode enabled (template=%s)", self._card_template_id)
|
|
||||||
|
|
||||||
self._running = True
|
|
||||||
self.bus.subscribe_outbound(self._on_outbound)
|
|
||||||
|
|
||||||
self._thread = threading.Thread(
|
|
||||||
target=self._run_stream,
|
|
||||||
args=(client_id, client_secret),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
self._thread.start()
|
|
||||||
logger.info("DingTalk channel started")
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
self._running = False
|
|
||||||
self.bus.unsubscribe_outbound(self._on_outbound)
|
|
||||||
|
|
||||||
stream_client = self._stream_client
|
|
||||||
if stream_client is not None:
|
|
||||||
try:
|
|
||||||
if hasattr(stream_client, "disconnect"):
|
|
||||||
stream_client.disconnect()
|
|
||||||
except Exception:
|
|
||||||
logger.debug("[DingTalk] error disconnecting stream client", exc_info=True)
|
|
||||||
|
|
||||||
self._dingtalk_client = None
|
|
||||||
self._stream_client = None
|
|
||||||
with self._incoming_messages_lock:
|
|
||||||
self._incoming_messages.clear()
|
|
||||||
self._card_repliers.clear()
|
|
||||||
self._card_track_ids.clear()
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=5)
|
|
||||||
self._thread = None
|
|
||||||
logger.info("DingTalk channel stopped")
|
|
||||||
|
|
||||||
def _resolve_routing(self, msg: OutboundMessage) -> tuple[str, str, str]:
|
|
||||||
"""Return (conversation_type, sender_staff_id, conversation_id).
|
|
||||||
|
|
||||||
Uses msg.chat_id as the primary routing key; metadata as fallback.
|
|
||||||
"""
|
|
||||||
conversation_type = _normalize_conversation_type(msg.metadata.get("conversation_type"))
|
|
||||||
sender_staff_id = msg.metadata.get("sender_staff_id", "")
|
|
||||||
conversation_id = msg.metadata.get("conversation_id", "")
|
|
||||||
if conversation_type == _CONVERSATION_TYPE_GROUP:
|
|
||||||
conversation_id = msg.chat_id or conversation_id
|
|
||||||
else:
|
|
||||||
sender_staff_id = msg.chat_id or sender_staff_id
|
|
||||||
return conversation_type, sender_staff_id, conversation_id
|
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None:
|
|
||||||
conversation_type, sender_staff_id, conversation_id = self._resolve_routing(msg)
|
|
||||||
robot_code = self._client_id
|
|
||||||
|
|
||||||
# Card mode: stream update to existing AI card
|
|
||||||
source_key = self._make_card_source_key_from_outbound(msg)
|
|
||||||
out_track_id = self._card_track_ids.get(source_key)
|
|
||||||
|
|
||||||
# ``card_template_id`` enables ``runs.stream`` (non-final + final outbounds).
|
|
||||||
# If card creation failed, skip non-final chunks to avoid duplicate messages.
|
|
||||||
if self._card_template_id and not out_track_id and not msg.is_final:
|
|
||||||
return
|
|
||||||
|
|
||||||
if out_track_id:
|
|
||||||
try:
|
|
||||||
await self._stream_update_card(
|
|
||||||
out_track_id,
|
|
||||||
msg.text,
|
|
||||||
is_finalize=msg.is_final,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("[DingTalk] card stream failed, falling back to sampleMarkdown")
|
|
||||||
if msg.is_final:
|
|
||||||
self._card_track_ids.pop(source_key, None)
|
|
||||||
self._card_repliers.pop(out_track_id, None)
|
|
||||||
await self._send_markdown_fallback(robot_code, conversation_type, sender_staff_id, conversation_id, msg.text)
|
|
||||||
return
|
|
||||||
if msg.is_final:
|
|
||||||
self._card_track_ids.pop(source_key, None)
|
|
||||||
self._card_repliers.pop(out_track_id, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Non-card mode: send sampleMarkdown with retry
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
for attempt in range(_max_retries):
|
|
||||||
try:
|
|
||||||
if conversation_type == _CONVERSATION_TYPE_GROUP:
|
|
||||||
await self._send_group_message(robot_code, conversation_id, msg.text, at_user_ids=[sender_staff_id] if sender_staff_id else None)
|
|
||||||
else:
|
|
||||||
await self._send_p2p_message(robot_code, sender_staff_id, msg.text)
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
|
||||||
last_exc = exc
|
|
||||||
if attempt < _max_retries - 1:
|
|
||||||
delay = 2**attempt
|
|
||||||
logger.warning(
|
|
||||||
"[DingTalk] send failed (attempt %d/%d), retrying in %ds: %s",
|
|
||||||
attempt + 1,
|
|
||||||
_max_retries,
|
|
||||||
delay,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
|
|
||||||
logger.error("[DingTalk] send failed after %d attempts: %s", _max_retries, last_exc)
|
|
||||||
if last_exc is None:
|
|
||||||
raise RuntimeError("DingTalk send failed without an exception from any attempt")
|
|
||||||
raise last_exc
|
|
||||||
|
|
||||||
async def _send_markdown_fallback(
|
|
||||||
self,
|
|
||||||
robot_code: str,
|
|
||||||
conversation_type: str,
|
|
||||||
sender_staff_id: str,
|
|
||||||
conversation_id: str,
|
|
||||||
text: str,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
if conversation_type == _CONVERSATION_TYPE_GROUP:
|
|
||||||
await self._send_group_message(robot_code, conversation_id, text)
|
|
||||||
else:
|
|
||||||
await self._send_p2p_message(robot_code, sender_staff_id, text)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[DingTalk] markdown fallback also failed")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
|
|
||||||
if attachment.size > _MAX_UPLOAD_SIZE_BYTES:
|
|
||||||
logger.warning("[DingTalk] file too large (%d bytes), skipping: %s", attachment.size, attachment.filename)
|
|
||||||
return False
|
|
||||||
|
|
||||||
conversation_type, sender_staff_id, conversation_id = self._resolve_routing(msg)
|
|
||||||
robot_code = self._client_id
|
|
||||||
|
|
||||||
try:
|
|
||||||
media_id = await self._upload_media(attachment.actual_path, "image" if attachment.is_image else "file")
|
|
||||||
if not media_id:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if attachment.is_image:
|
|
||||||
msg_key = "sampleImageMsg"
|
|
||||||
msg_param = json.dumps({"photoURL": media_id})
|
|
||||||
else:
|
|
||||||
msg_key = "sampleFile"
|
|
||||||
msg_param = json.dumps(
|
|
||||||
{
|
|
||||||
"fileUrl": media_id,
|
|
||||||
"fileName": attachment.filename,
|
|
||||||
"fileSize": str(attachment.size),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
token = await self._get_access_token()
|
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
||||||
if conversation_type == _CONVERSATION_TYPE_GROUP:
|
|
||||||
response = await client.post(
|
|
||||||
f"{DINGTALK_API_BASE}/v1.0/robot/groupMessages/send",
|
|
||||||
headers=self._api_headers(token),
|
|
||||||
json={
|
|
||||||
"msgKey": msg_key,
|
|
||||||
"msgParam": msg_param,
|
|
||||||
"robotCode": robot_code,
|
|
||||||
"openConversationId": conversation_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
response = await client.post(
|
|
||||||
f"{DINGTALK_API_BASE}/v1.0/robot/oToMessages/batchSend",
|
|
||||||
headers=self._api_headers(token),
|
|
||||||
json={
|
|
||||||
"msgKey": msg_key,
|
|
||||||
"msgParam": msg_param,
|
|
||||||
"robotCode": robot_code,
|
|
||||||
"userIds": [sender_staff_id],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
logger.info("[DingTalk] file sent: %s", attachment.filename)
|
|
||||||
return True
|
|
||||||
except (httpx.HTTPError, OSError, ValueError, TypeError, AttributeError):
|
|
||||||
logger.exception("[DingTalk] failed to send file: %s", attachment.filename)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# -- stream client (runs in dedicated thread) --------------------------
|
|
||||||
|
|
||||||
def _run_stream(self, client_id: str, client_secret: str) -> None:
|
|
||||||
try:
|
|
||||||
import dingtalk_stream
|
|
||||||
|
|
||||||
credential = dingtalk_stream.Credential(client_id, client_secret)
|
|
||||||
client = dingtalk_stream.DingTalkStreamClient(credential)
|
|
||||||
self._stream_client = client
|
|
||||||
client.register_callback_handler(
|
|
||||||
dingtalk_stream.chatbot.ChatbotMessage.TOPIC,
|
|
||||||
_DingTalkMessageHandler(self),
|
|
||||||
)
|
|
||||||
client.start_forever()
|
|
||||||
except Exception:
|
|
||||||
if self._running:
|
|
||||||
logger.exception("DingTalk Stream Push error")
|
|
||||||
finally:
|
|
||||||
self._stream_client = None
|
|
||||||
|
|
||||||
def _on_chatbot_message(self, message: Any) -> None:
|
|
||||||
if not self._running:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
sender_staff_id = message.sender_staff_id or ""
|
|
||||||
conversation_type = _normalize_conversation_type(message.conversation_type)
|
|
||||||
conversation_id = message.conversation_id or ""
|
|
||||||
msg_id = message.message_id or ""
|
|
||||||
sender_nick = message.sender_nick or ""
|
|
||||||
|
|
||||||
if self._allowed_users and sender_staff_id not in self._allowed_users:
|
|
||||||
logger.debug("[DingTalk] ignoring message from non-allowed user: %s", sender_staff_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
text = self._extract_text(message)
|
|
||||||
if not text:
|
|
||||||
logger.info("[DingTalk] empty text, ignoring message")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[DingTalk] parsed message: conv_type=%s, msg_id=%s, sender=%s(%s), text=%r",
|
|
||||||
conversation_type,
|
|
||||||
msg_id,
|
|
||||||
sender_staff_id,
|
|
||||||
sender_nick,
|
|
||||||
text[:100],
|
|
||||||
)
|
|
||||||
|
|
||||||
connect_code = extract_connect_code(text)
|
|
||||||
if connect_code and self._connection_repo is not None:
|
|
||||||
if self._main_loop and self._main_loop.is_running():
|
|
||||||
fut = asyncio.run_coroutine_threadsafe(
|
|
||||||
self._bind_connection_from_connect_code(
|
|
||||||
conversation_type=conversation_type,
|
|
||||||
sender_staff_id=sender_staff_id,
|
|
||||||
sender_nick=sender_nick,
|
|
||||||
conversation_id=conversation_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("[DingTalk] main loop not running, cannot bind channel connection")
|
|
||||||
return
|
|
||||||
|
|
||||||
if _is_dingtalk_command(text):
|
|
||||||
msg_type = InboundMessageType.COMMAND
|
|
||||||
else:
|
|
||||||
msg_type = InboundMessageType.CHAT
|
|
||||||
|
|
||||||
# P2P: topic_id=None (single thread per user, like Telegram private chat)
|
|
||||||
# Group: topic_id=msg_id (each new message starts a new topic, like Feishu)
|
|
||||||
topic_id: str | None = msg_id if conversation_type == _CONVERSATION_TYPE_GROUP else None
|
|
||||||
|
|
||||||
# chat_id uses conversation_id for groups, sender_staff_id for P2P
|
|
||||||
chat_id = conversation_id if conversation_type == _CONVERSATION_TYPE_GROUP else sender_staff_id
|
|
||||||
|
|
||||||
inbound = self._make_inbound(
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=sender_staff_id,
|
|
||||||
text=text,
|
|
||||||
msg_type=msg_type,
|
|
||||||
thread_ts=msg_id,
|
|
||||||
metadata={
|
|
||||||
"conversation_type": conversation_type,
|
|
||||||
"conversation_id": conversation_id,
|
|
||||||
"sender_staff_id": sender_staff_id,
|
|
||||||
"sender_nick": sender_nick,
|
|
||||||
"message_id": msg_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
inbound.topic_id = topic_id
|
|
||||||
|
|
||||||
if self._card_template_id:
|
|
||||||
source_key = self._make_card_source_key(inbound)
|
|
||||||
with self._incoming_messages_lock:
|
|
||||||
self._incoming_messages[source_key] = message
|
|
||||||
|
|
||||||
if self._main_loop and self._main_loop.is_running():
|
|
||||||
logger.info("[DingTalk] publishing inbound message to bus (type=%s, msg_id=%s)", msg_type.value, msg_id)
|
|
||||||
fut = asyncio.run_coroutine_threadsafe(
|
|
||||||
self._prepare_inbound(chat_id, inbound),
|
|
||||||
self._main_loop,
|
|
||||||
)
|
|
||||||
fut.add_done_callback(lambda f, mid=msg_id: self._log_future_error(f, "prepare_inbound", mid))
|
|
||||||
else:
|
|
||||||
logger.warning("[DingTalk] main loop not running, cannot publish inbound message")
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[DingTalk] error processing chatbot message")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_text(message: Any) -> str:
|
|
||||||
msg_type = message.message_type
|
|
||||||
if msg_type == "text" and message.text:
|
|
||||||
return message.text.content.strip()
|
|
||||||
if msg_type == "richText" and message.rich_text_content:
|
|
||||||
return _extract_text_from_rich_text(message.rich_text_content.rich_text_list).strip()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
async def _prepare_inbound(self, chat_id: str, inbound: InboundMessage) -> None:
|
|
||||||
inbound = await self._attach_connection_identity(inbound)
|
|
||||||
# Running reply must finish before publish_inbound so AI card tracks are
|
|
||||||
# registered before the manager emits streaming outbounds.
|
|
||||||
await self._send_running_reply(chat_id, inbound)
|
|
||||||
await self.bus.publish_inbound(inbound)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _connection_workspace_id(conversation_type: str, conversation_id: str) -> str | None:
|
|
||||||
if conversation_type == _CONVERSATION_TYPE_GROUP and conversation_id:
|
|
||||||
return conversation_id
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
|
|
||||||
conversation_type = str(inbound.metadata.get("conversation_type") or _CONVERSATION_TYPE_P2P)
|
|
||||||
conversation_id = str(inbound.metadata.get("conversation_id") or "")
|
|
||||||
return await attach_connection_identity(
|
|
||||||
inbound,
|
|
||||||
repo=self._connection_repo,
|
|
||||||
provider="dingtalk",
|
|
||||||
workspace_id=self._connection_workspace_id(conversation_type, conversation_id),
|
|
||||||
fallback_without_workspace=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _bind_connection_from_connect_code(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
conversation_type: str,
|
|
||||||
sender_staff_id: str,
|
|
||||||
sender_nick: str,
|
|
||||||
conversation_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="dingtalk", state=code)
|
|
||||||
if state is None:
|
|
||||||
await self._send_connection_reply(
|
|
||||||
conversation_type,
|
|
||||||
sender_staff_id,
|
|
||||||
conversation_id,
|
|
||||||
"DingTalk connection code is invalid or expired.",
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
if not sender_staff_id:
|
|
||||||
await self._send_connection_reply(
|
|
||||||
conversation_type,
|
|
||||||
sender_staff_id,
|
|
||||||
conversation_id,
|
|
||||||
"DingTalk connection could not be completed from this message.",
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
await self._connection_repo.upsert_connection(
|
|
||||||
owner_user_id=state["owner_user_id"],
|
|
||||||
provider="dingtalk",
|
|
||||||
external_account_id=sender_staff_id,
|
|
||||||
external_account_name=sender_nick or None,
|
|
||||||
workspace_id=self._connection_workspace_id(conversation_type, conversation_id),
|
|
||||||
metadata={
|
|
||||||
"conversation_type": conversation_type,
|
|
||||||
"conversation_id": conversation_id,
|
|
||||||
},
|
|
||||||
status="connected",
|
|
||||||
)
|
|
||||||
await self._send_connection_reply(
|
|
||||||
conversation_type,
|
|
||||||
sender_staff_id,
|
|
||||||
conversation_id,
|
|
||||||
"DingTalk connected to DeerFlow.",
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def _send_connection_reply(
|
|
||||||
self,
|
|
||||||
conversation_type: str,
|
|
||||||
sender_staff_id: str,
|
|
||||||
conversation_id: str,
|
|
||||||
text: str,
|
|
||||||
) -> None:
|
|
||||||
robot_code = self._client_id
|
|
||||||
if conversation_type == _CONVERSATION_TYPE_GROUP:
|
|
||||||
if conversation_id:
|
|
||||||
await self._send_text_message_to_group(robot_code, conversation_id, text)
|
|
||||||
return
|
|
||||||
if sender_staff_id:
|
|
||||||
await self._send_text_message_to_user(robot_code, sender_staff_id, text)
|
|
||||||
|
|
||||||
async def _send_running_reply(self, chat_id: str, inbound: InboundMessage) -> None:
|
|
||||||
conversation_type = inbound.metadata.get("conversation_type", _CONVERSATION_TYPE_P2P)
|
|
||||||
sender_staff_id = inbound.metadata.get("sender_staff_id", "")
|
|
||||||
conversation_id = inbound.metadata.get("conversation_id", "")
|
|
||||||
text = "\u23f3 Working on it..."
|
|
||||||
|
|
||||||
try:
|
|
||||||
if self._card_template_id:
|
|
||||||
source_key = self._make_card_source_key(inbound)
|
|
||||||
with self._incoming_messages_lock:
|
|
||||||
chatbot_message = self._incoming_messages.pop(source_key, None)
|
|
||||||
out_track_id = await self._create_and_deliver_card(
|
|
||||||
text,
|
|
||||||
chatbot_message=chatbot_message,
|
|
||||||
)
|
|
||||||
if out_track_id:
|
|
||||||
self._card_track_ids[source_key] = out_track_id
|
|
||||||
logger.info("[DingTalk] AI card running reply sent for chat=%s", chat_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
robot_code = self._client_id
|
|
||||||
if conversation_type == _CONVERSATION_TYPE_GROUP:
|
|
||||||
await self._send_text_message_to_group(robot_code, conversation_id, text)
|
|
||||||
else:
|
|
||||||
await self._send_text_message_to_user(robot_code, sender_staff_id, text)
|
|
||||||
logger.info("[DingTalk] 'Working on it...' reply sent for chat=%s", chat_id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[DingTalk] failed to send running reply for chat=%s", chat_id)
|
|
||||||
|
|
||||||
# -- DingTalk API helpers ----------------------------------------------
|
|
||||||
|
|
||||||
async def _get_access_token(self) -> str:
|
|
||||||
if self._cached_token and time.monotonic() < self._token_expires_at:
|
|
||||||
return self._cached_token
|
|
||||||
async with self._token_lock:
|
|
||||||
if self._cached_token and time.monotonic() < self._token_expires_at:
|
|
||||||
return self._cached_token
|
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{DINGTALK_API_BASE}/v1.0/oauth2/accessToken",
|
|
||||||
json={"appKey": self._client_id, "appSecret": self._client_secret}, # DingTalk API field names
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError(f"DingTalk access token response must be a JSON object, got {type(data).__name__}")
|
|
||||||
|
|
||||||
access_token = data.get("accessToken")
|
|
||||||
if not isinstance(access_token, str) or not access_token.strip():
|
|
||||||
raise ValueError("DingTalk access token response did not contain a usable accessToken")
|
|
||||||
|
|
||||||
raw_expires_in = data.get("expireIn", 7200)
|
|
||||||
try:
|
|
||||||
expires_in = int(raw_expires_in)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logger.warning("[DingTalk] invalid expireIn value %r, using default 7200s", raw_expires_in)
|
|
||||||
expires_in = 7200
|
|
||||||
|
|
||||||
self._cached_token = access_token.strip()
|
|
||||||
self._token_expires_at = time.monotonic() + expires_in - _TOKEN_REFRESH_MARGIN_SECONDS
|
|
||||||
return self._cached_token
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _api_headers(token: str) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
"x-acs-dingtalk-access-token": token,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _send_text_message_to_user(self, robot_code: str, user_id: str, text: str) -> None:
|
|
||||||
token = await self._get_access_token()
|
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{DINGTALK_API_BASE}/v1.0/robot/oToMessages/batchSend",
|
|
||||||
headers=self._api_headers(token),
|
|
||||||
json={
|
|
||||||
"msgKey": "sampleText",
|
|
||||||
"msgParam": json.dumps({"content": text}),
|
|
||||||
"robotCode": robot_code,
|
|
||||||
"userIds": [user_id],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async def _send_text_message_to_group(self, robot_code: str, conversation_id: str, text: str) -> None:
|
|
||||||
token = await self._get_access_token()
|
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{DINGTALK_API_BASE}/v1.0/robot/groupMessages/send",
|
|
||||||
headers=self._api_headers(token),
|
|
||||||
json={
|
|
||||||
"msgKey": "sampleText",
|
|
||||||
"msgParam": json.dumps({"content": text}),
|
|
||||||
"robotCode": robot_code,
|
|
||||||
"openConversationId": conversation_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async def _send_p2p_message(self, robot_code: str, user_id: str, text: str) -> None:
|
|
||||||
text = _adapt_markdown_for_dingtalk(text)
|
|
||||||
token = await self._get_access_token()
|
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{DINGTALK_API_BASE}/v1.0/robot/oToMessages/batchSend",
|
|
||||||
headers=self._api_headers(token),
|
|
||||||
json={
|
|
||||||
"msgKey": "sampleMarkdown",
|
|
||||||
"msgParam": json.dumps({"title": "DeerFlow", "text": text}),
|
|
||||||
"robotCode": robot_code,
|
|
||||||
"userIds": [user_id],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
if data.get("processQueryKey"):
|
|
||||||
logger.info("[DingTalk] P2P message sent to user=%s", user_id)
|
|
||||||
else:
|
|
||||||
logger.warning("[DingTalk] P2P send response: %s", data)
|
|
||||||
|
|
||||||
async def _send_group_message(
|
|
||||||
self,
|
|
||||||
robot_code: str,
|
|
||||||
conversation_id: str,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
at_user_ids: list[str] | None = None, # noqa: ARG002
|
|
||||||
) -> None:
|
|
||||||
# at_user_ids accepted for call-site compatibility but not passed to the API
|
|
||||||
# (sampleMarkdown does not support @mentions).
|
|
||||||
text = _adapt_markdown_for_dingtalk(text)
|
|
||||||
token = await self._get_access_token()
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{DINGTALK_API_BASE}/v1.0/robot/groupMessages/send",
|
|
||||||
headers=self._api_headers(token),
|
|
||||||
json={
|
|
||||||
"msgKey": "sampleMarkdown",
|
|
||||||
"msgParam": json.dumps({"title": "DeerFlow", "text": text}),
|
|
||||||
"robotCode": robot_code,
|
|
||||||
"openConversationId": conversation_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
if data.get("processQueryKey"):
|
|
||||||
logger.info("[DingTalk] group message sent to conversation=%s", conversation_id)
|
|
||||||
else:
|
|
||||||
logger.warning("[DingTalk] group send response: %s", data)
|
|
||||||
|
|
||||||
# -- AI Card streaming helpers -------------------------------------------
|
|
||||||
|
|
||||||
def _make_card_source_key(self, inbound: InboundMessage) -> str:
|
|
||||||
m = inbound.metadata
|
|
||||||
return f"{m.get('conversation_type', '')}:{m.get('sender_staff_id', '')}:{m.get('conversation_id', '')}:{m.get('message_id', '')}"
|
|
||||||
|
|
||||||
def _make_card_source_key_from_outbound(self, msg: OutboundMessage) -> str:
|
|
||||||
m = msg.metadata
|
|
||||||
correlation_id = m.get("message_id") or msg.thread_ts or ""
|
|
||||||
return f"{m.get('conversation_type', '')}:{m.get('sender_staff_id', '')}:{m.get('conversation_id', '')}:{correlation_id}"
|
|
||||||
|
|
||||||
async def _create_and_deliver_card(
|
|
||||||
self,
|
|
||||||
initial_text: str,
|
|
||||||
*,
|
|
||||||
chatbot_message: Any = None,
|
|
||||||
) -> str | None:
|
|
||||||
if self._dingtalk_client is None or chatbot_message is None:
|
|
||||||
logger.warning("[DingTalk] SDK client or chatbot_message unavailable, skipping AI card")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
from dingtalk_stream.card_replier import AICardReplier
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("[DingTalk] dingtalk-stream card_replier not available")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
replier = AICardReplier(self._dingtalk_client, chatbot_message)
|
|
||||||
card_instance_id = await replier.async_create_and_deliver_card(
|
|
||||||
card_template_id=self._card_template_id,
|
|
||||||
card_data={"content": initial_text},
|
|
||||||
)
|
|
||||||
if not card_instance_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
self._card_repliers[card_instance_id] = replier
|
|
||||||
logger.info("[DingTalk] AI card created: outTrackId=%s", card_instance_id)
|
|
||||||
return card_instance_id
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[DingTalk] failed to create AI card")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _stream_update_card(
|
|
||||||
self,
|
|
||||||
out_track_id: str,
|
|
||||||
content: str,
|
|
||||||
*,
|
|
||||||
is_finalize: bool = False,
|
|
||||||
is_error: bool = False,
|
|
||||||
) -> None:
|
|
||||||
replier = self._card_repliers.get(out_track_id)
|
|
||||||
if not replier:
|
|
||||||
raise RuntimeError(f"No AICardReplier found for track ID {out_track_id}")
|
|
||||||
|
|
||||||
await replier.async_streaming(
|
|
||||||
card_instance_id=out_track_id,
|
|
||||||
content_key="content",
|
|
||||||
content_value=content,
|
|
||||||
append=False,
|
|
||||||
finished=is_finalize,
|
|
||||||
failed=is_error,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- media upload --------------------------------------------------------
|
|
||||||
|
|
||||||
async def _upload_media(self, file_path: str | Path, media_type: str) -> str | None:
|
|
||||||
try:
|
|
||||||
file_bytes = await asyncio.to_thread(Path(file_path).read_bytes)
|
|
||||||
token = await self._get_access_token()
|
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{DINGTALK_API_BASE}/v1.0/files/upload",
|
|
||||||
headers={"x-acs-dingtalk-access-token": token},
|
|
||||||
files={"file": ("upload", file_bytes)},
|
|
||||||
data={"type": media_type},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
try:
|
|
||||||
payload = response.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.exception("[DingTalk] failed to decode upload response JSON: %s", file_path)
|
|
||||||
return None
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
logger.warning("[DingTalk] unexpected upload response type %s for %s", type(payload).__name__, file_path)
|
|
||||||
return None
|
|
||||||
return payload.get("mediaId")
|
|
||||||
except (httpx.HTTPError, OSError):
|
|
||||||
logger.exception("[DingTalk] failed to upload media: %s", file_path)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _log_future_error(fut: Any, name: str, msg_id: str) -> None:
|
|
||||||
try:
|
|
||||||
exc = fut.exception()
|
|
||||||
if exc:
|
|
||||||
logger.error("[DingTalk] %s failed for msg_id=%s: %s", name, msg_id, exc)
|
|
||||||
except (asyncio.CancelledError, asyncio.InvalidStateError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _DingTalkMessageHandler:
|
|
||||||
"""Callback handler registered with dingtalk-stream."""
|
|
||||||
|
|
||||||
def __init__(self, channel: DingTalkChannel) -> None:
|
|
||||||
self._channel = channel
|
|
||||||
|
|
||||||
def pre_start(self) -> None:
|
|
||||||
if hasattr(self, "dingtalk_client") and self.dingtalk_client is not None:
|
|
||||||
self._channel._dingtalk_client = self.dingtalk_client
|
|
||||||
|
|
||||||
async def raw_process(self, callback_message: Any) -> Any:
|
|
||||||
import dingtalk_stream
|
|
||||||
from dingtalk_stream.frames import Headers
|
|
||||||
|
|
||||||
code, message = await self.process(callback_message)
|
|
||||||
ack_message = dingtalk_stream.AckMessage()
|
|
||||||
ack_message.code = code
|
|
||||||
ack_message.headers.message_id = callback_message.headers.message_id
|
|
||||||
ack_message.headers.content_type = Headers.CONTENT_TYPE_APPLICATION_JSON
|
|
||||||
ack_message.data = {"response": message}
|
|
||||||
return ack_message
|
|
||||||
|
|
||||||
async def process(self, callback: Any) -> tuple[int, str]:
|
|
||||||
import dingtalk_stream
|
|
||||||
|
|
||||||
incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data)
|
|
||||||
self._channel._on_chatbot_message(incoming_message)
|
|
||||||
return dingtalk_stream.AckMessage.STATUS_OK, "OK"
|
|
||||||
@@ -1,620 +0,0 @@
|
|||||||
"""Discord channel integration using discord.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.channels.base import Channel
|
|
||||||
from app.channels.commands import extract_connect_code, is_known_channel_command
|
|
||||||
from app.channels.connection_identity import attach_connection_identity
|
|
||||||
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_DISCORD_MAX_MESSAGE_LEN = 2000
|
|
||||||
|
|
||||||
|
|
||||||
class DiscordChannel(Channel):
|
|
||||||
"""Discord bot channel.
|
|
||||||
|
|
||||||
Configuration keys (in ``config.yaml`` under ``channels.discord``):
|
|
||||||
- ``bot_token``: Discord Bot token.
|
|
||||||
- ``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:
|
|
||||||
super().__init__(name="discord", bus=bus, config=config)
|
|
||||||
self._bot_token = str(config.get("bot_token", "")).strip()
|
|
||||||
self._allowed_guilds: set[int] = set()
|
|
||||||
for guild_id in config.get("allowed_guilds", []):
|
|
||||||
try:
|
|
||||||
self._allowed_guilds.add(int(guild_id))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
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._thread: threading.Thread | None = None
|
|
||||||
self._discord_loop: asyncio.AbstractEventLoop | None = None
|
|
||||||
self._main_loop: asyncio.AbstractEventLoop | None = None
|
|
||||||
self._discord_module = None
|
|
||||||
self._connection_repo = config.get("connection_repo")
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
import discord
|
|
||||||
except ImportError:
|
|
||||||
logger.error("discord.py is not installed. Install it with: uv add discord.py")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self._bot_token:
|
|
||||||
logger.error("Discord channel requires bot_token")
|
|
||||||
return
|
|
||||||
|
|
||||||
intents = discord.Intents.default()
|
|
||||||
intents.messages = True
|
|
||||||
intents.guilds = True
|
|
||||||
intents.message_content = True
|
|
||||||
|
|
||||||
client = discord.Client(
|
|
||||||
intents=intents,
|
|
||||||
allowed_mentions=discord.AllowedMentions.none(),
|
|
||||||
)
|
|
||||||
self._client = client
|
|
||||||
self._discord_module = discord
|
|
||||||
self._main_loop = asyncio.get_event_loop()
|
|
||||||
|
|
||||||
@client.event
|
|
||||||
async def on_message(message) -> None:
|
|
||||||
await self._on_message(message)
|
|
||||||
|
|
||||||
self._running = True
|
|
||||||
self.bus.subscribe_outbound(self._on_outbound)
|
|
||||||
|
|
||||||
self._thread = threading.Thread(target=self._run_client, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
self._load_active_threads()
|
|
||||||
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 _save_thread(self, channel_id: str, thread_id: str) -> None:
|
|
||||||
"""Persist a Discord thread mapping to the dedicated JSON file."""
|
|
||||||
with self._thread_store_lock:
|
|
||||||
try:
|
|
||||||
data: dict[str, str] = {}
|
|
||||||
if self._thread_store_path.exists():
|
|
||||||
data = json.loads(self._thread_store_path.read_text())
|
|
||||||
old_id = data.get(channel_id)
|
|
||||||
data[channel_id] = thread_id
|
|
||||||
# Update reverse-lookup set
|
|
||||||
if old_id:
|
|
||||||
self._active_thread_ids.discard(old_id)
|
|
||||||
self._active_thread_ids.add(thread_id)
|
|
||||||
self._thread_store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._thread_store_path.write_text(json.dumps(data, indent=2))
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[Discord] failed to save thread mapping for channel %s", channel_id)
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
self._running = False
|
|
||||||
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():
|
|
||||||
close_future = asyncio.run_coroutine_threadsafe(self._client.close(), self._discord_loop)
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(asyncio.wrap_future(close_future), timeout=10)
|
|
||||||
except TimeoutError:
|
|
||||||
logger.warning("[Discord] client close timed out after 10s")
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[Discord] error while closing client")
|
|
||||||
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=10)
|
|
||||||
self._thread = None
|
|
||||||
|
|
||||||
self._client = None
|
|
||||||
self._discord_loop = None
|
|
||||||
self._discord_module = None
|
|
||||||
logger.info("Discord channel stopped")
|
|
||||||
|
|
||||||
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)
|
|
||||||
if target is None:
|
|
||||||
logger.error("[Discord] target not found for chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts)
|
|
||||||
return
|
|
||||||
|
|
||||||
text = msg.text or ""
|
|
||||||
for chunk in self._split_text(text):
|
|
||||||
send_future = asyncio.run_coroutine_threadsafe(target.send(chunk), self._discord_loop)
|
|
||||||
await asyncio.wrap_future(send_future)
|
|
||||||
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self._discord_module is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Keep the file handle open only for the duration of the upload: discord.py
|
|
||||||
# reads ``fp`` while ``target.send`` runs on ``_discord_loop``; once that
|
|
||||||
# future resolves the bytes are consumed, so closing here is safe and avoids
|
|
||||||
# leaking the handle on both the success and failure paths.
|
|
||||||
with open(str(attachment.actual_path), "rb") as fp:
|
|
||||||
file = self._discord_module.File(fp, filename=attachment.filename)
|
|
||||||
send_future = asyncio.run_coroutine_threadsafe(target.send(file=file), self._discord_loop)
|
|
||||||
await asyncio.wrap_future(send_future)
|
|
||||||
logger.info("[Discord] file uploaded: %s", attachment.filename)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[Discord] failed to upload file: %s", attachment.filename)
|
|
||||||
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:
|
|
||||||
if not self._running or not self._client:
|
|
||||||
return
|
|
||||||
|
|
||||||
if message.author.bot:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._client.user and message.author.id == self._client.user.id:
|
|
||||||
return
|
|
||||||
|
|
||||||
guild = message.guild
|
|
||||||
if self._allowed_guilds:
|
|
||||||
if guild is None or guild.id not in self._allowed_guilds:
|
|
||||||
return
|
|
||||||
|
|
||||||
text = (message.content or "").strip()
|
|
||||||
if not text:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._discord_module is None:
|
|
||||||
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 = extract_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):
|
|
||||||
# --- Message already inside a thread ---
|
|
||||||
thread_obj = message.channel
|
|
||||||
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._active_threads[channel_id] = target_thread_id
|
|
||||||
self._save_thread(channel_id, target_thread_id)
|
|
||||||
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._active_threads[channel_id] = target_thread_id
|
|
||||||
self._save_thread(channel_id, target_thread_id)
|
|
||||||
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._active_threads[channel_id] = target_thread_id
|
|
||||||
self._save_thread(channel_id, target_thread_id)
|
|
||||||
thread_id = target_thread_id
|
|
||||||
chat_id = channel_id
|
|
||||||
typing_target = thread_obj # Type into the new thread
|
|
||||||
else:
|
|
||||||
# No threading — reply directly in channel
|
|
||||||
thread_id = channel_id
|
|
||||||
chat_id = channel_id
|
|
||||||
typing_target = message.channel # Type into the channel
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 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():
|
|
||||||
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)
|
|
||||||
|
|
||||||
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:
|
|
||||||
self._discord_loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(self._discord_loop)
|
|
||||||
try:
|
|
||||||
self._discord_loop.run_until_complete(self._client.start(self._bot_token))
|
|
||||||
except Exception:
|
|
||||||
if self._running:
|
|
||||||
logger.exception("Discord client error")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
if self._client and not self._client.is_closed():
|
|
||||||
self._discord_loop.run_until_complete(self._client.close())
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Error during Discord shutdown")
|
|
||||||
|
|
||||||
async def _create_thread(self, message):
|
|
||||||
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]
|
|
||||||
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:
|
|
||||||
logger.exception("[Discord] failed to create thread for message=%s (threads may be disabled or missing permissions)", message.id)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _resolve_target(self, msg: OutboundMessage):
|
|
||||||
if not self._client or not self._discord_loop:
|
|
||||||
return None
|
|
||||||
|
|
||||||
target_ids: list[str] = []
|
|
||||||
if msg.thread_ts:
|
|
||||||
target_ids.append(msg.thread_ts)
|
|
||||||
if msg.chat_id and msg.chat_id not in target_ids:
|
|
||||||
target_ids.append(msg.chat_id)
|
|
||||||
|
|
||||||
for raw_id in target_ids:
|
|
||||||
target = await self._get_channel_or_thread(raw_id)
|
|
||||||
if target is not None:
|
|
||||||
return target
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _get_channel_or_thread(self, raw_id: str):
|
|
||||||
if not self._client or not self._discord_loop:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
target_id = int(raw_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
get_future = asyncio.run_coroutine_threadsafe(self._fetch_channel(target_id), self._discord_loop)
|
|
||||||
try:
|
|
||||||
return await asyncio.wrap_future(get_future)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[Discord] failed to resolve target id=%s", raw_id)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _fetch_channel(self, target_id: int):
|
|
||||||
if not self._client:
|
|
||||||
return None
|
|
||||||
|
|
||||||
channel = self._client.get_channel(target_id)
|
|
||||||
if channel is not None:
|
|
||||||
return channel
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await self._client.fetch_channel(target_id)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _split_text(text: str) -> list[str]:
|
|
||||||
if not text:
|
|
||||||
return [""]
|
|
||||||
|
|
||||||
chunks: list[str] = []
|
|
||||||
remaining = text
|
|
||||||
while len(remaining) > _DISCORD_MAX_MESSAGE_LEN:
|
|
||||||
split_at = remaining.rfind("\n", 0, _DISCORD_MAX_MESSAGE_LEN)
|
|
||||||
if split_at <= 0:
|
|
||||||
split_at = _DISCORD_MAX_MESSAGE_LEN
|
|
||||||
chunks.append(remaining[:split_at])
|
|
||||||
remaining = remaining[split_at:].lstrip("\n")
|
|
||||||
|
|
||||||
if remaining:
|
|
||||||
chunks.append(remaining)
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
+16
-275
@@ -7,31 +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 extract_connect_code, is_known_channel_command
|
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
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
@@ -65,45 +55,12 @@ 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._CreateFileRequest = None
|
self._CreateFileRequest = None
|
||||||
self._CreateFileRequestBody = None
|
self._CreateFileRequestBody = None
|
||||||
self._CreateImageRequest = None
|
self._CreateImageRequest = None
|
||||||
self._CreateImageRequestBody = None
|
self._CreateImageRequestBody = None
|
||||||
self._GetMessageResourceRequest = None
|
self._GetMessageResourceRequest = None
|
||||||
self._thread_lock = threading.Lock()
|
self._thread_lock = threading.Lock()
|
||||||
self._connection_repo = config.get("connection_repo")
|
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
@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:
|
||||||
@@ -198,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,
|
||||||
@@ -210,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
|
||||||
@@ -391,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()
|
||||||
user_id = get_effective_user_id()
|
paths.ensure_thread_dirs(thread_id)
|
||||||
paths.ensure_thread_dirs(thread_id, user_id=user_id)
|
uploads_dir = paths.sandbox_uploads_dir(thread_id).resolve()
|
||||||
uploads_dir = paths.sandbox_uploads_dir(thread_id, user_id=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}"
|
||||||
@@ -573,25 +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, msg.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(source_message_id, msg.text)
|
await self._reply_card(source_message_id, msg.text)
|
||||||
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(source_message_id, msg.text)
|
await self._ensure_running_card(source_message_id, msg.text)
|
||||||
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)
|
||||||
@@ -602,129 +547,6 @@ 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 _log_future_error(fut, name: str, msg_id: str) -> None:
|
def _log_future_error(fut, name: str, msg_id: str) -> None:
|
||||||
"""Callback for run_coroutine_threadsafe futures to surface errors."""
|
"""Callback for run_coroutine_threadsafe futures to surface errors."""
|
||||||
@@ -749,47 +571,11 @@ class FeishuChannel(Channel):
|
|||||||
|
|
||||||
async def _prepare_inbound(self, msg_id: str, inbound) -> 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_task = asyncio.create_task(self._add_reaction(msg_id, "OK"))
|
||||||
self._track_background_task(reaction_task, name="add_reaction", msg_id=msg_id)
|
self._track_background_task(reaction_task, name="add_reaction", msg_id=msg_id)
|
||||||
self._ensure_running_card_started(msg_id)
|
self._ensure_running_card_started(msg_id)
|
||||||
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:
|
||||||
@@ -801,9 +587,7 @@ class FeishuChannel(Channel):
|
|||||||
|
|
||||||
# root_id is set when the message is a reply within a Feishu thread.
|
# 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.
|
# Use it as topic_id so all replies share the same DeerFlow thread.
|
||||||
root_id = self._non_empty_str(getattr(message, "root_id", None))
|
root_id = getattr(message, "root_id", None) or 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)
|
||||||
@@ -864,12 +648,10 @@ 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, sender=%s, text=%r",
|
"[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,
|
|
||||||
sender_id,
|
sender_id,
|
||||||
text[:100] if text else "",
|
text[:100] if text else "",
|
||||||
)
|
)
|
||||||
@@ -878,23 +660,6 @@ class FeishuChannel(Channel):
|
|||||||
logger.info("[Feishu] empty text, ignoring message")
|
logger.info("[Feishu] empty text, ignoring message")
|
||||||
return
|
return
|
||||||
|
|
||||||
connect_code = extract_connect_code(text)
|
|
||||||
if connect_code and self._connection_repo is not None:
|
|
||||||
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.
|
||||||
if _is_feishu_command(text):
|
if _is_feishu_command(text):
|
||||||
@@ -902,24 +667,8 @@ class FeishuChannel(Channel):
|
|||||||
else:
|
else:
|
||||||
msg_type = InboundMessageType.CHAT
|
msg_type = InboundMessageType.CHAT
|
||||||
|
|
||||||
# Prefer any platform message id that already maps to a DeerFlow
|
# topic_id: use root_id for replies (same topic), msg_id for new messages (new topic)
|
||||||
# thread. This keeps replies to bot clarification cards in the
|
topic_id = root_id or msg_id
|
||||||
# original conversation even when Feishu reports the card as root.
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
inbound = self._make_inbound(
|
inbound = self._make_inbound(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
@@ -928,15 +677,7 @@ 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={"message_id": msg_id, "root_id": root_id},
|
||||||
"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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
inbound.topic_id = topic_id
|
inbound.topic_id = topic_id
|
||||||
|
|
||||||
|
|||||||
+58
-452
@@ -1,4 +1,4 @@
|
|||||||
"""ChannelManager — consumes inbound messages and dispatches them to the DeerFlow agent via Gateway."""
|
"""ChannelManager — consumes inbound messages and dispatches them to the DeerFlow agent via LangGraph Server."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,7 +8,6 @@ import mimetypes
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -16,28 +15,12 @@ import httpx
|
|||||||
from langgraph_sdk.errors import ConflictError
|
from langgraph_sdk.errors import ConflictError
|
||||||
|
|
||||||
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
||||||
from app.channels.message_bus import (
|
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||||
PENDING_CLARIFICATION_METADATA_KEY,
|
|
||||||
InboundMessage,
|
|
||||||
InboundMessageType,
|
|
||||||
MessageBus,
|
|
||||||
OutboundMessage,
|
|
||||||
ResolvedAttachment,
|
|
||||||
)
|
|
||||||
from app.channels.store import ChannelStore
|
from app.channels.store import ChannelStore
|
||||||
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
||||||
from app.gateway.internal_auth import create_internal_auth_headers
|
|
||||||
from deerflow.config.agents_config import load_agent_config
|
|
||||||
from deerflow.config.paths import make_safe_user_id
|
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
|
||||||
from deerflow.skills.slash import parse_slash_skill_reference
|
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
|
||||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
|
||||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_LANGGRAPH_URL = "http://localhost:8001/api"
|
DEFAULT_LANGGRAPH_URL = "http://localhost:2024"
|
||||||
DEFAULT_GATEWAY_URL = "http://localhost:8001"
|
DEFAULT_GATEWAY_URL = "http://localhost:8001"
|
||||||
DEFAULT_ASSISTANT_ID = "lead_agent"
|
DEFAULT_ASSISTANT_ID = "lead_agent"
|
||||||
CUSTOM_AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
CUSTOM_AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
||||||
@@ -49,32 +32,18 @@ DEFAULT_RUN_CONTEXT: dict[str, Any] = {
|
|||||||
"subagent_enabled": False,
|
"subagent_enabled": False,
|
||||||
}
|
}
|
||||||
STREAM_UPDATE_MIN_INTERVAL_SECONDS = 0.35
|
STREAM_UPDATE_MIN_INTERVAL_SECONDS = 0.35
|
||||||
# Stream modes requested from the runtime, and the SSE event names under which
|
|
||||||
# the message-tuple stream may arrive: the embedded runtime (and LangGraph
|
|
||||||
# Platform) deliver the requested "messages-tuple" mode as event "messages".
|
|
||||||
STREAM_MODES = ["messages-tuple", "values"]
|
|
||||||
MESSAGE_STREAM_EVENTS = ("messages-tuple", "messages")
|
|
||||||
THREAD_BUSY_MESSAGE = "This conversation is already processing another request. Please wait for it to finish and try again."
|
THREAD_BUSY_MESSAGE = "This conversation is already processing another request. Please wait for it to finish and try again."
|
||||||
|
|
||||||
CHANNEL_CAPABILITIES = {
|
CHANNEL_CAPABILITIES = {
|
||||||
"dingtalk": {"supports_streaming": False},
|
|
||||||
"discord": {"supports_streaming": False},
|
|
||||||
"feishu": {"supports_streaming": True},
|
"feishu": {"supports_streaming": True},
|
||||||
"slack": {"supports_streaming": False},
|
"slack": {"supports_streaming": False},
|
||||||
"telegram": {"supports_streaming": True},
|
"telegram": {"supports_streaming": False},
|
||||||
"wechat": {"supports_streaming": False},
|
"wechat": {"supports_streaming": False},
|
||||||
"wecom": {"supports_streaming": True},
|
"wecom": {"supports_streaming": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
InboundFileReader = Callable[[dict[str, Any], httpx.AsyncClient], Awaitable[bytes | None]]
|
InboundFileReader = Callable[[dict[str, Any], httpx.AsyncClient], Awaitable[bytes | None]]
|
||||||
|
|
||||||
_METADATA_DROP_KEYS = frozenset({"raw_message", "ref_msg"})
|
|
||||||
|
|
||||||
|
|
||||||
def _slim_metadata(meta: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Return a shallow copy of *meta* with known-large keys removed."""
|
|
||||||
return {k: v for k, v in meta.items() if k not in _METADATA_DROP_KEYS}
|
|
||||||
|
|
||||||
|
|
||||||
INBOUND_FILE_READERS: dict[str, InboundFileReader] = {}
|
INBOUND_FILE_READERS: dict[str, InboundFileReader] = {}
|
||||||
|
|
||||||
@@ -135,16 +104,6 @@ class InvalidChannelSessionConfigError(ValueError):
|
|||||||
"""Raised when IM channel session overrides contain invalid agent config."""
|
"""Raised when IM channel session overrides contain invalid agent config."""
|
||||||
|
|
||||||
|
|
||||||
class SlashSkillCommandResolutionError(RuntimeError):
|
|
||||||
"""Raised when IM slash-skill command resolution cannot complete safely."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _SlashSkillCommandResolution:
|
|
||||||
route_to_chat: bool = False
|
|
||||||
failure_message: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_thread_busy_error(exc: BaseException | None) -> bool:
|
def _is_thread_busy_error(exc: BaseException | None) -> bool:
|
||||||
if exc is None:
|
if exc is None:
|
||||||
return False
|
return False
|
||||||
@@ -184,6 +143,7 @@ def _extract_response_text(result: dict | list) -> str:
|
|||||||
Handles special cases:
|
Handles special cases:
|
||||||
- Regular AI text responses
|
- Regular AI text responses
|
||||||
- Clarification interrupts (``ask_clarification`` tool messages)
|
- Clarification interrupts (``ask_clarification`` tool messages)
|
||||||
|
- AI messages with tool_calls but no text content
|
||||||
"""
|
"""
|
||||||
if isinstance(result, list):
|
if isinstance(result, list):
|
||||||
messages = result
|
messages = result
|
||||||
@@ -202,8 +162,6 @@ def _extract_response_text(result: dict | list) -> str:
|
|||||||
|
|
||||||
# Stop at the last human message — anything before it is a previous turn
|
# Stop at the last human message — anything before it is a previous turn
|
||||||
if msg_type == "human":
|
if msg_type == "human":
|
||||||
if _is_hidden_human_control_message(msg):
|
|
||||||
continue
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# Check for tool messages from ask_clarification (interrupt case)
|
# Check for tool messages from ask_clarification (interrupt case)
|
||||||
@@ -231,70 +189,6 @@ def _extract_response_text(result: dict | list) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _messages_from_result(result: dict | list) -> list[Any]:
|
|
||||||
if isinstance(result, list):
|
|
||||||
return result
|
|
||||||
if isinstance(result, dict):
|
|
||||||
messages = result.get("messages", [])
|
|
||||||
if isinstance(messages, list):
|
|
||||||
return messages
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _current_turn_messages(result: dict | list) -> list[dict[str, Any]]:
|
|
||||||
messages = _messages_from_result(result)
|
|
||||||
current_turn: list[dict[str, Any]] = []
|
|
||||||
for msg in reversed(messages):
|
|
||||||
if not isinstance(msg, dict):
|
|
||||||
continue
|
|
||||||
if msg.get("type") == "human":
|
|
||||||
break
|
|
||||||
current_turn.append(msg)
|
|
||||||
current_turn.reverse()
|
|
||||||
return current_turn
|
|
||||||
|
|
||||||
|
|
||||||
def _has_current_turn_clarification(result: dict | list) -> bool:
|
|
||||||
"""Return True only when the current turn's final result is clarification."""
|
|
||||||
for msg in reversed(_current_turn_messages(result)):
|
|
||||||
msg_type = msg.get("type")
|
|
||||||
if msg_type == "tool":
|
|
||||||
return msg.get("name") == "ask_clarification"
|
|
||||||
if msg_type == "ai":
|
|
||||||
content = msg.get("content")
|
|
||||||
if isinstance(content, str):
|
|
||||||
if content:
|
|
||||||
return False
|
|
||||||
elif content:
|
|
||||||
return False
|
|
||||||
if msg.get("tool_calls"):
|
|
||||||
return False
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _response_metadata(base_metadata: dict[str, Any], *, pending_clarification: bool = False) -> dict[str, Any]:
|
|
||||||
metadata = _slim_metadata(base_metadata)
|
|
||||||
if pending_clarification:
|
|
||||||
metadata[PENDING_CLARIFICATION_METADATA_KEY] = True
|
|
||||||
return metadata
|
|
||||||
|
|
||||||
|
|
||||||
def _thread_channel_metadata(msg: InboundMessage) -> dict[str, Any]:
|
|
||||||
channel_source: dict[str, Any] = {
|
|
||||||
"type": "im_channel",
|
|
||||||
"provider": msg.channel_name,
|
|
||||||
"chat_id": msg.chat_id,
|
|
||||||
}
|
|
||||||
if msg.topic_id:
|
|
||||||
channel_source["topic_id"] = msg.topic_id
|
|
||||||
if msg.thread_ts:
|
|
||||||
channel_source["thread_ts"] = msg.thread_ts
|
|
||||||
if msg.connection_id:
|
|
||||||
channel_source["connection_id"] = msg.connection_id
|
|
||||||
|
|
||||||
return {"channel_source": channel_source}
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_text_content(content: Any) -> str:
|
def _extract_text_content(content: Any) -> str:
|
||||||
"""Extract text from a streaming payload content field."""
|
"""Extract text from a streaming payload content field."""
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
@@ -408,8 +302,6 @@ def _extract_artifacts(result: dict | list) -> list[str]:
|
|||||||
continue
|
continue
|
||||||
# Stop at the last human message — anything before it is a previous turn
|
# Stop at the last human message — anything before it is a previous turn
|
||||||
if msg.get("type") == "human":
|
if msg.get("type") == "human":
|
||||||
if _is_hidden_human_control_message(msg):
|
|
||||||
continue
|
|
||||||
break
|
break
|
||||||
# Look for AI messages with present_files tool calls
|
# Look for AI messages with present_files tool calls
|
||||||
if msg.get("type") == "ai":
|
if msg.get("type") == "ai":
|
||||||
@@ -422,18 +314,6 @@ def _extract_artifacts(result: dict | list) -> list[str]:
|
|||||||
return artifacts
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
def _is_hidden_human_control_message(msg: Mapping[str, Any]) -> bool:
|
|
||||||
"""Return whether a human message is an internal control message hidden from UI."""
|
|
||||||
if msg.get("type") != "human":
|
|
||||||
return False
|
|
||||||
|
|
||||||
additional_kwargs = msg.get("additional_kwargs")
|
|
||||||
if not isinstance(additional_kwargs, Mapping):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return additional_kwargs.get("hide_from_ui") is True
|
|
||||||
|
|
||||||
|
|
||||||
def _format_artifact_text(artifacts: list[str]) -> str:
|
def _format_artifact_text(artifacts: list[str]) -> str:
|
||||||
"""Format artifact paths into a human-readable text block listing filenames."""
|
"""Format artifact paths into a human-readable text block listing filenames."""
|
||||||
import posixpath
|
import posixpath
|
||||||
@@ -447,83 +327,6 @@ def _format_artifact_text(artifacts: list[str]) -> str:
|
|||||||
_OUTPUTS_VIRTUAL_PREFIX = "/mnt/user-data/outputs/"
|
_OUTPUTS_VIRTUAL_PREFIX = "/mnt/user-data/outputs/"
|
||||||
|
|
||||||
|
|
||||||
def _unknown_command_reply(command: str | None = None) -> str:
|
|
||||||
available = " | ".join(sorted(KNOWN_CHANNEL_COMMANDS))
|
|
||||||
if command:
|
|
||||||
return f"Unknown command: /{command}. Available commands: {available}"
|
|
||||||
return f"Unknown command. Available commands: {available}"
|
|
||||||
|
|
||||||
|
|
||||||
def _human_input_message(content: str, *, original_content: str | None = None) -> dict[str, Any]:
|
|
||||||
message: dict[str, Any] = {"role": "human", "content": content}
|
|
||||||
if original_content is not None and original_content != content:
|
|
||||||
message["additional_kwargs"] = {ORIGINAL_USER_CONTENT_KEY: original_content}
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
def _auth_disabled_owner_user_id() -> str | None:
|
|
||||||
try:
|
|
||||||
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, is_auth_disabled
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Unable to inspect auth-disabled mode for channel owner fallback", exc_info=True)
|
|
||||||
return None
|
|
||||||
return AUTH_DISABLED_USER_ID if is_auth_disabled() else None
|
|
||||||
|
|
||||||
|
|
||||||
def _effective_owner_user_id(msg: InboundMessage) -> str | None:
|
|
||||||
return _auth_disabled_owner_user_id() or msg.owner_user_id
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_effective_owner(msg: InboundMessage) -> InboundMessage:
|
|
||||||
owner_user_id = _effective_owner_user_id(msg)
|
|
||||||
if owner_user_id:
|
|
||||||
msg.owner_user_id = owner_user_id
|
|
||||||
return msg
|
|
||||||
|
|
||||||
|
|
||||||
def _owner_headers(msg: InboundMessage) -> dict[str, str] | None:
|
|
||||||
owner_user_id = _effective_owner_user_id(msg)
|
|
||||||
if not owner_user_id:
|
|
||||||
return None
|
|
||||||
return create_internal_auth_headers(owner_user_id=owner_user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_user_id_for_run(raw_user_id: str) -> str:
|
|
||||||
from deerflow.config.paths import get_paths
|
|
||||||
|
|
||||||
try:
|
|
||||||
return get_paths().prepare_user_dir_for_raw_id(raw_user_id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to prepare channel run user directory")
|
|
||||||
return make_safe_user_id(raw_user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_slash_skill_command(
|
|
||||||
text: str,
|
|
||||||
available_skills: set[str] | None = None,
|
|
||||||
storage: SkillStorage | Callable[[], SkillStorage] | None = None,
|
|
||||||
) -> _SlashSkillCommandResolution | None:
|
|
||||||
reference = parse_slash_skill_reference(text)
|
|
||||||
if reference is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
resolved_storage = storage() if callable(storage) else storage or get_or_new_skill_storage()
|
|
||||||
skills = resolved_storage.load_skills(enabled_only=False)
|
|
||||||
|
|
||||||
skill = next((candidate for candidate in skills if candidate.name == reference.name), None)
|
|
||||||
if skill is None:
|
|
||||||
return None
|
|
||||||
if not skill.enabled:
|
|
||||||
return _SlashSkillCommandResolution(failure_message=f"Skill `/{reference.name}` is installed but disabled. Enable it before using slash activation.")
|
|
||||||
if available_skills is not None and reference.name not in available_skills:
|
|
||||||
return _SlashSkillCommandResolution(failure_message=f"Skill `/{reference.name}` is not available for this agent.")
|
|
||||||
|
|
||||||
return _SlashSkillCommandResolution(route_to_chat=True)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("[Manager] failed to resolve slash skill command")
|
|
||||||
raise SlashSkillCommandResolutionError("Failed to resolve slash skill command. Please check the skill configuration.") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_attachments(thread_id: str, artifacts: list[str]) -> list[ResolvedAttachment]:
|
def _resolve_attachments(thread_id: str, artifacts: list[str]) -> list[ResolvedAttachment]:
|
||||||
"""Resolve virtual artifact paths to host filesystem paths with metadata.
|
"""Resolve virtual artifact paths to host filesystem paths with metadata.
|
||||||
|
|
||||||
@@ -538,15 +341,14 @@ def _resolve_attachments(thread_id: str, artifacts: list[str]) -> list[ResolvedA
|
|||||||
|
|
||||||
attachments: list[ResolvedAttachment] = []
|
attachments: list[ResolvedAttachment] = []
|
||||||
paths = get_paths()
|
paths = get_paths()
|
||||||
user_id = get_effective_user_id()
|
outputs_dir = paths.sandbox_outputs_dir(thread_id).resolve()
|
||||||
outputs_dir = paths.sandbox_outputs_dir(thread_id, user_id=user_id).resolve()
|
|
||||||
for virtual_path in artifacts:
|
for virtual_path in artifacts:
|
||||||
# Security: only allow files from the agent outputs directory
|
# Security: only allow files from the agent outputs directory
|
||||||
if not virtual_path.startswith(_OUTPUTS_VIRTUAL_PREFIX):
|
if not virtual_path.startswith(_OUTPUTS_VIRTUAL_PREFIX):
|
||||||
logger.warning("[Manager] rejected non-outputs artifact path: %s", virtual_path)
|
logger.warning("[Manager] rejected non-outputs artifact path: %s", virtual_path)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
actual = paths.resolve_virtual_path(thread_id, virtual_path, user_id=user_id)
|
actual = paths.resolve_virtual_path(thread_id, virtual_path)
|
||||||
# Verify the resolved path is actually under the outputs directory
|
# Verify the resolved path is actually under the outputs directory
|
||||||
# (guards against path-traversal even after prefix check)
|
# (guards against path-traversal even after prefix check)
|
||||||
try:
|
try:
|
||||||
@@ -605,22 +407,10 @@ async def _ingest_inbound_files(thread_id: str, msg: InboundMessage) -> list[dic
|
|||||||
if not msg.files:
|
if not msg.files:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
from deerflow.uploads.manager import (
|
from deerflow.uploads.manager import claim_unique_filename, ensure_uploads_dir, normalize_filename
|
||||||
UnsafeUploadPathError,
|
|
||||||
claim_unique_filename,
|
|
||||||
ensure_uploads_dir,
|
|
||||||
normalize_filename,
|
|
||||||
write_upload_file_no_symlink,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _prepare_uploads_dir() -> tuple[Path, set[str]]:
|
uploads_dir = ensure_uploads_dir(thread_id)
|
||||||
# Worker thread: ensure_uploads_dir's mkdir and the iterdir enumeration are
|
seen_names = {entry.name for entry in uploads_dir.iterdir() if entry.is_file()}
|
||||||
# blocking filesystem IO that must stay off the event loop.
|
|
||||||
target = ensure_uploads_dir(thread_id)
|
|
||||||
existing = {entry.name for entry in target.iterdir() if entry.is_file()}
|
|
||||||
return target, existing
|
|
||||||
|
|
||||||
uploads_dir, seen_names = await asyncio.to_thread(_prepare_uploads_dir)
|
|
||||||
|
|
||||||
created: list[dict[str, Any]] = []
|
created: list[dict[str, Any]] = []
|
||||||
file_reader = INBOUND_FILE_READERS.get(msg.channel_name, _read_http_inbound_file)
|
file_reader = INBOUND_FILE_READERS.get(msg.channel_name, _read_http_inbound_file)
|
||||||
@@ -668,10 +458,7 @@ async def _ingest_inbound_files(thread_id: str, msg: InboundMessage) -> list[dic
|
|||||||
|
|
||||||
dest = uploads_dir / safe_name
|
dest = uploads_dir / safe_name
|
||||||
try:
|
try:
|
||||||
dest = await asyncio.to_thread(write_upload_file_no_symlink, uploads_dir, safe_name, data)
|
dest.write_bytes(data)
|
||||||
except UnsafeUploadPathError:
|
|
||||||
logger.warning("[Manager] skipping inbound file with unsafe destination: %s", safe_name)
|
|
||||||
continue
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("[Manager] failed to write inbound file: %s", dest)
|
logger.exception("[Manager] failed to write inbound file: %s", dest)
|
||||||
continue
|
continue
|
||||||
@@ -719,7 +506,7 @@ class ChannelManager:
|
|||||||
"""Core dispatcher that bridges IM channels to the DeerFlow agent.
|
"""Core dispatcher that bridges IM channels to the DeerFlow agent.
|
||||||
|
|
||||||
It reads from the MessageBus inbound queue, creates/reuses threads on
|
It reads from the MessageBus inbound queue, creates/reuses threads on
|
||||||
Gateway's LangGraph-compatible API, sends messages via ``runs.wait``, and publishes
|
the LangGraph Server, sends messages via ``runs.wait``, and publishes
|
||||||
outbound responses back through the bus.
|
outbound responses back through the bus.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -734,7 +521,6 @@ class ChannelManager:
|
|||||||
assistant_id: str = DEFAULT_ASSISTANT_ID,
|
assistant_id: str = DEFAULT_ASSISTANT_ID,
|
||||||
default_session: dict[str, Any] | None = None,
|
default_session: dict[str, Any] | None = None,
|
||||||
channel_sessions: dict[str, Any] | None = None,
|
channel_sessions: dict[str, Any] | None = None,
|
||||||
connection_repo: Any | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.store = store
|
self.store = store
|
||||||
@@ -744,24 +530,13 @@ class ChannelManager:
|
|||||||
self._assistant_id = assistant_id
|
self._assistant_id = assistant_id
|
||||||
self._default_session = _as_dict(default_session)
|
self._default_session = _as_dict(default_session)
|
||||||
self._channel_sessions = dict(channel_sessions or {})
|
self._channel_sessions = dict(channel_sessions or {})
|
||||||
self._connection_repo = connection_repo
|
|
||||||
self._client = None # lazy init — langgraph_sdk async client
|
self._client = None # lazy init — langgraph_sdk async client
|
||||||
self._channel_metadata_synced: set[str] = set()
|
|
||||||
self._skill_storage: SkillStorage | None = None
|
|
||||||
self._csrf_token = generate_csrf_token()
|
|
||||||
self._semaphore: asyncio.Semaphore | None = None
|
self._semaphore: asyncio.Semaphore | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _channel_supports_streaming(channel_name: str) -> bool:
|
def _channel_supports_streaming(channel_name: str) -> bool:
|
||||||
from .service import get_channel_service
|
|
||||||
|
|
||||||
service = get_channel_service()
|
|
||||||
if service:
|
|
||||||
channel = service.get_channel(channel_name)
|
|
||||||
if channel is not None:
|
|
||||||
return channel.supports_streaming
|
|
||||||
return CHANNEL_CAPABILITIES.get(channel_name, {}).get("supports_streaming", False)
|
return CHANNEL_CAPABILITIES.get(channel_name, {}).get("supports_streaming", False)
|
||||||
|
|
||||||
def _resolve_session_layer(self, msg: InboundMessage) -> tuple[dict[str, Any], dict[str, Any]]:
|
def _resolve_session_layer(self, msg: InboundMessage) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
@@ -784,36 +559,12 @@ class ChannelManager:
|
|||||||
user_layer.get("config"),
|
user_layer.get("config"),
|
||||||
)
|
)
|
||||||
|
|
||||||
configurable = run_config.get("configurable")
|
|
||||||
if isinstance(configurable, Mapping):
|
|
||||||
configurable = dict(configurable)
|
|
||||||
else:
|
|
||||||
configurable = {}
|
|
||||||
run_config["configurable"] = configurable
|
|
||||||
# Pin channel-triggered runs to the root graph namespace so follow-up
|
|
||||||
# turns continue from the same conversation checkpoint.
|
|
||||||
configurable["checkpoint_ns"] = ""
|
|
||||||
configurable["thread_id"] = thread_id
|
|
||||||
|
|
||||||
# ``user_id`` drives DeerFlow-owned memory, files, and thread buckets.
|
|
||||||
# For browser-connected IM channels, prefer the DeerFlow account that
|
|
||||||
# owns the connection. Preserve the raw platform user under
|
|
||||||
# ``channel_user_id`` for platform-facing lookups and audits.
|
|
||||||
run_context_identity: dict[str, Any] = {"thread_id": thread_id}
|
|
||||||
owner_user_id = _effective_owner_user_id(msg)
|
|
||||||
if owner_user_id:
|
|
||||||
run_context_identity["user_id"] = _safe_user_id_for_run(owner_user_id)
|
|
||||||
elif msg.user_id:
|
|
||||||
run_context_identity["user_id"] = _safe_user_id_for_run(msg.user_id)
|
|
||||||
if msg.user_id:
|
|
||||||
run_context_identity["channel_user_id"] = msg.user_id
|
|
||||||
|
|
||||||
run_context = _merge_dicts(
|
run_context = _merge_dicts(
|
||||||
DEFAULT_RUN_CONTEXT,
|
DEFAULT_RUN_CONTEXT,
|
||||||
self._default_session.get("context"),
|
self._default_session.get("context"),
|
||||||
channel_layer.get("context"),
|
channel_layer.get("context"),
|
||||||
user_layer.get("context"),
|
user_layer.get("context"),
|
||||||
run_context_identity,
|
{"thread_id": thread_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Custom agents are implemented as lead_agent + agent_name context.
|
# Custom agents are implemented as lead_agent + agent_name context.
|
||||||
@@ -825,21 +576,6 @@ class ChannelManager:
|
|||||||
|
|
||||||
return assistant_id, run_config, run_context
|
return assistant_id, run_config, run_context
|
||||||
|
|
||||||
def _resolve_available_skill_names(self, msg: InboundMessage) -> set[str] | None:
|
|
||||||
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or ""
|
|
||||||
_, _, run_context = self._resolve_run_params(msg, thread_id)
|
|
||||||
if run_context.get("is_bootstrap"):
|
|
||||||
return {"bootstrap"}
|
|
||||||
|
|
||||||
agent_name = run_context.get("agent_name")
|
|
||||||
if not isinstance(agent_name, str) or not agent_name.strip():
|
|
||||||
return None
|
|
||||||
|
|
||||||
agent_config = load_agent_config(_normalize_custom_agent_name(agent_name))
|
|
||||||
if agent_config and agent_config.skills is not None:
|
|
||||||
return set(agent_config.skills)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# -- LangGraph SDK client (lazy) ----------------------------------------
|
# -- LangGraph SDK client (lazy) ----------------------------------------
|
||||||
|
|
||||||
def _get_client(self):
|
def _get_client(self):
|
||||||
@@ -847,21 +583,9 @@ class ChannelManager:
|
|||||||
if self._client is None:
|
if self._client is None:
|
||||||
from langgraph_sdk import get_client
|
from langgraph_sdk import get_client
|
||||||
|
|
||||||
self._client = get_client(
|
self._client = get_client(url=self._langgraph_url)
|
||||||
url=self._langgraph_url,
|
|
||||||
headers={
|
|
||||||
**create_internal_auth_headers(),
|
|
||||||
CSRF_HEADER_NAME: self._csrf_token,
|
|
||||||
"Cookie": f"{CSRF_COOKIE_NAME}={self._csrf_token}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
def _get_skill_storage(self) -> SkillStorage:
|
|
||||||
if self._skill_storage is None:
|
|
||||||
self._skill_storage = get_or_new_skill_storage()
|
|
||||||
return self._skill_storage
|
|
||||||
|
|
||||||
# -- lifecycle ---------------------------------------------------------
|
# -- lifecycle ---------------------------------------------------------
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -917,7 +641,6 @@ class ChannelManager:
|
|||||||
logger.error("[Manager] unhandled error in message task: %s", exc, exc_info=exc)
|
logger.error("[Manager] unhandled error in message task: %s", exc, exc_info=exc)
|
||||||
|
|
||||||
async def _handle_message(self, msg: InboundMessage) -> None:
|
async def _handle_message(self, msg: InboundMessage) -> None:
|
||||||
msg = _apply_effective_owner(msg)
|
|
||||||
async with self._semaphore:
|
async with self._semaphore:
|
||||||
try:
|
try:
|
||||||
if msg.msg_type == InboundMessageType.COMMAND:
|
if msg.msg_type == InboundMessageType.COMMAND:
|
||||||
@@ -932,14 +655,6 @@ class ChannelManager:
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
await self._send_error(msg, str(exc))
|
await self._send_error(msg, str(exc))
|
||||||
except SlashSkillCommandResolutionError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Slash skill command resolution failed for %s (chat=%s): %s",
|
|
||||||
msg.channel_name,
|
|
||||||
msg.chat_id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
await self._send_error(msg, str(exc))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Error handling message from %s (chat=%s)",
|
"Error handling message from %s (chat=%s)",
|
||||||
@@ -950,27 +665,10 @@ class ChannelManager:
|
|||||||
|
|
||||||
# -- chat handling -----------------------------------------------------
|
# -- chat handling -----------------------------------------------------
|
||||||
|
|
||||||
async def _lookup_thread_id(self, msg: InboundMessage) -> str | None:
|
async def _create_thread(self, client, msg: InboundMessage) -> str:
|
||||||
if msg.connection_id and self._connection_repo is not None:
|
"""Create a new thread on the LangGraph Server and store the mapping."""
|
||||||
return await self._connection_repo.get_thread_id(
|
thread = await client.threads.create()
|
||||||
msg.connection_id,
|
thread_id = thread["thread_id"]
|
||||||
msg.chat_id,
|
|
||||||
msg.topic_id,
|
|
||||||
)
|
|
||||||
return self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
|
|
||||||
|
|
||||||
async def _store_thread_id(self, msg: InboundMessage, thread_id: str) -> None:
|
|
||||||
if msg.connection_id and msg.owner_user_id and self._connection_repo is not None:
|
|
||||||
await self._connection_repo.set_thread_id(
|
|
||||||
connection_id=msg.connection_id,
|
|
||||||
owner_user_id=msg.owner_user_id,
|
|
||||||
provider=msg.channel_name,
|
|
||||||
external_conversation_id=msg.chat_id,
|
|
||||||
external_topic_id=msg.topic_id,
|
|
||||||
thread_id=thread_id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.store.set_thread_id(
|
self.store.set_thread_id(
|
||||||
msg.channel_name,
|
msg.channel_name,
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
@@ -978,49 +676,18 @@ class ChannelManager:
|
|||||||
topic_id=msg.topic_id,
|
topic_id=msg.topic_id,
|
||||||
user_id=msg.user_id,
|
user_id=msg.user_id,
|
||||||
)
|
)
|
||||||
|
logger.info("[Manager] new thread created on LangGraph Server: thread_id=%s for chat_id=%s topic_id=%s", thread_id, msg.chat_id, msg.topic_id)
|
||||||
async def _create_thread(self, client, msg: InboundMessage) -> str:
|
|
||||||
"""Create a new thread through Gateway and store the mapping."""
|
|
||||||
metadata = _thread_channel_metadata(msg)
|
|
||||||
owner_headers = _owner_headers(msg)
|
|
||||||
if owner_headers:
|
|
||||||
thread = await client.threads.create(metadata=metadata, headers=owner_headers)
|
|
||||||
else:
|
|
||||||
thread = await client.threads.create(metadata=metadata)
|
|
||||||
thread_id = thread["thread_id"]
|
|
||||||
await self._store_thread_id(msg, thread_id)
|
|
||||||
logger.info("[Manager] new thread created through Gateway: thread_id=%s for chat_id=%s topic_id=%s", thread_id, msg.chat_id, msg.topic_id)
|
|
||||||
return thread_id
|
return thread_id
|
||||||
|
|
||||||
async def _update_thread_channel_metadata(self, client, msg: InboundMessage, thread_id: str) -> None:
|
|
||||||
"""Best-effort source metadata backfill for existing IM-created threads."""
|
|
||||||
# The metadata (provider/chat/topic) is constant for a thread, so one
|
|
||||||
# successful backfill per manager lifetime is enough — skip the
|
|
||||||
# redundant PATCH on every subsequent inbound message.
|
|
||||||
if thread_id in self._channel_metadata_synced:
|
|
||||||
return
|
|
||||||
update_kwargs: dict[str, Any] = {"metadata": _thread_channel_metadata(msg)}
|
|
||||||
if owner_headers := _owner_headers(msg):
|
|
||||||
update_kwargs["headers"] = owner_headers
|
|
||||||
try:
|
|
||||||
await client.threads.update(thread_id, **update_kwargs)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("[Manager] failed to update channel metadata for thread_id=%s", thread_id, exc_info=True)
|
|
||||||
return
|
|
||||||
if len(self._channel_metadata_synced) > 4096:
|
|
||||||
self._channel_metadata_synced.clear()
|
|
||||||
self._channel_metadata_synced.add(thread_id)
|
|
||||||
|
|
||||||
async def _handle_chat(self, msg: InboundMessage, extra_context: dict[str, Any] | None = None) -> None:
|
async def _handle_chat(self, msg: InboundMessage, extra_context: dict[str, Any] | None = None) -> None:
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
|
|
||||||
# Look up existing DeerFlow thread.
|
# Look up existing DeerFlow thread.
|
||||||
# topic_id may be None (e.g. Telegram private chats) — the store
|
# topic_id may be None (e.g. Telegram private chats) — the store
|
||||||
# handles this by using the "channel:chat_id" key without a topic suffix.
|
# handles this by using the "channel:chat_id" key without a topic suffix.
|
||||||
thread_id = await self._lookup_thread_id(msg)
|
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
|
||||||
if thread_id:
|
if thread_id:
|
||||||
logger.info("[Manager] reusing thread: thread_id=%s for topic_id=%s", thread_id, msg.topic_id)
|
logger.info("[Manager] reusing thread: thread_id=%s for topic_id=%s", thread_id, msg.topic_id)
|
||||||
await self._update_thread_channel_metadata(client, msg, thread_id)
|
|
||||||
|
|
||||||
# No existing thread found — create a new one
|
# No existing thread found — create a new one
|
||||||
if thread_id is None:
|
if thread_id is None:
|
||||||
@@ -1042,11 +709,9 @@ class ChannelManager:
|
|||||||
if extra_context:
|
if extra_context:
|
||||||
run_context.update(extra_context)
|
run_context.update(extra_context)
|
||||||
|
|
||||||
original_text = msg.text
|
|
||||||
uploaded = await _ingest_inbound_files(thread_id, msg)
|
uploaded = await _ingest_inbound_files(thread_id, msg)
|
||||||
if uploaded:
|
if uploaded:
|
||||||
msg.text = f"{_format_uploaded_files_block(uploaded)}\n\n{msg.text}".strip()
|
msg.text = f"{_format_uploaded_files_block(uploaded)}\n\n{msg.text}".strip()
|
||||||
human_message = _human_input_message(msg.text, original_content=original_text)
|
|
||||||
|
|
||||||
if self._channel_supports_streaming(msg.channel_name):
|
if self._channel_supports_streaming(msg.channel_name):
|
||||||
await self._handle_streaming_chat(
|
await self._handle_streaming_chat(
|
||||||
@@ -1056,35 +721,19 @@ class ChannelManager:
|
|||||||
assistant_id,
|
assistant_id,
|
||||||
run_config,
|
run_config,
|
||||||
run_context,
|
run_context,
|
||||||
human_message,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("[Manager] invoking runs.wait(thread_id=%s, text=%r)", thread_id, msg.text[:100])
|
logger.info("[Manager] invoking runs.wait(thread_id=%s, text=%r)", thread_id, msg.text[:100])
|
||||||
run_kwargs: dict[str, Any] = {
|
result = await client.runs.wait(
|
||||||
"input": {"messages": [human_message]},
|
thread_id,
|
||||||
"config": run_config,
|
assistant_id,
|
||||||
"context": run_context,
|
input={"messages": [{"role": "human", "content": msg.text}]},
|
||||||
"multitask_strategy": "reject",
|
config=run_config,
|
||||||
}
|
context=run_context,
|
||||||
if owner_headers := _owner_headers(msg):
|
)
|
||||||
run_kwargs["headers"] = owner_headers
|
|
||||||
try:
|
|
||||||
result = await client.runs.wait(
|
|
||||||
thread_id,
|
|
||||||
assistant_id,
|
|
||||||
**run_kwargs,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
if _is_thread_busy_error(exc):
|
|
||||||
logger.warning("[Manager] thread busy (concurrent run rejected): thread_id=%s", thread_id)
|
|
||||||
await self._send_error(msg, THREAD_BUSY_MESSAGE)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
response_text = _extract_response_text(result)
|
response_text = _extract_response_text(result)
|
||||||
pending_clarification = _has_current_turn_clarification(result)
|
|
||||||
artifacts = _extract_artifacts(result)
|
artifacts = _extract_artifacts(result)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1110,9 +759,6 @@ class ChannelManager:
|
|||||||
artifacts=artifacts,
|
artifacts=artifacts,
|
||||||
attachments=attachments,
|
attachments=attachments,
|
||||||
thread_ts=msg.thread_ts,
|
thread_ts=msg.thread_ts,
|
||||||
connection_id=msg.connection_id,
|
|
||||||
owner_user_id=msg.owner_user_id,
|
|
||||||
metadata=_response_metadata(msg.metadata, pending_clarification=pending_clarification),
|
|
||||||
)
|
)
|
||||||
logger.info("[Manager] publishing outbound message to bus: channel=%s, chat_id=%s", msg.channel_name, msg.chat_id)
|
logger.info("[Manager] publishing outbound message to bus: channel=%s, chat_id=%s", msg.channel_name, msg.chat_id)
|
||||||
await self.bus.publish_outbound(outbound)
|
await self.bus.publish_outbound(outbound)
|
||||||
@@ -1125,7 +771,6 @@ class ChannelManager:
|
|||||||
assistant_id: str,
|
assistant_id: str,
|
||||||
run_config: dict[str, Any],
|
run_config: dict[str, Any],
|
||||||
run_context: dict[str, Any],
|
run_context: dict[str, Any],
|
||||||
human_message: dict[str, Any],
|
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.info("[Manager] invoking runs.stream(thread_id=%s, text=%r)", thread_id, msg.text[:100])
|
logger.info("[Manager] invoking runs.stream(thread_id=%s, text=%r)", thread_id, msg.text[:100])
|
||||||
|
|
||||||
@@ -1136,26 +781,21 @@ class ChannelManager:
|
|||||||
last_published_text = ""
|
last_published_text = ""
|
||||||
last_publish_at = 0.0
|
last_publish_at = 0.0
|
||||||
stream_error: BaseException | None = None
|
stream_error: BaseException | None = None
|
||||||
stream_kwargs: dict[str, Any] = {
|
|
||||||
"input": {"messages": [human_message]},
|
|
||||||
"config": run_config,
|
|
||||||
"context": run_context,
|
|
||||||
"stream_mode": list(STREAM_MODES),
|
|
||||||
"multitask_strategy": "reject",
|
|
||||||
}
|
|
||||||
if owner_headers := _owner_headers(msg):
|
|
||||||
stream_kwargs["headers"] = owner_headers
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for chunk in client.runs.stream(
|
async for chunk in client.runs.stream(
|
||||||
thread_id,
|
thread_id,
|
||||||
assistant_id,
|
assistant_id,
|
||||||
**stream_kwargs,
|
input={"messages": [{"role": "human", "content": msg.text}]},
|
||||||
|
config=run_config,
|
||||||
|
context=run_context,
|
||||||
|
stream_mode=["messages-tuple", "values"],
|
||||||
|
multitask_strategy="reject",
|
||||||
):
|
):
|
||||||
event = getattr(chunk, "event", "")
|
event = getattr(chunk, "event", "")
|
||||||
data = getattr(chunk, "data", None)
|
data = getattr(chunk, "data", None)
|
||||||
|
|
||||||
if event in MESSAGE_STREAM_EVENTS:
|
if event == "messages-tuple":
|
||||||
accumulated_text, current_message_id = _accumulate_stream_text(streamed_buffers, current_message_id, data)
|
accumulated_text, current_message_id = _accumulate_stream_text(streamed_buffers, current_message_id, data)
|
||||||
if accumulated_text:
|
if accumulated_text:
|
||||||
latest_text = accumulated_text
|
latest_text = accumulated_text
|
||||||
@@ -1180,9 +820,6 @@ class ChannelManager:
|
|||||||
text=latest_text,
|
text=latest_text,
|
||||||
is_final=False,
|
is_final=False,
|
||||||
thread_ts=msg.thread_ts,
|
thread_ts=msg.thread_ts,
|
||||||
connection_id=msg.connection_id,
|
|
||||||
owner_user_id=msg.owner_user_id,
|
|
||||||
metadata=_response_metadata(msg.metadata),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
last_published_text = latest_text
|
last_published_text = latest_text
|
||||||
@@ -1196,7 +833,6 @@ class ChannelManager:
|
|||||||
finally:
|
finally:
|
||||||
result = last_values if last_values is not None else {"messages": [{"type": "ai", "content": latest_text}]}
|
result = last_values if last_values is not None else {"messages": [{"type": "ai", "content": latest_text}]}
|
||||||
response_text = _extract_response_text(result)
|
response_text = _extract_response_text(result)
|
||||||
pending_clarification = _has_current_turn_clarification(result)
|
|
||||||
artifacts = _extract_artifacts(result)
|
artifacts = _extract_artifacts(result)
|
||||||
response_text, attachments = _prepare_artifact_delivery(thread_id, response_text, artifacts)
|
response_text, attachments = _prepare_artifact_delivery(thread_id, response_text, artifacts)
|
||||||
|
|
||||||
@@ -1228,29 +864,17 @@ class ChannelManager:
|
|||||||
attachments=attachments,
|
attachments=attachments,
|
||||||
is_final=True,
|
is_final=True,
|
||||||
thread_ts=msg.thread_ts,
|
thread_ts=msg.thread_ts,
|
||||||
connection_id=msg.connection_id,
|
|
||||||
owner_user_id=msg.owner_user_id,
|
|
||||||
metadata=_response_metadata(msg.metadata, pending_clarification=pending_clarification),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- command handling --------------------------------------------------
|
# -- command handling --------------------------------------------------
|
||||||
|
|
||||||
async def _handle_command(self, msg: InboundMessage) -> None:
|
async def _handle_command(self, msg: InboundMessage) -> None:
|
||||||
raw_text = msg.text
|
text = msg.text.strip()
|
||||||
text = raw_text.strip()
|
|
||||||
parts = text.split(maxsplit=1)
|
parts = text.split(maxsplit=1)
|
||||||
reply: str | None = None
|
command = parts[0].lower().lstrip("/")
|
||||||
if not parts:
|
|
||||||
command = None
|
|
||||||
reply = _unknown_command_reply()
|
|
||||||
else:
|
|
||||||
command = parts[0].lower().removeprefix("/")
|
|
||||||
|
|
||||||
if reply is None and not raw_text.startswith("/"):
|
if command == "bootstrap":
|
||||||
reply = _unknown_command_reply(command)
|
|
||||||
|
|
||||||
if reply is None and command == "bootstrap":
|
|
||||||
from dataclasses import replace as _dc_replace
|
from dataclasses import replace as _dc_replace
|
||||||
|
|
||||||
chat_text = parts[1] if len(parts) > 1 else "Initialize workspace"
|
chat_text = parts[1] if len(parts) > 1 else "Initialize workspace"
|
||||||
@@ -1258,19 +882,27 @@ class ChannelManager:
|
|||||||
await self._handle_chat(chat_msg, extra_context={"is_bootstrap": True})
|
await self._handle_chat(chat_msg, extra_context={"is_bootstrap": True})
|
||||||
return
|
return
|
||||||
|
|
||||||
if reply is None and command == "new":
|
if command == "new":
|
||||||
# Create a new thread through Gateway
|
# Create a new thread on the LangGraph Server
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
await self._create_thread(client, msg)
|
thread = await client.threads.create()
|
||||||
|
new_thread_id = thread["thread_id"]
|
||||||
|
self.store.set_thread_id(
|
||||||
|
msg.channel_name,
|
||||||
|
msg.chat_id,
|
||||||
|
new_thread_id,
|
||||||
|
topic_id=msg.topic_id,
|
||||||
|
user_id=msg.user_id,
|
||||||
|
)
|
||||||
reply = "New conversation started."
|
reply = "New conversation started."
|
||||||
elif reply is None and command == "status":
|
elif command == "status":
|
||||||
thread_id = await self._lookup_thread_id(msg)
|
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
|
||||||
reply = f"Active thread: {thread_id}" if thread_id else "No active conversation."
|
reply = f"Active thread: {thread_id}" if thread_id else "No active conversation."
|
||||||
elif reply is None and command == "models":
|
elif command == "models":
|
||||||
reply = await self._fetch_gateway("/api/models", "models")
|
reply = await self._fetch_gateway("/api/models", "models")
|
||||||
elif reply is None and command == "memory":
|
elif command == "memory":
|
||||||
reply = await self._fetch_gateway("/api/memory", "memory")
|
reply = await self._fetch_gateway("/api/memory", "memory")
|
||||||
elif reply is None and command == "help":
|
elif command == "help":
|
||||||
reply = (
|
reply = (
|
||||||
"Available commands:\n"
|
"Available commands:\n"
|
||||||
"/bootstrap — Start a bootstrap session (enables agent setup)\n"
|
"/bootstrap — Start a bootstrap session (enables agent setup)\n"
|
||||||
@@ -1278,37 +910,18 @@ class ChannelManager:
|
|||||||
"/status — Show current thread info\n"
|
"/status — Show current thread info\n"
|
||||||
"/models — List available models\n"
|
"/models — List available models\n"
|
||||||
"/memory — Show memory status\n"
|
"/memory — Show memory status\n"
|
||||||
"/<skill-name> <task> — Activate an enabled skill for one turn\n"
|
|
||||||
"/help — Show this help"
|
"/help — Show this help"
|
||||||
)
|
)
|
||||||
elif reply is None:
|
else:
|
||||||
slash_resolution = await asyncio.to_thread(
|
available = " | ".join(sorted(KNOWN_CHANNEL_COMMANDS))
|
||||||
lambda: _resolve_slash_skill_command(
|
reply = f"Unknown command: /{command}. Available commands: {available}"
|
||||||
raw_text,
|
|
||||||
self._resolve_available_skill_names(msg),
|
|
||||||
self._get_skill_storage,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if slash_resolution and slash_resolution.failure_message:
|
|
||||||
reply = slash_resolution.failure_message
|
|
||||||
elif slash_resolution and slash_resolution.route_to_chat:
|
|
||||||
from dataclasses import replace as _dc_replace
|
|
||||||
|
|
||||||
chat_msg = _dc_replace(msg, msg_type=InboundMessageType.CHAT)
|
|
||||||
await self._handle_chat(chat_msg)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
reply = _unknown_command_reply(command)
|
|
||||||
|
|
||||||
outbound = OutboundMessage(
|
outbound = OutboundMessage(
|
||||||
channel_name=msg.channel_name,
|
channel_name=msg.channel_name,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
thread_id=await self._lookup_thread_id(msg) or "",
|
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id) or "",
|
||||||
text=reply,
|
text=reply,
|
||||||
thread_ts=msg.thread_ts,
|
thread_ts=msg.thread_ts,
|
||||||
connection_id=msg.connection_id,
|
|
||||||
owner_user_id=msg.owner_user_id,
|
|
||||||
metadata=_slim_metadata(msg.metadata),
|
|
||||||
)
|
)
|
||||||
await self.bus.publish_outbound(outbound)
|
await self.bus.publish_outbound(outbound)
|
||||||
|
|
||||||
@@ -1318,11 +931,7 @@ class ChannelManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as http:
|
async with httpx.AsyncClient() as http:
|
||||||
resp = await http.get(
|
resp = await http.get(f"{self._gateway_url}{path}", timeout=10)
|
||||||
f"{self._gateway_url}{path}",
|
|
||||||
timeout=10,
|
|
||||||
headers=create_internal_auth_headers(),
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1343,11 +952,8 @@ class ChannelManager:
|
|||||||
outbound = OutboundMessage(
|
outbound = OutboundMessage(
|
||||||
channel_name=msg.channel_name,
|
channel_name=msg.channel_name,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
thread_id=await self._lookup_thread_id(msg) or "",
|
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id) or "",
|
||||||
text=error_text,
|
text=error_text,
|
||||||
thread_ts=msg.thread_ts,
|
thread_ts=msg.thread_ts,
|
||||||
connection_id=msg.connection_id,
|
|
||||||
owner_user_id=msg.owner_user_id,
|
|
||||||
metadata=_slim_metadata(msg.metadata),
|
|
||||||
)
|
)
|
||||||
await self.bus.publish_outbound(outbound)
|
await self.bus.publish_outbound(outbound)
|
||||||
|
|||||||
@@ -13,9 +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"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Message types
|
# Message types
|
||||||
@@ -44,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.
|
||||||
@@ -62,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)
|
||||||
@@ -104,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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -118,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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,154 +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:
|
|
||||||
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(runtime_config)
|
|
||||||
if isinstance(existing, dict):
|
|
||||||
merged.update(existing)
|
|
||||||
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
|
|
||||||
+25
-203
@@ -2,26 +2,19 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import 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
|
|
||||||
|
|
||||||
# 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",
|
|
||||||
"feishu": "app.channels.feishu:FeishuChannel",
|
"feishu": "app.channels.feishu:FeishuChannel",
|
||||||
"slack": "app.channels.slack:SlackChannel",
|
"slack": "app.channels.slack:SlackChannel",
|
||||||
"telegram": "app.channels.telegram:TelegramChannel",
|
"telegram": "app.channels.telegram:TelegramChannel",
|
||||||
@@ -29,26 +22,10 @@ _CHANNEL_REGISTRY: dict[str, str] = {
|
|||||||
"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():
|
||||||
@@ -59,30 +36,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(app_config: AppConfig):
|
|
||||||
connection_config = getattr(app_config, "channel_connections", 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.
|
||||||
|
|
||||||
@@ -90,10 +43,9 @@ class ChannelService:
|
|||||||
instantiates enabled channels, and starts the ChannelManager dispatcher.
|
instantiates enabled channels, and starts the ChannelManager dispatcher.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, channels_config: dict[str, Any] | None = None, *, connection_repo: Any | None = None) -> None:
|
def __init__(self, channels_config: dict[str, Any] | None = None) -> None:
|
||||||
self.bus = MessageBus()
|
self.bus = MessageBus()
|
||||||
self.store = ChannelStore()
|
self.store = ChannelStore()
|
||||||
self._connection_repo = connection_repo
|
|
||||||
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)
|
||||||
@@ -106,27 +58,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,
|
|
||||||
)
|
)
|
||||||
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(cls, app_config: AppConfig | None = None) -> ChannelService:
|
def from_app_config(cls) -> ChannelService:
|
||||||
"""Create a ChannelService from the application config."""
|
"""Create a ChannelService from the application config."""
|
||||||
if app_config is None:
|
from deerflow.config.app_config import get_app_config
|
||||||
from deerflow.config.app_config import get_app_config
|
|
||||||
|
|
||||||
app_config = get_app_config()
|
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)
|
||||||
return cls(channels_config=channels_config, connection_repo=_make_connection_repo(app_config))
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the manager and all enabled channels."""
|
"""Start the manager and all enabled channels."""
|
||||||
@@ -134,169 +82,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 mtime,
|
|
||||||
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:
|
||||||
@@ -304,26 +137,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]:
|
||||||
@@ -357,14 +181,12 @@ def get_channel_service() -> ChannelService | None:
|
|||||||
return _channel_service
|
return _channel_service
|
||||||
|
|
||||||
|
|
||||||
async def start_channel_service(app_config: AppConfig | None = None) -> ChannelService:
|
async def start_channel_service() -> ChannelService:
|
||||||
"""Create and start the global ChannelService from app config."""
|
"""Create and start the global ChannelService from app config."""
|
||||||
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.
|
|
||||||
_channel_service = await asyncio.to_thread(ChannelService.from_app_config, app_config)
|
|
||||||
await _channel_service.start()
|
await _channel_service.start()
|
||||||
return _channel_service
|
return _channel_service
|
||||||
|
|
||||||
|
|||||||
+17
-193
@@ -9,8 +9,6 @@ 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 extract_connect_code, 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__)
|
||||||
@@ -18,45 +16,13 @@ logger = logging.getLogger(__name__)
|
|||||||
_slack_md_converter = SlackMarkdownConverter()
|
_slack_md_converter = SlackMarkdownConverter()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_allowed_users(allowed_users: Any) -> set[str]:
|
|
||||||
if allowed_users is None:
|
|
||||||
return set()
|
|
||||||
if isinstance(allowed_users, str):
|
|
||||||
values = [allowed_users]
|
|
||||||
elif isinstance(allowed_users, list | tuple | set):
|
|
||||||
values = allowed_users
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Slack allowed_users should be a list of Slack user IDs or a single Slack user ID string; treating %s as one string value",
|
|
||||||
type(allowed_users).__name__,
|
|
||||||
)
|
|
||||||
values = [allowed_users]
|
|
||||||
return {str(user_id) for user_id in values if str(user_id)}
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -64,12 +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._connection_repo = config.get("connection_repo")
|
|
||||||
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:
|
||||||
@@ -84,28 +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._connection_repo is not None and self.config.get("event_delivery") == "http":
|
|
||||||
if not bot_token:
|
|
||||||
logger.error("Slack HTTP Events mode requires bot_token")
|
|
||||||
return
|
|
||||||
await self._initialize_operator_web_client(str(bot_token))
|
|
||||||
self._loop = asyncio.get_event_loop()
|
|
||||||
self._running = True
|
|
||||||
self.bus.subscribe_outbound(self._on_outbound)
|
|
||||||
logger.info("Slack channel started in HTTP Events mode")
|
|
||||||
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,
|
||||||
@@ -130,8 +78,7 @@ 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] = {
|
||||||
@@ -144,12 +91,11 @@ class SlackChannel(Channel):
|
|||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for attempt in range(_max_retries):
|
for attempt in range(_max_retries):
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(web_client.chat_postMessage, **kwargs)
|
await asyncio.to_thread(self._web_client.chat_postMessage, **kwargs)
|
||||||
# Add a completion reaction to the thread root
|
# Add a completion reaction to the thread root
|
||||||
if msg.thread_ts:
|
if msg.thread_ts:
|
||||||
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,
|
||||||
"white_check_mark",
|
"white_check_mark",
|
||||||
@@ -173,8 +119,7 @@ class SlackChannel(Channel):
|
|||||||
if msg.thread_ts:
|
if msg.thread_ts:
|
||||||
try:
|
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",
|
"x",
|
||||||
@@ -186,8 +131,7 @@ class SlackChannel(Channel):
|
|||||||
raise last_exc
|
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:
|
||||||
@@ -200,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:
|
||||||
@@ -209,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,
|
||||||
@@ -256,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:
|
||||||
@@ -287,26 +192,17 @@ 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
|
||||||
@@ -319,28 +215,13 @@ class SlackChannel(Channel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
text = event.get("text", "").strip()
|
text = event.get("text", "").strip()
|
||||||
if event.get("type") == "app_mention":
|
|
||||||
text = _strip_leading_slack_bot_mention(text, self._bot_user_id)
|
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
|
||||||
connect_code = extract_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 event.get("team") or ""),
|
|
||||||
code=connect_code,
|
|
||||||
),
|
|
||||||
self._loop,
|
|
||||||
)
|
|
||||||
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
|
||||||
@@ -362,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,27 +5,13 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
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 InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TELEGRAM_MAX_MESSAGE_LENGTH = 4096
|
|
||||||
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.
|
||||||
@@ -49,14 +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]] = {}
|
|
||||||
self._connection_repo = config.get("connection_repo")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def supports_streaming(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if self._running:
|
if self._running:
|
||||||
@@ -82,17 +60,12 @@ 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("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))
|
||||||
|
|
||||||
@@ -124,117 +97,10 @@ 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:
|
|
||||||
await self._finalize_stream_message(chat_id, msg.chat_id, state, msg.text)
|
|
||||||
return
|
|
||||||
|
|
||||||
await self._send_new_message(chat_id, msg.chat_id, msg.text, _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
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -243,8 +109,8 @@ class TelegramChannel(Channel):
|
|||||||
for attempt in range(_max_retries):
|
for attempt in range(_max_retries):
|
||||||
try:
|
try:
|
||||||
sent = await bot.send_message(**kwargs)
|
sent = await bot.send_message(**kwargs)
|
||||||
self._last_bot_message[chat_key] = sent.message_id
|
self._last_bot_message[msg.chat_id] = sent.message_id
|
||||||
return sent.message_id
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
if attempt < _max_retries - 1:
|
if attempt < _max_retries - 1:
|
||||||
@@ -307,63 +173,17 @@ class TelegramChannel(Channel):
|
|||||||
|
|
||||||
# -- helpers -----------------------------------------------------------
|
# -- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
@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 (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -408,90 +228,10 @@ 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."""
|
||||||
if not self._check_user(update.effective_user.id):
|
if not self._check_user(update.effective_user.id):
|
||||||
return
|
return
|
||||||
args = getattr(context, "args", []) if context is not None else []
|
|
||||||
if args:
|
|
||||||
handled = await self._bind_connection_from_start_token(update, str(args[0]))
|
|
||||||
if handled:
|
|
||||||
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.")
|
||||||
|
|
||||||
async def _process_incoming_with_reply(self, chat_id: str, msg_id: int, inbound: InboundMessage) -> None:
|
async def _process_incoming_with_reply(self, chat_id: str, msg_id: int, inbound: InboundMessage) -> None:
|
||||||
@@ -503,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)
|
||||||
@@ -527,7 +267,6 @@ class TelegramChannel(Channel):
|
|||||||
thread_ts=msg_id,
|
thread_ts=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)
|
||||||
@@ -540,7 +279,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.strip()
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -570,7 +309,6 @@ class TelegramChannel(Channel):
|
|||||||
thread_ts=msg_id,
|
thread_ts=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)
|
||||||
|
|||||||
@@ -22,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 extract_connect_code, 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__)
|
||||||
|
|
||||||
@@ -254,7 +252,6 @@ 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
|
||||||
self._connection_repo = config.get("connection_repo")
|
|
||||||
self._load_state()
|
self._load_state()
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -619,21 +616,11 @@ class WechatChannel(Channel):
|
|||||||
if thread_ts:
|
if thread_ts:
|
||||||
self._context_tokens_by_thread[thread_ts] = context_token
|
self._context_tokens_by_thread[thread_ts] = context_token
|
||||||
|
|
||||||
connect_code = extract_connect_code(text)
|
|
||||||
if connect_code and self._connection_repo is not None:
|
|
||||||
handled = await self._bind_connection_from_connect_code(
|
|
||||||
chat_id=chat_id,
|
|
||||||
context_token=context_token,
|
|
||||||
code=connect_code,
|
|
||||||
)
|
|
||||||
if handled:
|
|
||||||
return
|
|
||||||
|
|
||||||
inbound = self._make_inbound(
|
inbound = self._make_inbound(
|
||||||
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={
|
||||||
@@ -644,54 +631,8 @@ class WechatChannel(Channel):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
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:
|
||||||
|
|||||||
@@ -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 extract_connect_code, 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,
|
||||||
@@ -31,11 +28,6 @@ class WeComChannel(Channel):
|
|||||||
self._ws_frames: dict[str, dict[str, Any]] = {}
|
self._ws_frames: dict[str, dict[str, Any]] = {}
|
||||||
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..."
|
||||||
self._connection_repo = config.get("connection_repo")
|
|
||||||
|
|
||||||
@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:
|
||||||
@@ -82,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)
|
||||||
@@ -295,17 +266,7 @@ class WeComChannel(Channel):
|
|||||||
|
|
||||||
user_id = (body.get("from") or {}).get("userid")
|
user_id = (body.get("from") or {}).get("userid")
|
||||||
|
|
||||||
connect_code = extract_connect_code(text)
|
inbound_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
|
||||||
if connect_code and self._connection_repo is not None:
|
|
||||||
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,
|
||||||
@@ -326,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
|
||||||
|
|||||||
+91
-132
@@ -1,22 +1,21 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.auth_middleware import AuthMiddleware
|
||||||
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.csrf_middleware import CSRFMiddleware
|
||||||
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,
|
auth,
|
||||||
channel_connections,
|
|
||||||
channels,
|
channels,
|
||||||
feedback,
|
feedback,
|
||||||
mcp,
|
mcp,
|
||||||
@@ -29,13 +28,9 @@ from app.gateway.routers import (
|
|||||||
threads,
|
threads,
|
||||||
uploads,
|
uploads,
|
||||||
)
|
)
|
||||||
from deerflow.config import app_config as deerflow_app_config
|
from deerflow.config.app_config import get_app_config
|
||||||
from deerflow.config.app_config import apply_logging_level
|
|
||||||
|
|
||||||
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="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
@@ -44,74 +39,79 @@ logging.basicConfig(
|
|||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_admin_user(app: FastAPI) -> None:
|
async def _ensure_admin_user(app: FastAPI) -> None:
|
||||||
"""Startup hook: handle first boot and migrate orphan threads otherwise.
|
"""Auto-create the admin user on first boot if no users exist.
|
||||||
|
|
||||||
After admin creation, migrate orphan threads from the LangGraph
|
After admin creation, migrate orphan threads from the LangGraph
|
||||||
store (metadata.user_id unset) to the admin account. This is the
|
store (metadata.owner_id unset) to the admin account. This is the
|
||||||
"no-auth → with-auth" upgrade path: users who ran DeerFlow without
|
"no-auth → with-auth" upgrade path: users who ran DeerFlow without
|
||||||
authentication have existing LangGraph thread data that needs an
|
authentication have existing LangGraph thread data that needs an
|
||||||
owner assigned.
|
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):
|
No SQL persistence migration is needed: the four owner_id columns
|
||||||
- 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
|
(threads_meta, runs, run_events, feedback) only come into existence
|
||||||
alongside the auth module via create_all, so freshly created tables
|
alongside the auth module via create_all, so freshly created tables
|
||||||
never contain NULL-owner rows.
|
never contain NULL-owner rows. "Existing persistence DB + new auth"
|
||||||
|
is not a supported upgrade path — fresh install or wipe-and-retry.
|
||||||
|
|
||||||
|
Multi-worker safe: relies on SQLite UNIQUE constraint to resolve
|
||||||
|
races during admin creation. Only the worker that successfully
|
||||||
|
creates/updates the admin prints the password; losers silently skip.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
import secrets
|
||||||
|
|
||||||
|
from app.gateway.auth.credential_file import write_initial_credentials
|
||||||
from app.gateway.deps import get_local_provider
|
from app.gateway.deps import get_local_provider
|
||||||
from deerflow.persistence.engine import get_session_factory
|
|
||||||
from deerflow.persistence.user.model import UserRow
|
|
||||||
|
|
||||||
try:
|
def _announce_credentials(email: str, password: str, *, label: str, headline: str) -> None:
|
||||||
provider = get_local_provider()
|
"""Write the password to a 0600 file and log the path (never the secret)."""
|
||||||
except RuntimeError:
|
cred_path = write_initial_credentials(email, password, label=label)
|
||||||
# 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("=" * 60)
|
||||||
logger.info(" First boot detected — no admin account exists.")
|
logger.info(" %s", headline)
|
||||||
logger.info(" Visit /setup to complete admin account creation.")
|
logger.info(" Credentials written to: %s (mode 0600)", cred_path)
|
||||||
|
logger.info(" Change it after login: Settings -> Account")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
return
|
|
||||||
|
|
||||||
# Admin already exists — run orphan thread migration for any
|
provider = get_local_provider()
|
||||||
# LangGraph thread metadata that pre-dates the auth module.
|
user_count = await provider.count_users()
|
||||||
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:
|
admin = None
|
||||||
return # Should not happen (admin_count > 0 above), but be safe.
|
|
||||||
|
|
||||||
admin_id = str(row.id)
|
if user_count == 0:
|
||||||
|
password = secrets.token_urlsafe(16)
|
||||||
|
try:
|
||||||
|
admin = await provider.create_user(email="admin@deerflow.dev", password=password, system_role="admin", needs_setup=True)
|
||||||
|
except ValueError:
|
||||||
|
return # Another worker already created the admin.
|
||||||
|
_announce_credentials(admin.email, password, label="initial", headline="Admin account created on first boot")
|
||||||
|
else:
|
||||||
|
# Admin exists but setup never completed — reset password so operator
|
||||||
|
# can always find it in the console without needing the CLI.
|
||||||
|
# Multi-worker guard: if admin was created less than 30s ago, another
|
||||||
|
# worker just created it and will print the password — skip reset.
|
||||||
|
admin = await provider.get_user_by_email("admin@deerflow.dev")
|
||||||
|
if admin and admin.needs_setup:
|
||||||
|
import time
|
||||||
|
|
||||||
|
age = time.time() - admin.created_at.replace(tzinfo=UTC).timestamp()
|
||||||
|
if age >= 30:
|
||||||
|
from app.gateway.auth.password import hash_password_async
|
||||||
|
|
||||||
|
password = secrets.token_urlsafe(16)
|
||||||
|
admin.password_hash = await hash_password_async(password)
|
||||||
|
admin.token_version += 1
|
||||||
|
await provider.update_user(admin)
|
||||||
|
_announce_credentials(admin.email, password, label="reset", headline="Admin account setup incomplete — password reset")
|
||||||
|
|
||||||
|
if admin is None:
|
||||||
|
return # Nothing to bind orphans to.
|
||||||
|
|
||||||
|
admin_id = str(admin.id)
|
||||||
|
|
||||||
# LangGraph store orphan migration — non-fatal.
|
# LangGraph store orphan migration — non-fatal.
|
||||||
# This covers the "no-auth → with-auth" upgrade path for users
|
# This covers the "no-auth → with-auth" upgrade path for users
|
||||||
# whose existing LangGraph thread metadata has no user_id set.
|
# whose existing LangGraph thread metadata has no owner_id set.
|
||||||
store = getattr(app.state, "store", None)
|
store = getattr(app.state, "store", None)
|
||||||
if store is not None:
|
if store is not None:
|
||||||
try:
|
try:
|
||||||
@@ -143,7 +143,7 @@ async def _iter_store_items(store, namespace, *, page_size: int = 500):
|
|||||||
|
|
||||||
|
|
||||||
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
||||||
"""Migrate LangGraph store threads with no user_id to the given admin.
|
"""Migrate LangGraph store threads with no owner_id to the given admin.
|
||||||
|
|
||||||
Uses cursor pagination so all orphans are migrated regardless of
|
Uses cursor pagination so all orphans are migrated regardless of
|
||||||
count. Returns the number of rows migrated.
|
count. Returns the number of rows migrated.
|
||||||
@@ -151,8 +151,8 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
|||||||
migrated = 0
|
migrated = 0
|
||||||
async for item in _iter_store_items(store, ("threads",)):
|
async for item in _iter_store_items(store, ("threads",)):
|
||||||
metadata = item.value.get("metadata", {})
|
metadata = item.value.get("metadata", {})
|
||||||
if not metadata.get("user_id"):
|
if not metadata.get("owner_id"):
|
||||||
metadata["user_id"] = admin_user_id
|
metadata["owner_id"] = admin_user_id
|
||||||
item.value["metadata"] = metadata
|
item.value["metadata"] = metadata
|
||||||
await store.aput(("threads",), item.key, item.value)
|
await store.aput(("threads",), item.key, item.value)
|
||||||
migrated += 1
|
migrated += 1
|
||||||
@@ -163,18 +163,10 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
|||||||
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()
|
||||||
apply_logging_level(startup_config.log_level)
|
|
||||||
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)
|
||||||
@@ -182,36 +174,11 @@ 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}")
|
||||||
|
|
||||||
# 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).
|
|
||||||
# When memory.token_counting is "char", token counting never touches
|
|
||||||
# tiktoken, so skip the warm-up entirely (avoids even the 5s probe in
|
|
||||||
# network-restricted deployments — see issue #3429).
|
|
||||||
if startup_config.memory.token_counting == "char":
|
|
||||||
logger.info("memory.token_counting='char'; skipping tiktoken warm-up (network-free token estimation)")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
from deerflow.agents.memory.prompt import warm_tiktoken_cache
|
|
||||||
|
|
||||||
warmed = await asyncio.wait_for(
|
|
||||||
asyncio.to_thread(warm_tiktoken_cache),
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if 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)
|
|
||||||
|
|
||||||
# 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.
|
# Ensure admin user exists (auto-create on first boot)
|
||||||
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
||||||
await _ensure_admin_user(app)
|
await _ensure_admin_user(app)
|
||||||
|
|
||||||
@@ -219,26 +186,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
try:
|
try:
|
||||||
from app.channels.service import start_channel_service
|
from app.channels.service import start_channel_service
|
||||||
|
|
||||||
channel_service = await start_channel_service(startup_config)
|
channel_service = await start_channel_service()
|
||||||
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")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Stop channel service on shutdown (bounded to prevent worker hang)
|
# Stop channel service on shutdown
|
||||||
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")
|
||||||
|
|
||||||
@@ -251,10 +210,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",
|
||||||
@@ -274,14 +229,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",
|
||||||
@@ -344,18 +299,25 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
|
|||||||
# CSRF: Double Submit Cookie pattern for state-changing requests
|
# CSRF: Double Submit Cookie pattern for state-changing requests
|
||||||
app.add_middleware(CSRFMiddleware)
|
app.add_middleware(CSRFMiddleware)
|
||||||
|
|
||||||
# CORS: the unified nginx endpoint is same-origin by default. Split-origin
|
# CORS: when GATEWAY_CORS_ORIGINS is set (dev without nginx), add CORS middleware.
|
||||||
# browser clients must opt in with this explicit Gateway allowlist so CORS
|
# In production, nginx handles CORS and no middleware is needed.
|
||||||
# and CSRF origin checks share the same source of truth.
|
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "")
|
||||||
cors_origins = sorted(get_configured_cors_origins())
|
if cors_origins_env:
|
||||||
if cors_origins:
|
cors_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()]
|
||||||
app.add_middleware(
|
# Validate: wildcard origin with credentials is a security misconfiguration
|
||||||
CORSMiddleware,
|
for origin in cors_origins:
|
||||||
allow_origins=cors_origins,
|
if origin == "*":
|
||||||
allow_credentials=True,
|
logger.error("GATEWAY_CORS_ORIGINS contains wildcard '*' with allow_credentials=True. This is a security misconfiguration — browsers will reject the response. Use explicit scheme://host:port origins instead.")
|
||||||
allow_methods=["*"],
|
cors_origins = [o for o in cors_origins if o != "*"]
|
||||||
allow_headers=["*"],
|
break
|
||||||
)
|
if cors_origins:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
# Models API is mounted at /api/models
|
# Models API is mounted at /api/models
|
||||||
@@ -385,9 +347,6 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
|
|||||||
# 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)
|
||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
@@ -407,7 +366,7 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
|
|||||||
app.include_router(runs.router)
|
app.include_router(runs.router)
|
||||||
|
|
||||||
@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:
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
load_dotenv()
|
||||||
|
|
||||||
_SECRET_FILE = ".jwt_secret"
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AuthConfig(BaseModel):
|
class AuthConfig(BaseModel):
|
||||||
@@ -32,46 +33,17 @@ class AuthConfig(BaseModel):
|
|||||||
_auth_config: AuthConfig | None = 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:
|
def get_auth_config() -> AuthConfig:
|
||||||
"""Get the global AuthConfig instance. Parses from env on first call."""
|
"""Get the global AuthConfig instance. Parses from env on first call."""
|
||||||
global _auth_config
|
global _auth_config
|
||||||
if _auth_config is None:
|
if _auth_config is None:
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
jwt_secret = os.environ.get("AUTH_JWT_SECRET")
|
jwt_secret = os.environ.get("AUTH_JWT_SECRET")
|
||||||
if not jwt_secret:
|
if not jwt_secret:
|
||||||
jwt_secret = _load_or_create_secret()
|
jwt_secret = secrets.token_urlsafe(32)
|
||||||
os.environ["AUTH_JWT_SECRET"] = jwt_secret
|
os.environ["AUTH_JWT_SECRET"] = jwt_secret
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"⚠ AUTH_JWT_SECRET is not set — using an auto-generated secret "
|
"⚠ AUTH_JWT_SECRET is not set — using an auto-generated ephemeral secret. "
|
||||||
"persisted to .jwt_secret. Sessions will survive restarts. "
|
"Sessions will be invalidated on restart. "
|
||||||
"For production, add AUTH_JWT_SECRET to your .env file: "
|
"For production, add AUTH_JWT_SECRET to your .env file: "
|
||||||
'python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
'python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class AuthErrorCode(StrEnum):
|
|||||||
EMAIL_ALREADY_EXISTS = "email_already_exists"
|
EMAIL_ALREADY_EXISTS = "email_already_exists"
|
||||||
PROVIDER_NOT_FOUND = "provider_not_found"
|
PROVIDER_NOT_FOUND = "provider_not_found"
|
||||||
NOT_AUTHENTICATED = "not_authenticated"
|
NOT_AUTHENTICATED = "not_authenticated"
|
||||||
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
|
|
||||||
|
|
||||||
|
|
||||||
class TokenError(StrEnum):
|
class TokenError(StrEnum):
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
"""Local email/password authentication provider."""
|
"""Local email/password authentication provider."""
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.gateway.auth.models import User
|
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.password import hash_password_async, verify_password_async
|
||||||
from app.gateway.auth.providers import AuthProvider
|
from app.gateway.auth.providers import AuthProvider
|
||||||
from app.gateway.auth.repositories.base import UserRepository
|
from app.gateway.auth.repositories.base import UserRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class LocalAuthProvider(AuthProvider):
|
class LocalAuthProvider(AuthProvider):
|
||||||
"""Email/password authentication provider using local database."""
|
"""Email/password authentication provider using local database."""
|
||||||
@@ -47,15 +43,6 @@ class LocalAuthProvider(AuthProvider):
|
|||||||
if not await verify_password_async(password, user.password_hash):
|
if not await verify_password_async(password, user.password_hash):
|
||||||
return None
|
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
|
return user
|
||||||
|
|
||||||
async def get_user(self, user_id: str) -> User | None:
|
async def get_user(self, user_id: str) -> User | None:
|
||||||
@@ -91,10 +78,6 @@ class LocalAuthProvider(AuthProvider):
|
|||||||
"""Return total number of registered users."""
|
"""Return total number of registered users."""
|
||||||
return await self._repo.count_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:
|
async def update_user(self, user: User) -> User:
|
||||||
"""Update an existing user."""
|
"""Update an existing user."""
|
||||||
return await self._repo.update_user(user)
|
return await self._repo.update_user(user)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class User(BaseModel):
|
|||||||
oauth_id: str | None = Field(None, description="User ID from OAuth provider")
|
oauth_id: str | None = Field(None, description="User ID from OAuth provider")
|
||||||
|
|
||||||
# Auth lifecycle
|
# Auth lifecycle
|
||||||
needs_setup: bool = Field(default=False, description="True when a reset account must complete setup")
|
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
|
||||||
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
|
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,18 @@
|
|||||||
"""Password hashing utilities with versioned hash format.
|
"""Password hashing utilities using bcrypt directly."""
|
||||||
|
|
||||||
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 asyncio
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
import bcrypt
|
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:
|
def hash_password(password: str) -> str:
|
||||||
"""Hash a password (current version: v2 — SHA-256 + bcrypt)."""
|
"""Hash a password using bcrypt."""
|
||||||
raw = bcrypt.hashpw(_pre_hash_v2(password), bcrypt.gensalt()).decode("utf-8")
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
return f"{_PREFIX_V2}{raw}"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
"""Verify a password, auto-detecting the hash version.
|
"""Verify a password against its hash."""
|
||||||
|
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||||
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:
|
async def hash_password_async(password: str) -> str:
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ class AuthProvider(ABC):
|
|||||||
|
|
||||||
Returns User if authentication succeeds, None otherwise.
|
Returns User if authentication succeeds, None otherwise.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_user(self, user_id: str) -> "User | None":
|
async def get_user(self, user_id: str) -> "User | None":
|
||||||
"""Retrieve user by ID."""
|
"""Retrieve user by ID."""
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|
||||||
|
|
||||||
# Import User at runtime to avoid circular imports
|
# Import User at runtime to avoid circular imports
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class UserRepository(ABC):
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If email already exists
|
ValueError: If email already exists
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_user_by_id(self, user_id: str) -> User | None:
|
async def get_user_by_id(self, user_id: str) -> User | None:
|
||||||
@@ -47,7 +47,7 @@ class UserRepository(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
User if found, None otherwise
|
User if found, None otherwise
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_user_by_email(self, email: str) -> User | None:
|
async def get_user_by_email(self, email: str) -> User | None:
|
||||||
@@ -59,7 +59,7 @@ class UserRepository(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
User if found, None otherwise
|
User if found, None otherwise
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def update_user(self, user: User) -> User:
|
async def update_user(self, user: User) -> User:
|
||||||
@@ -76,17 +76,12 @@ class UserRepository(ABC):
|
|||||||
a hard failure (not a no-op) so callers cannot mistake a
|
a hard failure (not a no-op) so callers cannot mistake a
|
||||||
concurrent-delete race for a successful update.
|
concurrent-delete race for a successful update.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def count_users(self) -> int:
|
async def count_users(self) -> int:
|
||||||
"""Return total number of registered users."""
|
"""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
|
@abstractmethod
|
||||||
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
|
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
|
||||||
@@ -99,4 +94,4 @@ class UserRepository(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
User if found, None otherwise
|
User if found, None otherwise
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|||||||
@@ -114,11 +114,6 @@ class SQLiteUserRepository(UserRepository):
|
|||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
return await session.scalar(stmt) or 0
|
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:
|
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)
|
stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id)
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
|
|||||||
@@ -1,56 +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,
|
|
||||||
)
|
|
||||||
@@ -17,15 +17,7 @@ from starlette.responses import JSONResponse
|
|||||||
from starlette.types import ASGIApp
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
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 _ALL_PERMISSIONS, AuthContext
|
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||||
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
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||||
|
|
||||||
# Paths that never require authentication.
|
# Paths that never require authentication.
|
||||||
@@ -44,7 +36,6 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
|
|||||||
"/api/v1/auth/register",
|
"/api/v1/auth/register",
|
||||||
"/api/v1/auth/logout",
|
"/api/v1/auth/logout",
|
||||||
"/api/v1/auth/setup-status",
|
"/api/v1/auth/setup-status",
|
||||||
"/api/v1/auth/initialize",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,42 +74,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
if _is_public(request.url.path):
|
if _is_public(request.url.path):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
internal_user = None
|
|
||||||
if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)):
|
|
||||||
internal_user = get_internal_user()
|
|
||||||
|
|
||||||
auth_source = AUTH_SOURCE_SESSION
|
|
||||||
access_token = request.cookies.get("access_token")
|
|
||||||
|
|
||||||
# Non-public path: require session cookie
|
# Non-public path: require session cookie
|
||||||
if internal_user is not None:
|
if not request.cookies.get("access_token"):
|
||||||
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(
|
return JSONResponse(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
content={
|
content={
|
||||||
@@ -129,12 +86,29 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||||
|
|
||||||
# Stamp both request.state.user (for the contextvar pattern)
|
# Stamp both request.state.user (for the contextvar pattern)
|
||||||
# and request.state.auth (so @require_permission's "auth is
|
# and request.state.auth (so @require_permission's "auth is
|
||||||
# None" branch short-circuits instead of running the entire
|
# None" branch short-circuits instead of running the entire
|
||||||
# JWT-decode + DB-lookup pipeline a second time per request).
|
# JWT-decode + DB-lookup pipeline a second time per request).
|
||||||
request.state.user = user
|
request.state.user = user
|
||||||
request.state.auth_source = auth_source
|
|
||||||
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
||||||
token = set_current_user(user)
|
token = set_current_user(user)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ Inspired by LangGraph Auth system: https://github.com/langchain-ai/langgraph/blo
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import inspect
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
|
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
@@ -119,15 +117,6 @@ _ALL_PERMISSIONS: list[str] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _make_test_request_stub() -> Any:
|
|
||||||
"""Create a minimal request-like object for direct unit calls.
|
|
||||||
|
|
||||||
Used when decorated route handlers are invoked without FastAPI's
|
|
||||||
request injection. Includes fields accessed by auth helpers.
|
|
||||||
"""
|
|
||||||
return SimpleNamespace(state=SimpleNamespace(), cookies={}, _deerflow_test_bypass_auth=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def _authenticate(request: Request) -> AuthContext:
|
async def _authenticate(request: Request) -> AuthContext:
|
||||||
"""Authenticate request and return AuthContext.
|
"""Authenticate request and return AuthContext.
|
||||||
|
|
||||||
@@ -145,11 +134,7 @@ async def _authenticate(request: Request) -> AuthContext:
|
|||||||
|
|
||||||
|
|
||||||
def require_auth[**P, T](func: Callable[P, T]) -> Callable[P, T]:
|
def require_auth[**P, T](func: Callable[P, T]) -> Callable[P, T]:
|
||||||
"""Decorator that authenticates the request and enforces authentication.
|
"""Decorator that authenticates the request and sets AuthContext.
|
||||||
|
|
||||||
Independently raises HTTP 401 for unauthenticated requests, regardless of
|
|
||||||
whether ``AuthMiddleware`` is present in the ASGI stack. Sets the resolved
|
|
||||||
``AuthContext`` on ``request.state.auth`` for downstream handlers.
|
|
||||||
|
|
||||||
Must be placed ABOVE other decorators (executes after them).
|
Must be placed ABOVE other decorators (executes after them).
|
||||||
|
|
||||||
@@ -162,33 +147,19 @@ def require_auth[**P, T](func: Callable[P, T]) -> Callable[P, T]:
|
|||||||
...
|
...
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 401 if the request is unauthenticated.
|
ValueError: If 'request' parameter is missing
|
||||||
ValueError: If 'request' parameter is missing.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
request = kwargs.get("request")
|
request = kwargs.get("request")
|
||||||
if request is None:
|
if request is None:
|
||||||
# Unit tests may call decorated handlers directly without a
|
raise ValueError("require_auth decorator requires 'request' parameter")
|
||||||
# FastAPI Request object. Inject a minimal request stub when
|
|
||||||
# the wrapped function declares `request`.
|
|
||||||
if "request" in inspect.signature(func).parameters:
|
|
||||||
kwargs["request"] = _make_test_request_stub()
|
|
||||||
else:
|
|
||||||
raise ValueError("require_auth decorator requires 'request' parameter")
|
|
||||||
request = kwargs["request"]
|
|
||||||
|
|
||||||
if getattr(request, "_deerflow_test_bypass_auth", False):
|
|
||||||
return await func(*args, **kwargs)
|
|
||||||
|
|
||||||
# Authenticate and set context
|
# Authenticate and set context
|
||||||
auth_context = await _authenticate(request)
|
auth_context = await _authenticate(request)
|
||||||
request.state.auth = auth_context
|
request.state.auth = auth_context
|
||||||
|
|
||||||
if not auth_context.is_authenticated:
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
|
||||||
|
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -239,17 +210,7 @@ def require_permission(
|
|||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
request = kwargs.get("request")
|
request = kwargs.get("request")
|
||||||
if request is None:
|
if request is None:
|
||||||
# Unit tests may call decorated route handlers directly without
|
raise ValueError("require_permission decorator requires 'request' parameter")
|
||||||
# constructing a FastAPI Request object. Inject a minimal stub
|
|
||||||
# when the wrapped function declares `request`.
|
|
||||||
if "request" in inspect.signature(func).parameters:
|
|
||||||
kwargs["request"] = _make_test_request_stub()
|
|
||||||
else:
|
|
||||||
return await func(*args, **kwargs)
|
|
||||||
request = kwargs["request"]
|
|
||||||
|
|
||||||
if getattr(request, "_deerflow_test_bypass_auth", False):
|
|
||||||
return await func(*args, **kwargs)
|
|
||||||
|
|
||||||
auth: AuthContext = getattr(request.state, "auth", None)
|
auth: AuthContext = getattr(request.state, "auth", None)
|
||||||
if auth is None:
|
if auth is None:
|
||||||
@@ -272,40 +233,22 @@ def require_permission(
|
|||||||
# (``threads_meta`` table). We verify ownership via
|
# (``threads_meta`` table). We verify ownership via
|
||||||
# ``ThreadMetaStore.check_access``: it returns True for
|
# ``ThreadMetaStore.check_access``: it returns True for
|
||||||
# missing rows (untracked legacy thread) and for rows whose
|
# missing rows (untracked legacy thread) and for rows whose
|
||||||
# ``user_id`` is NULL (shared / pre-auth data), so this is
|
# ``owner_id`` is NULL (shared / pre-auth data), so this is
|
||||||
# strict-deny rather than strict-allow — only an *existing*
|
# strict-deny rather than strict-allow — only an *existing*
|
||||||
# row with a *different* user_id triggers 404.
|
# row with a *different* owner_id triggers 404.
|
||||||
if owner_check:
|
if owner_check:
|
||||||
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
|
|
||||||
|
|
||||||
thread_id = kwargs.get("thread_id")
|
thread_id = kwargs.get("thread_id")
|
||||||
if thread_id is None:
|
if thread_id is None:
|
||||||
raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter")
|
raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter")
|
||||||
|
|
||||||
from app.gateway.deps import get_thread_store
|
from app.gateway.deps import get_thread_meta_repo
|
||||||
|
|
||||||
thread_store = get_thread_store(request)
|
thread_meta_repo = get_thread_meta_repo(request)
|
||||||
allowed = await thread_store.check_access(
|
allowed = await thread_meta_repo.check_access(
|
||||||
thread_id,
|
thread_id,
|
||||||
str(auth.user.id),
|
str(auth.user.id),
|
||||||
require_existing=require_existing,
|
require_existing=require_existing,
|
||||||
)
|
)
|
||||||
if not allowed and getattr(auth.user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
|
|
||||||
# Trusted internal callers (channel workers) also act for
|
|
||||||
# the connection owner carried in X-DeerFlow-Owner-User-Id.
|
|
||||||
# Scope the check to that owner instead of bypassing it; a
|
|
||||||
# leaked internal token must not grant cross-user thread
|
|
||||||
# access. The header is honored only after ``auth`` proved
|
|
||||||
# the caller holds the internal token (mirrors
|
|
||||||
# get_trusted_internal_owner_user_id, which keys off the
|
|
||||||
# middleware-stamped ``request.state.user``).
|
|
||||||
header_owner = (request.headers.get(INTERNAL_OWNER_USER_ID_HEADER_NAME) or "").strip()
|
|
||||||
if header_owner:
|
|
||||||
allowed = await thread_store.check_access(
|
|
||||||
thread_id,
|
|
||||||
header_owner,
|
|
||||||
require_existing=require_existing,
|
|
||||||
)
|
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class GatewayConfig(BaseModel):
|
|||||||
|
|
||||||
host: str = Field(default="0.0.0.0", description="Host to bind the gateway server")
|
host: str = Field(default="0.0.0.0", description="Host to bind the gateway server")
|
||||||
port: int = Field(default=8001, description="Port to bind the gateway server")
|
port: int = Field(default=8001, description="Port to bind the gateway server")
|
||||||
enable_docs: bool = Field(default=True, description="Enable Swagger/ReDoc/OpenAPI endpoints")
|
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"], description="Allowed CORS origins")
|
||||||
|
|
||||||
|
|
||||||
_gateway_config: GatewayConfig | None = None
|
_gateway_config: GatewayConfig | None = None
|
||||||
@@ -18,9 +18,10 @@ def get_gateway_config() -> GatewayConfig:
|
|||||||
"""Get gateway config, loading from environment if available."""
|
"""Get gateway config, loading from environment if available."""
|
||||||
global _gateway_config
|
global _gateway_config
|
||||||
if _gateway_config is None:
|
if _gateway_config is None:
|
||||||
|
cors_origins_str = os.getenv("CORS_ORIGINS", "http://localhost:3000")
|
||||||
_gateway_config = GatewayConfig(
|
_gateway_config = GatewayConfig(
|
||||||
host=os.getenv("GATEWAY_HOST", "0.0.0.0"),
|
host=os.getenv("GATEWAY_HOST", "0.0.0.0"),
|
||||||
port=int(os.getenv("GATEWAY_PORT", "8001")),
|
port=int(os.getenv("GATEWAY_PORT", "8001")),
|
||||||
enable_docs=os.getenv("GATEWAY_ENABLE_DOCS", "true").lower() == "true",
|
cors_origins=cors_origins_str.split(","),
|
||||||
)
|
)
|
||||||
return _gateway_config
|
return _gateway_config
|
||||||
|
|||||||
@@ -4,18 +4,14 @@ Per RFC-001:
|
|||||||
State-changing operations require CSRF protection.
|
State-changing operations require CSRF protection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import secrets
|
import secrets
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Callable
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
from starlette.types import ASGIApp
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
from app.gateway.auth_disabled import is_auth_disabled
|
|
||||||
|
|
||||||
CSRF_COOKIE_NAME = "csrf_token"
|
CSRF_COOKIE_NAME = "csrf_token"
|
||||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||||
CSRF_TOKEN_LENGTH = 64 # bytes
|
CSRF_TOKEN_LENGTH = 64 # bytes
|
||||||
@@ -23,7 +19,7 @@ CSRF_TOKEN_LENGTH = 64 # bytes
|
|||||||
|
|
||||||
def is_secure_request(request: Request) -> bool:
|
def is_secure_request(request: Request) -> bool:
|
||||||
"""Detect whether the original client request was made over HTTPS."""
|
"""Detect whether the original client request was made over HTTPS."""
|
||||||
return _request_scheme(request) == "https"
|
return request.headers.get("x-forwarded-proto", request.url.scheme) == "https"
|
||||||
|
|
||||||
|
|
||||||
def generate_csrf_token() -> str:
|
def generate_csrf_token() -> str:
|
||||||
@@ -40,9 +36,6 @@ def should_check_csrf(request: Request) -> bool:
|
|||||||
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
|
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if is_auth_disabled():
|
|
||||||
return False
|
|
||||||
|
|
||||||
path = request.url.path.rstrip("/")
|
path = request.url.path.rstrip("/")
|
||||||
# Exempt /api/v1/auth/me endpoint
|
# Exempt /api/v1/auth/me endpoint
|
||||||
if path == "/api/v1/auth/me":
|
if path == "/api/v1/auth/me":
|
||||||
@@ -55,7 +48,6 @@ _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
|
|||||||
"/api/v1/auth/login/local",
|
"/api/v1/auth/login/local",
|
||||||
"/api/v1/auth/logout",
|
"/api/v1/auth/logout",
|
||||||
"/api/v1/auth/register",
|
"/api/v1/auth/register",
|
||||||
"/api/v1/auth/initialize",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,129 +60,15 @@ def is_auth_endpoint(request: Request) -> bool:
|
|||||||
return request.url.path.rstrip("/") in _AUTH_EXEMPT_PATHS
|
return request.url.path.rstrip("/") in _AUTH_EXEMPT_PATHS
|
||||||
|
|
||||||
|
|
||||||
def _host_with_optional_port(hostname: str, port: int | None, scheme: str) -> str:
|
|
||||||
"""Return normalized host[:port], omitting default ports."""
|
|
||||||
host = hostname.lower()
|
|
||||||
if ":" in host and not host.startswith("["):
|
|
||||||
host = f"[{host}]"
|
|
||||||
|
|
||||||
if port is None or (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
|
|
||||||
return host
|
|
||||||
return f"{host}:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_origin(origin: str) -> str | None:
|
|
||||||
"""Return a normalized scheme://host[:port] origin, or None for invalid input."""
|
|
||||||
try:
|
|
||||||
parsed = urlsplit(origin.strip())
|
|
||||||
port = parsed.port
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
scheme = parsed.scheme.lower()
|
|
||||||
if scheme not in {"http", "https"} or not parsed.hostname:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Browser Origin is only scheme/host/port. Reject URL-shaped or credentialed values.
|
|
||||||
if parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return f"{scheme}://{_host_with_optional_port(parsed.hostname, port, scheme)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _configured_cors_origins() -> set[str]:
|
|
||||||
"""Return explicit configured browser origins that may call auth routes."""
|
|
||||||
origins = set()
|
|
||||||
for raw_origin in os.environ.get("GATEWAY_CORS_ORIGINS", "").split(","):
|
|
||||||
origin = raw_origin.strip()
|
|
||||||
if not origin or origin == "*":
|
|
||||||
continue
|
|
||||||
normalized = _normalize_origin(origin)
|
|
||||||
if normalized:
|
|
||||||
origins.add(normalized)
|
|
||||||
return origins
|
|
||||||
|
|
||||||
|
|
||||||
def get_configured_cors_origins() -> set[str]:
|
|
||||||
"""Return normalized explicit browser origins from GATEWAY_CORS_ORIGINS."""
|
|
||||||
return _configured_cors_origins()
|
|
||||||
|
|
||||||
|
|
||||||
def _first_header_value(value: str | None) -> str | None:
|
|
||||||
"""Return the first value from a comma-separated proxy header."""
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
first = value.split(",", 1)[0].strip()
|
|
||||||
return first or None
|
|
||||||
|
|
||||||
|
|
||||||
def _forwarded_param(request: Request, name: str) -> str | None:
|
|
||||||
"""Extract a parameter from the first RFC 7239 Forwarded header entry."""
|
|
||||||
forwarded = _first_header_value(request.headers.get("forwarded"))
|
|
||||||
if not forwarded:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for part in forwarded.split(";"):
|
|
||||||
key, sep, value = part.strip().partition("=")
|
|
||||||
if sep and key.lower() == name:
|
|
||||||
return value.strip().strip('"') or None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _request_scheme(request: Request) -> str:
|
|
||||||
"""Resolve the original request scheme from trusted proxy headers."""
|
|
||||||
scheme = _forwarded_param(request, "proto") or _first_header_value(request.headers.get("x-forwarded-proto")) or request.url.scheme
|
|
||||||
return scheme.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _request_origin(request: Request) -> str | None:
|
|
||||||
"""Build the origin for the URL the browser is targeting."""
|
|
||||||
scheme = _request_scheme(request)
|
|
||||||
host = _forwarded_param(request, "host") or _first_header_value(request.headers.get("x-forwarded-host")) or request.headers.get("host") or request.url.netloc
|
|
||||||
|
|
||||||
forwarded_port = _first_header_value(request.headers.get("x-forwarded-port"))
|
|
||||||
if forwarded_port and ":" not in host.rsplit("]", 1)[-1]:
|
|
||||||
host = f"{host}:{forwarded_port}"
|
|
||||||
|
|
||||||
return _normalize_origin(f"{scheme}://{host}")
|
|
||||||
|
|
||||||
|
|
||||||
def is_allowed_auth_origin(request: Request) -> bool:
|
|
||||||
"""Allow auth POSTs only from the same origin or explicit configured origins.
|
|
||||||
|
|
||||||
Login/register/initialize are exempt from the double-submit token because
|
|
||||||
first-time browser clients do not have a CSRF token yet. They still create
|
|
||||||
a session cookie, so browser requests with a hostile Origin header must be
|
|
||||||
rejected to prevent login CSRF / session fixation. Requests without Origin
|
|
||||||
are allowed for non-browser clients such as curl and mobile integrations.
|
|
||||||
"""
|
|
||||||
origin = request.headers.get("origin")
|
|
||||||
if not origin:
|
|
||||||
return True
|
|
||||||
|
|
||||||
normalized_origin = _normalize_origin(origin)
|
|
||||||
if normalized_origin is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
request_origin = _request_origin(request)
|
|
||||||
return normalized_origin in _configured_cors_origins() or (request_origin is not None and normalized_origin == request_origin)
|
|
||||||
|
|
||||||
|
|
||||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||||
"""Middleware that implements CSRF protection using Double Submit Cookie pattern."""
|
"""Middleware that implements CSRF protection using Double Submit Cookie pattern."""
|
||||||
|
|
||||||
def __init__(self, app: ASGIApp) -> None:
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
super().__init__(app)
|
super().__init__(app)
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
_is_auth = is_auth_endpoint(request)
|
_is_auth = is_auth_endpoint(request)
|
||||||
|
|
||||||
if should_check_csrf(request) and _is_auth and not is_allowed_auth_origin(request):
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=403,
|
|
||||||
content={"detail": "Cross-site auth request denied."},
|
|
||||||
)
|
|
||||||
|
|
||||||
if should_check_csrf(request) and not _is_auth:
|
if should_check_csrf(request) and not _is_auth:
|
||||||
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
||||||
header_token = request.headers.get(CSRF_HEADER_NAME)
|
header_token = request.headers.get(CSRF_HEADER_NAME)
|
||||||
|
|||||||
+37
-211
@@ -1,265 +1,108 @@
|
|||||||
"""Centralized accessors for singleton objects stored on ``app.state``.
|
"""Centralized accessors for singleton objects stored on ``app.state``.
|
||||||
|
|
||||||
**Getters** (used by routers): raise 503 when a required dependency is
|
**Getters** (used by routers): raise 503 when a required dependency is
|
||||||
missing, except ``get_store`` which returns ``None``.
|
missing, except ``get_store`` and ``get_thread_meta_repo`` which return
|
||||||
|
``None``.
|
||||||
``AppConfig`` is intentionally *not* cached on ``app.state``. Routers and the
|
|
||||||
run path resolve it through :func:`deerflow.config.app_config.get_app_config`,
|
|
||||||
which performs mtime-based hot reload, so edits to ``config.yaml`` take
|
|
||||||
effect on the next request without a process restart. The engines created in
|
|
||||||
:func:`langgraph_runtime` (stream bridge, persistence, checkpointer, store,
|
|
||||||
run-event store) accept a ``startup_config`` snapshot — they are
|
|
||||||
restart-required by design and stay bound to that snapshot to keep the live
|
|
||||||
process consistent with itself.
|
|
||||||
|
|
||||||
Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
|
Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from collections.abc import AsyncGenerator
|
||||||
import logging
|
|
||||||
from collections.abc import AsyncGenerator, Callable
|
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from typing import TYPE_CHECKING, TypeVar, cast
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from langgraph.types import Checkpointer
|
|
||||||
|
|
||||||
from deerflow.config.app_config import AppConfig, get_app_config
|
|
||||||
from deerflow.persistence.feedback import FeedbackRepository
|
|
||||||
from deerflow.runtime import RunContext, RunManager, StreamBridge
|
|
||||||
from deerflow.runtime.events.store.base import RunEventStore
|
|
||||||
from deerflow.runtime.runs.store.base import RunStore
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Upper bound (seconds) for draining in-flight runs during shutdown, before the
|
|
||||||
# AsyncExitStack tears down the checkpointer (and its connection pool). Kept
|
|
||||||
# local to avoid an app -> deps -> app import cycle. This is a *separate* budget
|
|
||||||
# from ``app.gateway.app._SHUTDOWN_HOOK_TIMEOUT_SECONDS`` (currently also 5.0s,
|
|
||||||
# which bounds channel-service stop): the two govern independent teardown steps
|
|
||||||
# and may diverge, but both count toward the lifespan shutdown window — revisit
|
|
||||||
# them together if their sum must stay within the server's graceful-shutdown
|
|
||||||
# timeout.
|
|
||||||
_RUN_DRAIN_TIMEOUT_SECONDS = 5.0
|
|
||||||
|
|
||||||
|
|
||||||
async def _drain_inflight_runs(run_manager: RunManager) -> None:
|
|
||||||
"""Drain in-flight runs before the checkpointer is torn down (issue #3373).
|
|
||||||
|
|
||||||
Shields the (internally-bounded) drain so that even if the lifespan
|
|
||||||
coroutine is itself cancelled mid-shutdown — a second SIGINT or the server's
|
|
||||||
graceful-shutdown timeout, i.e. the same signal storm behind #3373 — the
|
|
||||||
checkpointer pool is not closed while run tasks are still writing
|
|
||||||
checkpoints. On such a cancellation we let the already-running drain finish
|
|
||||||
(it is bounded by ``RunManager.shutdown``'s own timeout) and then propagate
|
|
||||||
the cancellation.
|
|
||||||
"""
|
|
||||||
drain = asyncio.create_task(run_manager.shutdown(timeout=_RUN_DRAIN_TIMEOUT_SECONDS))
|
|
||||||
try:
|
|
||||||
await asyncio.shield(drain)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# Re-shield so this second wait does not abandon the in-flight drain;
|
|
||||||
# it is bounded, so this cannot hang. Then re-raise to honour shutdown.
|
|
||||||
try:
|
|
||||||
await asyncio.shield(drain)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("In-flight run drain failed after shutdown cancellation")
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to drain in-flight runs during shutdown")
|
|
||||||
|
|
||||||
|
from deerflow.runtime import RunContext, RunManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.gateway.auth.local_provider import LocalAuthProvider
|
from app.gateway.auth.local_provider import LocalAuthProvider
|
||||||
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
|
||||||
from deerflow.runtime import RunRecord
|
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
async def _mark_latest_recovered_threads_error(
|
|
||||||
run_manager: RunManager,
|
|
||||||
thread_store: ThreadMetaStore,
|
|
||||||
recovered_runs: list[RunRecord],
|
|
||||||
) -> None:
|
|
||||||
"""Mark thread status as error only when its newest run was recovered."""
|
|
||||||
recovered_by_thread: dict[str, set[str]] = {}
|
|
||||||
for record in recovered_runs:
|
|
||||||
recovered_by_thread.setdefault(record.thread_id, set()).add(record.run_id)
|
|
||||||
|
|
||||||
for thread_id, recovered_run_ids in recovered_by_thread.items():
|
|
||||||
try:
|
|
||||||
latest_runs = await run_manager.list_by_thread(thread_id, user_id=None, limit=1)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to find latest run for thread %s during run reconciliation", thread_id, exc_info=True)
|
|
||||||
continue
|
|
||||||
if not latest_runs or latest_runs[0].run_id not in recovered_run_ids:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await thread_store.update_status(thread_id, "error", user_id=None)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to mark thread %s as error during run reconciliation", thread_id, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> AppConfig:
|
|
||||||
"""Return the freshest ``AppConfig`` for the current request.
|
|
||||||
|
|
||||||
Routes through :func:`deerflow.config.app_config.get_app_config`, which
|
|
||||||
honours runtime ``ContextVar`` overrides and reloads ``config.yaml`` from
|
|
||||||
disk when its mtime changes. ``AppConfig`` is not cached on ``app.state``
|
|
||||||
at all — the only startup-time snapshot lives as a local
|
|
||||||
``startup_config`` variable inside ``lifespan()`` and is passed
|
|
||||||
explicitly into :func:`langgraph_runtime` for the engines that are
|
|
||||||
restart-required by design. Routing every request through
|
|
||||||
:func:`get_app_config` closes the bytedance/deer-flow issue #3107 BUG-001
|
|
||||||
split-brain where the worker / lead-agent thread saw a stale startup
|
|
||||||
snapshot.
|
|
||||||
|
|
||||||
Hot-reload boundary: fields backed by startup-time singletons
|
|
||||||
(engines, sandbox provider, IM channels, logging handler) require a
|
|
||||||
process restart to change at runtime. The authoritative list lives in
|
|
||||||
:mod:`deerflow.config.reload_boundary` and is mirrored by the
|
|
||||||
standardised ``"startup-only:"`` prefix on the matching
|
|
||||||
``Field(description=...)`` in :class:`AppConfig` — IDE hover on those
|
|
||||||
fields will surface the boundary inline. See
|
|
||||||
``backend/CLAUDE.md`` "Config Hot-Reload Boundary" for the operator
|
|
||||||
summary.
|
|
||||||
|
|
||||||
Any failure to materialise the config (missing file, permission denied,
|
|
||||||
YAML parse error, validation error) is reported as 503 — semantically
|
|
||||||
"the gateway cannot serve requests without a usable configuration" — and
|
|
||||||
logged with the original exception so operators have something to debug.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return get_app_config()
|
|
||||||
except Exception as exc: # noqa: BLE001 - request boundary: log and degrade gracefully
|
|
||||||
logger.exception("Failed to load AppConfig at request time")
|
|
||||||
raise HTTPException(status_code=503, detail="Configuration not available") from exc
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGenerator[None, None]:
|
async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Bootstrap and tear down all LangGraph runtime singletons.
|
"""Bootstrap and tear down all LangGraph runtime singletons.
|
||||||
|
|
||||||
``startup_config`` is the ``AppConfig`` snapshot taken once during
|
|
||||||
``lifespan()`` for one-shot infrastructure bootstrap. The engines and
|
|
||||||
stores constructed here (stream bridge, persistence engine, checkpointer,
|
|
||||||
store, run-event store) are restart-required by design — they hold live
|
|
||||||
connections, file handles, or singleton providers — so they bind to this
|
|
||||||
snapshot and survive across `config.yaml` edits. Request-time consumers
|
|
||||||
must still go through :func:`get_config` for any field that should be
|
|
||||||
hot-reloadable. See ``backend/CLAUDE.md`` "Config Hot-Reload Boundary".
|
|
||||||
|
|
||||||
The matching ``run_events_config`` is frozen onto ``app.state`` so
|
|
||||||
:func:`get_run_context` pairs a freshly-loaded ``AppConfig`` with the
|
|
||||||
*startup-time* run-events configuration the underlying ``event_store``
|
|
||||||
was built from — otherwise the runtime could end up combining a live
|
|
||||||
new ``run_events_config`` with an event store still bound to the
|
|
||||||
previous backend.
|
|
||||||
|
|
||||||
Usage in ``app.py``::
|
Usage in ``app.py``::
|
||||||
|
|
||||||
async with langgraph_runtime(app, startup_config):
|
async with langgraph_runtime(app):
|
||||||
yield
|
yield
|
||||||
"""
|
"""
|
||||||
|
from deerflow.agents.checkpointer.async_provider import make_checkpointer
|
||||||
|
from deerflow.config import get_app_config
|
||||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
||||||
from deerflow.runtime import make_store, make_stream_bridge
|
from deerflow.runtime import make_store, make_stream_bridge
|
||||||
from deerflow.runtime.checkpointer.async_provider import make_checkpointer
|
|
||||||
from deerflow.runtime.events.store import make_run_event_store
|
from deerflow.runtime.events.store import make_run_event_store
|
||||||
|
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
config = startup_config
|
app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge())
|
||||||
|
|
||||||
app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge(config))
|
|
||||||
|
|
||||||
# Initialize persistence engine BEFORE checkpointer so that
|
# Initialize persistence engine BEFORE checkpointer so that
|
||||||
# auto-create-database logic runs first (postgres backend).
|
# auto-create-database logic runs first (postgres backend).
|
||||||
|
config = get_app_config()
|
||||||
await init_engine_from_config(config.database)
|
await init_engine_from_config(config.database)
|
||||||
|
|
||||||
app.state.checkpointer = await stack.enter_async_context(make_checkpointer(config))
|
app.state.checkpointer = await stack.enter_async_context(make_checkpointer())
|
||||||
app.state.store = await stack.enter_async_context(make_store(config))
|
app.state.store = await stack.enter_async_context(make_store())
|
||||||
|
|
||||||
# Initialize repositories — one get_session_factory() call for all.
|
# Initialize repositories — one get_session_factory() call for all.
|
||||||
sf = get_session_factory()
|
sf = get_session_factory()
|
||||||
if sf is not None:
|
if sf is not None:
|
||||||
from deerflow.persistence.feedback import FeedbackRepository
|
from deerflow.persistence.feedback import FeedbackRepository
|
||||||
from deerflow.persistence.run import RunRepository
|
from deerflow.persistence.run import RunRepository
|
||||||
|
from deerflow.persistence.thread_meta import ThreadMetaRepository
|
||||||
|
|
||||||
app.state.run_store = RunRepository(sf)
|
app.state.run_store = RunRepository(sf)
|
||||||
app.state.feedback_repo = FeedbackRepository(sf)
|
app.state.feedback_repo = FeedbackRepository(sf)
|
||||||
|
app.state.thread_meta_repo = ThreadMetaRepository(sf)
|
||||||
else:
|
else:
|
||||||
|
from deerflow.persistence.thread_meta import MemoryThreadMetaStore
|
||||||
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||||
|
|
||||||
app.state.run_store = MemoryRunStore()
|
app.state.run_store = MemoryRunStore()
|
||||||
app.state.feedback_repo = None
|
app.state.feedback_repo = None
|
||||||
|
app.state.thread_meta_repo = MemoryThreadMetaStore(app.state.store)
|
||||||
|
|
||||||
from deerflow.persistence.thread_meta import make_thread_store
|
# Run event store (has its own factory with config-driven backend selection)
|
||||||
|
|
||||||
app.state.thread_store = make_thread_store(sf, app.state.store)
|
|
||||||
|
|
||||||
# Run event store. The store and the matching ``run_events_config`` are
|
|
||||||
# both frozen at startup so ``get_run_context`` does not combine a
|
|
||||||
# freshly-reloaded ``AppConfig.run_events`` with a store still bound to
|
|
||||||
# the previous backend.
|
|
||||||
run_events_config = getattr(config, "run_events", None)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_events_config = run_events_config
|
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||||
|
|
||||||
# RunManager with store backing for persistence
|
# RunManager with store backing for persistence
|
||||||
app.state.run_manager = RunManager(store=app.state.run_store)
|
app.state.run_manager = RunManager(store=app.state.run_store)
|
||||||
if getattr(config.database, "backend", None) == "sqlite":
|
|
||||||
from deerflow.utils.time import now_iso
|
|
||||||
|
|
||||||
# Startup-only recovery: clean shutdowns return no active rows and
|
|
||||||
# the thread-status update below becomes a no-op.
|
|
||||||
recovered_runs = await app.state.run_manager.reconcile_orphaned_inflight_runs(
|
|
||||||
error="Gateway restarted before this run reached a durable final state.",
|
|
||||||
before=now_iso(),
|
|
||||||
)
|
|
||||||
await _mark_latest_recovered_threads_error(app.state.run_manager, app.state.thread_store, recovered_runs)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
# Drain in-flight run tasks BEFORE the AsyncExitStack tears down the
|
|
||||||
# checkpointer (and its connection pool). A run still mid-graph would
|
|
||||||
# otherwise leak into asyncio.run() shutdown, where langgraph's
|
|
||||||
# _checkpointer_put_after_previous aput races the closed pool and
|
|
||||||
# raises PoolClosed (issue #3373).
|
|
||||||
run_manager = getattr(app.state, "run_manager", None)
|
|
||||||
if run_manager is not None:
|
|
||||||
await _drain_inflight_runs(run_manager)
|
|
||||||
await close_engine()
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Getters – called by routers per-request
|
# Getters -- called by routers per-request
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _require(attr: str, label: str) -> Callable[[Request], T]:
|
def _require(attr: str, label: str):
|
||||||
"""Create a FastAPI dependency that returns ``app.state.<attr>`` or 503."""
|
"""Create a FastAPI dependency that returns ``app.state.<attr>`` or 503."""
|
||||||
|
|
||||||
def dep(request: Request) -> T:
|
def dep(request: Request):
|
||||||
val = getattr(request.app.state, attr, None)
|
val = getattr(request.app.state, attr, None)
|
||||||
if val is None:
|
if val is None:
|
||||||
raise HTTPException(status_code=503, detail=f"{label} not available")
|
raise HTTPException(status_code=503, detail=f"{label} not available")
|
||||||
return cast(T, val)
|
return val
|
||||||
|
|
||||||
dep.__name__ = dep.__qualname__ = f"get_{attr}"
|
dep.__name__ = dep.__qualname__ = f"get_{attr}"
|
||||||
return dep
|
return dep
|
||||||
|
|
||||||
|
|
||||||
get_stream_bridge: Callable[[Request], StreamBridge] = _require("stream_bridge", "Stream bridge")
|
get_stream_bridge = _require("stream_bridge", "Stream bridge")
|
||||||
get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run manager")
|
get_run_manager = _require("run_manager", "Run manager")
|
||||||
get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
|
get_checkpointer = _require("checkpointer", "Checkpointer")
|
||||||
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
|
get_run_event_store = _require("run_event_store", "Run event store")
|
||||||
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
|
get_feedback_repo = _require("feedback_repo", "Feedback")
|
||||||
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
|
get_run_store = _require("run_store", "Run store")
|
||||||
|
|
||||||
|
|
||||||
def get_store(request: Request):
|
def get_store(request: Request):
|
||||||
@@ -267,31 +110,25 @@ def get_store(request: Request):
|
|||||||
return getattr(request.app.state, "store", None)
|
return getattr(request.app.state, "store", None)
|
||||||
|
|
||||||
|
|
||||||
def get_thread_store(request: Request) -> ThreadMetaStore:
|
get_thread_meta_repo = _require("thread_meta_repo", "Thread metadata store")
|
||||||
"""Return the thread metadata store (SQL or memory-backed)."""
|
|
||||||
val = getattr(request.app.state, "thread_store", None)
|
|
||||||
if val is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Thread metadata store not available")
|
|
||||||
return val
|
|
||||||
|
|
||||||
|
|
||||||
def get_run_context(request: Request) -> RunContext:
|
def get_run_context(request: Request) -> RunContext:
|
||||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
||||||
|
|
||||||
Returns a *base* context with infrastructure dependencies. The
|
Returns a *base* context with infrastructure dependencies. Callers that
|
||||||
``app_config`` field is resolved live so per-run fields (e.g.
|
need per-run fields (e.g. ``follow_up_to_run_id``) should use
|
||||||
``models[*].max_tokens``) follow ``config.yaml`` edits; the
|
``dataclasses.replace(ctx, follow_up_to_run_id=...)`` before passing it
|
||||||
``event_store`` / ``run_events_config`` pair stays frozen to the snapshot
|
to :func:`run_agent`.
|
||||||
captured in :func:`langgraph_runtime` so callers never see a store bound
|
|
||||||
to one backend paired with a config pointing at another.
|
|
||||||
"""
|
"""
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
|
||||||
return RunContext(
|
return RunContext(
|
||||||
checkpointer=get_checkpointer(request),
|
checkpointer=get_checkpointer(request),
|
||||||
store=get_store(request),
|
store=get_store(request),
|
||||||
event_store=get_run_event_store(request),
|
event_store=get_run_event_store(request),
|
||||||
run_events_config=getattr(request.app.state, "run_events_config", None),
|
run_events_config=getattr(get_app_config(), "run_events", None),
|
||||||
thread_store=get_thread_store(request),
|
thread_meta_repo=get_thread_meta_repo(request),
|
||||||
app_config=get_config(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -331,17 +168,6 @@ async def get_current_user_from_request(request: Request):
|
|||||||
|
|
||||||
Raises HTTPException 401 if not authenticated.
|
Raises HTTPException 401 if not authenticated.
|
||||||
"""
|
"""
|
||||||
state = getattr(request, "state", None)
|
|
||||||
state_user = getattr(state, "user", None)
|
|
||||||
from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_INTERNAL, AUTH_SOURCE_SESSION
|
|
||||||
|
|
||||||
if state_user is not None and getattr(state, "auth_source", None) in {
|
|
||||||
AUTH_SOURCE_SESSION,
|
|
||||||
AUTH_SOURCE_AUTH_DISABLED,
|
|
||||||
AUTH_SOURCE_INTERNAL,
|
|
||||||
}:
|
|
||||||
return state_user
|
|
||||||
|
|
||||||
from app.gateway.auth import decode_token
|
from app.gateway.auth import decode_token
|
||||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code
|
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
"""Authentication for trusted Gateway internal callers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from deerflow.runtime.user_context import DEFAULT_USER_ID
|
|
||||||
|
|
||||||
INTERNAL_AUTH_HEADER_NAME = "X-DeerFlow-Internal-Token"
|
|
||||||
INTERNAL_OWNER_USER_ID_HEADER_NAME = "X-DeerFlow-Owner-User-Id"
|
|
||||||
INTERNAL_AUTH_ENV_VAR = "DEER_FLOW_INTERNAL_AUTH_TOKEN"
|
|
||||||
INTERNAL_SYSTEM_ROLE = "internal"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_internal_auth_token() -> str:
|
|
||||||
token = os.environ.get(INTERNAL_AUTH_ENV_VAR)
|
|
||||||
if token:
|
|
||||||
return token
|
|
||||||
return secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
|
|
||||||
_INTERNAL_AUTH_TOKEN = _load_internal_auth_token()
|
|
||||||
|
|
||||||
|
|
||||||
def create_internal_auth_headers(*, owner_user_id: str | None = None) -> dict[str, str]:
|
|
||||||
"""Return headers that authenticate trusted Gateway internal calls."""
|
|
||||||
headers = {INTERNAL_AUTH_HEADER_NAME: _INTERNAL_AUTH_TOKEN}
|
|
||||||
if owner_user_id:
|
|
||||||
headers[INTERNAL_OWNER_USER_ID_HEADER_NAME] = owner_user_id
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_internal_auth_token(token: str | None) -> bool:
|
|
||||||
"""Return True when *token* matches this Gateway worker's internal token."""
|
|
||||||
return bool(token) and secrets.compare_digest(token, _INTERNAL_AUTH_TOKEN)
|
|
||||||
|
|
||||||
|
|
||||||
def get_internal_user():
|
|
||||||
"""Return the synthetic user used for trusted internal channel calls."""
|
|
||||||
return SimpleNamespace(id=DEFAULT_USER_ID, system_role=INTERNAL_SYSTEM_ROLE)
|
|
||||||
|
|
||||||
|
|
||||||
def get_trusted_internal_owner_user_id(request: Any) -> str | None:
|
|
||||||
"""Return the owner override for a trusted internal request, if present.
|
|
||||||
|
|
||||||
The header is ignored for normal browser/API callers. It is only honored
|
|
||||||
after ``AuthMiddleware`` has validated the internal auth token and stamped
|
|
||||||
the synthetic internal user onto ``request.state.user``.
|
|
||||||
"""
|
|
||||||
user = getattr(getattr(request, "state", None), "user", None)
|
|
||||||
if getattr(user, "system_role", None) != INTERNAL_SYSTEM_ROLE:
|
|
||||||
return None
|
|
||||||
|
|
||||||
owner_user_id = request.headers.get(INTERNAL_OWNER_USER_ID_HEADER_NAME)
|
|
||||||
if not owner_user_id:
|
|
||||||
return None
|
|
||||||
owner_user_id = owner_user_id.strip()
|
|
||||||
return owner_user_id or None
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
"""LangGraph compatibility auth handler — shares JWT logic with Gateway.
|
"""LangGraph Server auth handler — shares JWT logic with Gateway.
|
||||||
|
|
||||||
The default DeerFlow runtime is embedded in the FastAPI Gateway; scripts and
|
Loaded by LangGraph Server via langgraph.json ``auth.path``.
|
||||||
Docker deployments do not load this module. It is retained for LangGraph
|
Reuses the same ``decode_token`` / ``get_auth_config`` as Gateway,
|
||||||
tooling, Studio, or direct LangGraph Server compatibility through
|
so both modes validate tokens with the same secret and rules.
|
||||||
``langgraph.json``'s ``auth.path``.
|
|
||||||
|
|
||||||
When that compatibility path is used, this module reuses the same JWT and CSRF
|
|
||||||
rules as Gateway so both modes validate sessions consistently.
|
|
||||||
|
|
||||||
Two layers:
|
Two layers:
|
||||||
1. @auth.authenticate — validates JWT cookie, extracts user_id,
|
1. @auth.authenticate — validates JWT cookie, extracts user_id,
|
||||||
@@ -20,7 +16,6 @@ from langgraph_sdk import Auth
|
|||||||
|
|
||||||
from app.gateway.auth.errors import TokenError
|
from app.gateway.auth.errors import TokenError
|
||||||
from app.gateway.auth.jwt import decode_token
|
from app.gateway.auth.jwt import decode_token
|
||||||
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, is_auth_disabled
|
|
||||||
from app.gateway.deps import get_local_provider
|
from app.gateway.deps import get_local_provider
|
||||||
|
|
||||||
auth = Auth()
|
auth = Auth()
|
||||||
@@ -39,9 +34,6 @@ def _check_csrf(request) -> None:
|
|||||||
if method.upper() not in _CSRF_METHODS:
|
if method.upper() not in _CSRF_METHODS:
|
||||||
return
|
return
|
||||||
|
|
||||||
if is_auth_disabled():
|
|
||||||
return
|
|
||||||
|
|
||||||
cookie_token = request.cookies.get("csrf_token")
|
cookie_token = request.cookies.get("csrf_token")
|
||||||
header_token = request.headers.get("x-csrf-token")
|
header_token = request.headers.get("x-csrf-token")
|
||||||
|
|
||||||
@@ -70,9 +62,6 @@ async def authenticate(request):
|
|||||||
# are rejected early, even if the cookie carries a valid JWT.
|
# are rejected early, even if the cookie carries a valid JWT.
|
||||||
_check_csrf(request)
|
_check_csrf(request)
|
||||||
|
|
||||||
if is_auth_disabled():
|
|
||||||
return AUTH_DISABLED_USER_ID
|
|
||||||
|
|
||||||
token = request.cookies.get("access_token")
|
token = request.cookies.get("access_token")
|
||||||
if not token:
|
if not token:
|
||||||
raise Auth.exceptions.HTTPException(
|
raise Auth.exceptions.HTTPException(
|
||||||
@@ -84,7 +73,7 @@ async def authenticate(request):
|
|||||||
if isinstance(payload, TokenError):
|
if isinstance(payload, TokenError):
|
||||||
raise Auth.exceptions.HTTPException(
|
raise Auth.exceptions.HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Invalid token",
|
detail=f"Token error: {payload.value}",
|
||||||
)
|
)
|
||||||
|
|
||||||
user = await get_local_provider().get_user(payload.sub)
|
user = await get_local_provider().get_user(payload.sub)
|
||||||
@@ -104,14 +93,14 @@ async def authenticate(request):
|
|||||||
|
|
||||||
@auth.on
|
@auth.on
|
||||||
async def add_owner_filter(ctx: Auth.types.AuthContext, value: dict):
|
async def add_owner_filter(ctx: Auth.types.AuthContext, value: dict):
|
||||||
"""Inject user_id metadata on writes; filter by user_id on reads.
|
"""Inject owner_id metadata on writes; filter by owner_id on reads.
|
||||||
|
|
||||||
Gateway stores thread ownership as ``metadata.user_id``.
|
Gateway stores thread ownership as ``metadata.owner_id``.
|
||||||
This handler ensures LangGraph Server enforces the same isolation.
|
This handler ensures LangGraph Server enforces the same isolation.
|
||||||
"""
|
"""
|
||||||
# On create/update: stamp user_id into metadata
|
# On create/update: stamp owner_id into metadata
|
||||||
metadata = value.setdefault("metadata", {})
|
metadata = value.setdefault("metadata", {})
|
||||||
metadata["user_id"] = ctx.user.identity
|
metadata["owner_id"] = ctx.user.identity
|
||||||
|
|
||||||
# Return filter dict — LangGraph applies it to search/read/delete
|
# Return filter dict — LangGraph applies it to search/read/delete
|
||||||
return {"user_id": ctx.user.identity}
|
return {"owner_id": ctx.user.identity}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
"""Shared pagination helpers for gateway routers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def trim_run_message_page(rows: list[dict], *, limit: int, after_seq: int | None) -> tuple[list[dict], bool]:
|
|
||||||
"""Trim a ``limit + 1`` run-message page while preserving page boundaries."""
|
|
||||||
has_more = len(rows) > limit
|
|
||||||
if not has_more:
|
|
||||||
return rows, False
|
|
||||||
|
|
||||||
if after_seq is not None:
|
|
||||||
return rows[:limit], True
|
|
||||||
|
|
||||||
return rows[-limit:], True
|
|
||||||
@@ -5,7 +5,6 @@ from pathlib import Path
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_thread_virtual_path(thread_id: str, virtual_path: str) -> Path:
|
def resolve_thread_virtual_path(thread_id: str, virtual_path: str) -> Path:
|
||||||
@@ -23,7 +22,7 @@ def resolve_thread_virtual_path(thread_id: str, virtual_path: str) -> Path:
|
|||||||
HTTPException: If the path is invalid or outside allowed directories.
|
HTTPException: If the path is invalid or outside allowed directories.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return get_paths().resolve_virtual_path(thread_id, virtual_path, user_id=get_effective_user_id())
|
return get_paths().resolve_virtual_path(thread_id, virtual_path)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
status = 403 if "traversal" in str(e) else 400
|
status = 403 if "traversal" in str(e) else 400
|
||||||
raise HTTPException(status_code=status, detail=str(e))
|
raise HTTPException(status_code=status, detail=str(e))
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""CRUD API for custom agents."""
|
"""CRUD API for custom agents."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -9,10 +8,8 @@ import yaml
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from deerflow.config.agents_api_config import get_agents_api_config
|
|
||||||
from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul
|
from deerflow.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["agents"])
|
router = APIRouter(prefix="/api", tags=["agents"])
|
||||||
@@ -27,7 +24,6 @@ class AgentResponse(BaseModel):
|
|||||||
description: str = Field(default="", description="Agent description")
|
description: str = Field(default="", description="Agent description")
|
||||||
model: str | None = Field(default=None, description="Optional model override")
|
model: str | None = Field(default=None, description="Optional model override")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
||||||
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all, []=none)")
|
|
||||||
soul: str | None = Field(default=None, description="SOUL.md content")
|
soul: str | None = Field(default=None, description="SOUL.md content")
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +40,6 @@ class AgentCreateRequest(BaseModel):
|
|||||||
description: str = Field(default="", description="Agent description")
|
description: str = Field(default="", description="Agent description")
|
||||||
model: str | None = Field(default=None, description="Optional model override")
|
model: str | None = Field(default=None, description="Optional model override")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
||||||
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all enabled, []=none)")
|
|
||||||
soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails")
|
soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails")
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +49,6 @@ class AgentUpdateRequest(BaseModel):
|
|||||||
description: str | None = Field(default=None, description="Updated description")
|
description: str | None = Field(default=None, description="Updated description")
|
||||||
model: str | None = Field(default=None, description="Updated model override")
|
model: str | None = Field(default=None, description="Updated model override")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
|
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
|
||||||
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (None=all, []=none)")
|
|
||||||
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||||
|
|
||||||
|
|
||||||
@@ -79,27 +73,17 @@ def _normalize_agent_name(name: str) -> str:
|
|||||||
return name.lower()
|
return name.lower()
|
||||||
|
|
||||||
|
|
||||||
def _require_agents_api_enabled() -> None:
|
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse:
|
||||||
"""Reject access unless the custom-agent management API is explicitly enabled."""
|
|
||||||
if not get_agents_api_config().enabled:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=("Custom-agent management API is disabled. Set agents_api.enabled=true to expose agent and user-profile routes over HTTP."),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False, *, user_id: str | None = None) -> AgentResponse:
|
|
||||||
"""Convert AgentConfig to AgentResponse."""
|
"""Convert AgentConfig to AgentResponse."""
|
||||||
soul: str | None = None
|
soul: str | None = None
|
||||||
if include_soul:
|
if include_soul:
|
||||||
soul = load_agent_soul(agent_cfg.name, user_id=user_id) or ""
|
soul = load_agent_soul(agent_cfg.name) or ""
|
||||||
|
|
||||||
return AgentResponse(
|
return AgentResponse(
|
||||||
name=agent_cfg.name,
|
name=agent_cfg.name,
|
||||||
description=agent_cfg.description,
|
description=agent_cfg.description,
|
||||||
model=agent_cfg.model,
|
model=agent_cfg.model,
|
||||||
tool_groups=agent_cfg.tool_groups,
|
tool_groups=agent_cfg.tool_groups,
|
||||||
skills=agent_cfg.skills,
|
|
||||||
soul=soul,
|
soul=soul,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,12 +100,9 @@ async def list_agents() -> AgentsListResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
List of all custom agents with their metadata and soul content.
|
List of all custom agents with their metadata and soul content.
|
||||||
"""
|
"""
|
||||||
_require_agents_api_enabled()
|
|
||||||
|
|
||||||
user_id = get_effective_user_id()
|
|
||||||
try:
|
try:
|
||||||
agents = list_custom_agents(user_id=user_id)
|
agents = list_custom_agents()
|
||||||
return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True, user_id=user_id) for a in agents])
|
return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True) for a in agents])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list agents: {e}", exc_info=True)
|
logger.error(f"Failed to list agents: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}")
|
||||||
@@ -144,15 +125,9 @@ async def check_agent_name(name: str) -> dict:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 422 if the name is invalid.
|
HTTPException: 422 if the name is invalid.
|
||||||
"""
|
"""
|
||||||
_require_agents_api_enabled()
|
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
normalized = _normalize_agent_name(name)
|
normalized = _normalize_agent_name(name)
|
||||||
user_id = get_effective_user_id()
|
available = not get_paths().agent_dir(normalized).exists()
|
||||||
paths = get_paths()
|
|
||||||
# Treat the name as taken if either the per-user path or the legacy shared
|
|
||||||
# path holds an agent — picking a name that collides with an unmigrated
|
|
||||||
# legacy agent would shadow the legacy entry once migration runs.
|
|
||||||
available = not paths.user_agent_dir(user_id, normalized).exists() and not paths.agent_dir(normalized).exists()
|
|
||||||
return {"available": available, "name": normalized}
|
return {"available": available, "name": normalized}
|
||||||
|
|
||||||
|
|
||||||
@@ -174,14 +149,12 @@ async def get_agent(name: str) -> AgentResponse:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if agent not found.
|
HTTPException: 404 if agent not found.
|
||||||
"""
|
"""
|
||||||
_require_agents_api_enabled()
|
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
user_id = get_effective_user_id()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_cfg = load_agent_config(name, user_id=user_id)
|
agent_cfg = load_agent_config(name)
|
||||||
return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
|
return _agent_config_to_response(agent_cfg, include_soul=True)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -208,66 +181,47 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 409 if agent already exists, 422 if name is invalid.
|
HTTPException: 409 if agent already exists, 422 if name is invalid.
|
||||||
"""
|
"""
|
||||||
_require_agents_api_enabled()
|
|
||||||
_validate_agent_name(request.name)
|
_validate_agent_name(request.name)
|
||||||
normalized_name = _normalize_agent_name(request.name)
|
normalized_name = _normalize_agent_name(request.name)
|
||||||
user_id = get_effective_user_id()
|
|
||||||
paths = get_paths()
|
|
||||||
|
|
||||||
def _create_agent() -> AgentResponse | None:
|
agent_dir = get_paths().agent_dir(normalized_name)
|
||||||
# Worker thread: base-dir resolution, existence checks, directory/file
|
|
||||||
# creation, read-back, and failure cleanup are all blocking filesystem
|
|
||||||
# IO that must stay off the event loop.
|
|
||||||
agent_dir = paths.user_agent_dir(user_id, normalized_name)
|
|
||||||
legacy_dir = paths.agent_dir(normalized_name)
|
|
||||||
|
|
||||||
if legacy_dir.exists():
|
if agent_dir.exists():
|
||||||
return None # signals 409 to the caller
|
|
||||||
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
agent_dir.mkdir(parents=True, exist_ok=False)
|
|
||||||
except FileExistsError:
|
|
||||||
return None # signals 409 to the caller
|
|
||||||
# Write config.yaml
|
|
||||||
config_data: dict = {"name": normalized_name}
|
|
||||||
if request.description:
|
|
||||||
config_data["description"] = request.description
|
|
||||||
if request.model is not None:
|
|
||||||
config_data["model"] = request.model
|
|
||||||
if request.tool_groups is not None:
|
|
||||||
config_data["tool_groups"] = request.tool_groups
|
|
||||||
if request.skills is not None:
|
|
||||||
config_data["skills"] = request.skills
|
|
||||||
|
|
||||||
config_file = agent_dir / "config.yaml"
|
|
||||||
with open(config_file, "w", encoding="utf-8") as f:
|
|
||||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
|
||||||
|
|
||||||
# Write SOUL.md
|
|
||||||
soul_file = agent_dir / "SOUL.md"
|
|
||||||
soul_file.write_text(request.soul, encoding="utf-8")
|
|
||||||
|
|
||||||
logger.info(f"Created agent '{normalized_name}' at {agent_dir}")
|
|
||||||
|
|
||||||
agent_cfg = load_agent_config(normalized_name, user_id=user_id)
|
|
||||||
return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
|
|
||||||
except Exception:
|
|
||||||
# Clean up partial state on failure before surfacing the error.
|
|
||||||
if agent_dir.exists():
|
|
||||||
shutil.rmtree(agent_dir)
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await asyncio.to_thread(_create_agent)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}")
|
|
||||||
|
|
||||||
if response is None:
|
|
||||||
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
|
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
|
||||||
|
|
||||||
return response
|
try:
|
||||||
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write config.yaml
|
||||||
|
config_data: dict = {"name": normalized_name}
|
||||||
|
if request.description:
|
||||||
|
config_data["description"] = request.description
|
||||||
|
if request.model is not None:
|
||||||
|
config_data["model"] = request.model
|
||||||
|
if request.tool_groups is not None:
|
||||||
|
config_data["tool_groups"] = request.tool_groups
|
||||||
|
|
||||||
|
config_file = agent_dir / "config.yaml"
|
||||||
|
with open(config_file, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
|
||||||
|
# Write SOUL.md
|
||||||
|
soul_file = agent_dir / "SOUL.md"
|
||||||
|
soul_file.write_text(request.soul, encoding="utf-8")
|
||||||
|
|
||||||
|
logger.info(f"Created agent '{normalized_name}' at {agent_dir}")
|
||||||
|
|
||||||
|
agent_cfg = load_agent_config(normalized_name)
|
||||||
|
return _agent_config_to_response(agent_cfg, include_soul=True)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up on failure
|
||||||
|
if agent_dir.exists():
|
||||||
|
shutil.rmtree(agent_dir)
|
||||||
|
logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -289,52 +243,33 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if agent not found.
|
HTTPException: 404 if agent not found.
|
||||||
"""
|
"""
|
||||||
_require_agents_api_enabled()
|
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
user_id = get_effective_user_id()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_cfg = load_agent_config(name, user_id=user_id)
|
agent_cfg = load_agent_config(name)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||||
|
|
||||||
paths = get_paths()
|
agent_dir = get_paths().agent_dir(name)
|
||||||
agent_dir = paths.user_agent_dir(user_id, name)
|
|
||||||
if not agent_dir.exists() and paths.agent_dir(name).exists():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before updating."),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Update config if any config fields changed
|
# Update config if any config fields changed
|
||||||
# Use model_fields_set to distinguish "field omitted" from "explicitly set to null".
|
config_changed = any(v is not None for v in [request.description, request.model, request.tool_groups])
|
||||||
# This is critical for skills where None means "inherit all" (not "don't change").
|
|
||||||
fields_set = request.model_fields_set
|
|
||||||
config_changed = bool(fields_set & {"description", "model", "tool_groups", "skills"})
|
|
||||||
|
|
||||||
if config_changed:
|
if config_changed:
|
||||||
updated: dict = {
|
updated: dict = {
|
||||||
"name": agent_cfg.name,
|
"name": agent_cfg.name,
|
||||||
"description": request.description if "description" in fields_set else agent_cfg.description,
|
"description": request.description if request.description is not None else agent_cfg.description,
|
||||||
}
|
}
|
||||||
new_model = request.model if "model" in fields_set else agent_cfg.model
|
new_model = request.model if request.model is not None else agent_cfg.model
|
||||||
if new_model is not None:
|
if new_model is not None:
|
||||||
updated["model"] = new_model
|
updated["model"] = new_model
|
||||||
|
|
||||||
new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups
|
new_tool_groups = request.tool_groups if request.tool_groups is not None else agent_cfg.tool_groups
|
||||||
if new_tool_groups is not None:
|
if new_tool_groups is not None:
|
||||||
updated["tool_groups"] = new_tool_groups
|
updated["tool_groups"] = new_tool_groups
|
||||||
|
|
||||||
# skills: None = inherit all, [] = no skills, ["a","b"] = whitelist
|
|
||||||
if "skills" in fields_set:
|
|
||||||
new_skills = request.skills
|
|
||||||
else:
|
|
||||||
new_skills = agent_cfg.skills
|
|
||||||
if new_skills is not None:
|
|
||||||
updated["skills"] = new_skills
|
|
||||||
|
|
||||||
config_file = agent_dir / "config.yaml"
|
config_file = agent_dir / "config.yaml"
|
||||||
with open(config_file, "w", encoding="utf-8") as f:
|
with open(config_file, "w", encoding="utf-8") as f:
|
||||||
yaml.dump(updated, f, default_flow_style=False, allow_unicode=True)
|
yaml.dump(updated, f, default_flow_style=False, allow_unicode=True)
|
||||||
@@ -346,8 +281,8 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
|||||||
|
|
||||||
logger.info(f"Updated agent '{name}'")
|
logger.info(f"Updated agent '{name}'")
|
||||||
|
|
||||||
refreshed_cfg = load_agent_config(name, user_id=user_id)
|
refreshed_cfg = load_agent_config(name)
|
||||||
return _agent_config_to_response(refreshed_cfg, include_soul=True, user_id=user_id)
|
return _agent_config_to_response(refreshed_cfg, include_soul=True)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -380,8 +315,6 @@ async def get_user_profile() -> UserProfileResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
UserProfileResponse with content=None if USER.md does not exist yet.
|
UserProfileResponse with content=None if USER.md does not exist yet.
|
||||||
"""
|
"""
|
||||||
_require_agents_api_enabled()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_md_path = get_paths().user_md_file
|
user_md_path = get_paths().user_md_file
|
||||||
if not user_md_path.exists():
|
if not user_md_path.exists():
|
||||||
@@ -408,8 +341,6 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR
|
|||||||
Returns:
|
Returns:
|
||||||
UserProfileResponse with the saved content.
|
UserProfileResponse with the saved content.
|
||||||
"""
|
"""
|
||||||
_require_agents_api_enabled()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
paths = get_paths()
|
paths = get_paths()
|
||||||
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -434,38 +365,19 @@ async def delete_agent(name: str) -> None:
|
|||||||
name: The agent name.
|
name: The agent name.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if no per-user copy exists; 409 if only a legacy
|
HTTPException: 404 if agent not found.
|
||||||
shared copy exists (suggesting the migration script).
|
|
||||||
"""
|
"""
|
||||||
_require_agents_api_enabled()
|
|
||||||
_validate_agent_name(name)
|
_validate_agent_name(name)
|
||||||
name = _normalize_agent_name(name)
|
name = _normalize_agent_name(name)
|
||||||
user_id = get_effective_user_id()
|
|
||||||
paths = get_paths()
|
|
||||||
|
|
||||||
def _remove_agent_dir() -> tuple[str, str]:
|
agent_dir = get_paths().agent_dir(name)
|
||||||
# Runs in a worker thread: resolving the base dir, probing the directory
|
|
||||||
# (`exists`), and removing it (`rmtree`) are all blocking filesystem IO
|
if not agent_dir.exists():
|
||||||
# that must stay off the event loop.
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||||
agent_dir = paths.user_agent_dir(user_id, name)
|
|
||||||
if not agent_dir.exists():
|
|
||||||
outcome = "legacy" if paths.agent_dir(name).exists() else "missing"
|
|
||||||
return outcome, str(agent_dir)
|
|
||||||
shutil.rmtree(agent_dir)
|
|
||||||
return "deleted", str(agent_dir)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
outcome, agent_dir = await asyncio.to_thread(_remove_agent_dir)
|
shutil.rmtree(agent_dir)
|
||||||
|
logger.info(f"Deleted agent '{name}' from {agent_dir}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True)
|
logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}")
|
||||||
|
|
||||||
if outcome == "legacy":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before deleting."),
|
|
||||||
)
|
|
||||||
if outcome == "missing":
|
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
|
||||||
|
|
||||||
logger.info(f"Deleted agent '{name}' from {agent_dir}")
|
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ ACTIVE_CONTENT_MIME_TYPES = {
|
|||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
}
|
}
|
||||||
|
|
||||||
MAX_SKILL_ARCHIVE_MEMBER_BYTES = 16 * 1024 * 1024
|
|
||||||
_SKILL_ARCHIVE_READ_CHUNK_SIZE = 64 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _build_content_disposition(disposition_type: str, filename: str) -> str:
|
def _build_content_disposition(disposition_type: str, filename: str) -> str:
|
||||||
"""Build an RFC 5987 encoded Content-Disposition header value."""
|
"""Build an RFC 5987 encoded Content-Disposition header value."""
|
||||||
@@ -47,22 +44,6 @@ def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _read_skill_archive_member(zip_ref: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
|
|
||||||
"""Read a .skill archive member while enforcing an uncompressed size cap."""
|
|
||||||
if info.file_size > MAX_SKILL_ARCHIVE_MEMBER_BYTES:
|
|
||||||
raise HTTPException(status_code=413, detail="Skill archive member is too large to preview")
|
|
||||||
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
total_read = 0
|
|
||||||
with zip_ref.open(info, "r") as src:
|
|
||||||
while chunk := src.read(_SKILL_ARCHIVE_READ_CHUNK_SIZE):
|
|
||||||
total_read += len(chunk)
|
|
||||||
if total_read > MAX_SKILL_ARCHIVE_MEMBER_BYTES:
|
|
||||||
raise HTTPException(status_code=413, detail="Skill archive member is too large to preview")
|
|
||||||
chunks.append(chunk)
|
|
||||||
return b"".join(chunks)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None:
|
def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None:
|
||||||
"""Extract a file from a .skill ZIP archive.
|
"""Extract a file from a .skill ZIP archive.
|
||||||
|
|
||||||
@@ -79,16 +60,16 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte
|
|||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
||||||
# List all files in the archive
|
# List all files in the archive
|
||||||
infos_by_name = {info.filename: info for info in zip_ref.infolist()}
|
namelist = zip_ref.namelist()
|
||||||
|
|
||||||
# Try direct path first
|
# Try direct path first
|
||||||
if internal_path in infos_by_name:
|
if internal_path in namelist:
|
||||||
return _read_skill_archive_member(zip_ref, infos_by_name[internal_path])
|
return zip_ref.read(internal_path)
|
||||||
|
|
||||||
# Try with any top-level directory prefix (e.g., "skill-name/SKILL.md")
|
# Try with any top-level directory prefix (e.g., "skill-name/SKILL.md")
|
||||||
for name, info in infos_by_name.items():
|
for name in namelist:
|
||||||
if name.endswith("/" + internal_path) or name == internal_path:
|
if name.endswith("/" + internal_path) or name == internal_path:
|
||||||
return _read_skill_archive_member(zip_ref, info)
|
return zip_ref.read(name)
|
||||||
|
|
||||||
# Not found
|
# Not found
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Authentication endpoints."""
|
"""Authentication endpoints."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -147,13 +146,7 @@ def _set_session_cookie(response: Response, token: str, request: Request) -> Non
|
|||||||
|
|
||||||
|
|
||||||
# ── Rate Limiting ────────────────────────────────────────────────────────
|
# ── Rate Limiting ────────────────────────────────────────────────────────
|
||||||
# In-process dict — not shared across workers.
|
# In-process dict — not shared across workers. Sufficient for single-worker deployments.
|
||||||
#
|
|
||||||
# **Limitation**: with multi-worker deployments (e.g., gunicorn -w N), each
|
|
||||||
# worker maintains its own lockout table, so an attacker effectively gets
|
|
||||||
# N × _MAX_LOGIN_ATTEMPTS guesses before being locked out everywhere. For
|
|
||||||
# production multi-worker setups, replace this with a shared store (Redis,
|
|
||||||
# database-backed counter) to enforce a true per-IP limit.
|
|
||||||
|
|
||||||
_MAX_LOGIN_ATTEMPTS = 5
|
_MAX_LOGIN_ATTEMPTS = 5
|
||||||
_LOCKOUT_SECONDS = 300 # 5 minutes
|
_LOCKOUT_SECONDS = 300 # 5 minutes
|
||||||
@@ -306,7 +299,7 @@ async def login_local(
|
|||||||
async def register(request: Request, response: Response, body: RegisterRequest):
|
async def register(request: Request, response: Response, body: RegisterRequest):
|
||||||
"""Register a new user account (always 'user' role).
|
"""Register a new user account (always 'user' role).
|
||||||
|
|
||||||
The first admin is created explicitly through /initialize. This endpoint creates regular users.
|
Admin is auto-created on first boot. This endpoint creates regular users.
|
||||||
Auto-login by setting the session cookie.
|
Auto-login by setting the session cookie.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -341,19 +334,9 @@ async def change_password(request: Request, response: Response, body: ChangePass
|
|||||||
- Re-issues session cookie with new token_version
|
- Re-issues session cookie with new token_version
|
||||||
"""
|
"""
|
||||||
from app.gateway.auth.password import hash_password_async, verify_password_async
|
from app.gateway.auth.password import hash_password_async, verify_password_async
|
||||||
from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED
|
|
||||||
|
|
||||||
user = await get_current_user_from_request(request)
|
user = await get_current_user_from_request(request)
|
||||||
|
|
||||||
if getattr(request.state, "auth_source", None) == AUTH_SOURCE_AUTH_DISABLED:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=AuthErrorResponse(
|
|
||||||
code=AuthErrorCode.INVALID_CREDENTIALS,
|
|
||||||
message="Password changes are not available when DEER_FLOW_AUTH_DISABLED=1.",
|
|
||||||
).model_dump(),
|
|
||||||
)
|
|
||||||
|
|
||||||
if user.password_hash is None:
|
if user.password_hash is None:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="OAuth users cannot change password").model_dump())
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="OAuth users cannot change password").model_dump())
|
||||||
|
|
||||||
@@ -393,113 +376,11 @@ async def get_me(request: Request):
|
|||||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup)
|
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup)
|
||||||
|
|
||||||
|
|
||||||
# Per-IP cache: ip → (timestamp, result_dict).
|
|
||||||
# Returns the cached result within the TTL instead of 429, because
|
|
||||||
# the answer (whether an admin exists) rarely changes and returning
|
|
||||||
# 429 breaks multi-tab / post-restart reconnection storms.
|
|
||||||
_SETUP_STATUS_CACHE: dict[str, tuple[float, dict]] = {}
|
|
||||||
_SETUP_STATUS_CACHE_TTL_SECONDS = 60
|
|
||||||
_MAX_TRACKED_SETUP_STATUS_IPS = 10000
|
|
||||||
_SETUP_STATUS_INFLIGHT: dict[str, asyncio.Task[dict]] = {}
|
|
||||||
_SETUP_STATUS_INFLIGHT_GUARD = asyncio.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/setup-status")
|
@router.get("/setup-status")
|
||||||
async def setup_status(request: Request):
|
async def setup_status():
|
||||||
"""Check if an admin account exists. Returns needs_setup=True when no admin exists."""
|
"""Check if admin account exists. Always False after first boot."""
|
||||||
client_ip = _get_client_ip(request)
|
user_count = await get_local_provider().count_users()
|
||||||
now = time.time()
|
return {"needs_setup": user_count == 0}
|
||||||
|
|
||||||
# Return cached result when within TTL — avoids 429 on multi-tab reconnection.
|
|
||||||
cached = _SETUP_STATUS_CACHE.get(client_ip)
|
|
||||||
if cached is not None:
|
|
||||||
cached_time, cached_result = cached
|
|
||||||
if now - cached_time < _SETUP_STATUS_CACHE_TTL_SECONDS:
|
|
||||||
return cached_result
|
|
||||||
|
|
||||||
async with _SETUP_STATUS_INFLIGHT_GUARD:
|
|
||||||
# Recheck cache after waiting for the inflight guard.
|
|
||||||
now = time.time()
|
|
||||||
cached = _SETUP_STATUS_CACHE.get(client_ip)
|
|
||||||
if cached is not None:
|
|
||||||
cached_time, cached_result = cached
|
|
||||||
if now - cached_time < _SETUP_STATUS_CACHE_TTL_SECONDS:
|
|
||||||
return cached_result
|
|
||||||
|
|
||||||
task = _SETUP_STATUS_INFLIGHT.get(client_ip)
|
|
||||||
if task is None:
|
|
||||||
# Evict stale entries when dict grows too large to bound memory usage.
|
|
||||||
if len(_SETUP_STATUS_CACHE) >= _MAX_TRACKED_SETUP_STATUS_IPS:
|
|
||||||
cutoff = now - _SETUP_STATUS_CACHE_TTL_SECONDS
|
|
||||||
stale = [k for k, (t, _) in _SETUP_STATUS_CACHE.items() if t < cutoff]
|
|
||||||
for k in stale:
|
|
||||||
del _SETUP_STATUS_CACHE[k]
|
|
||||||
if len(_SETUP_STATUS_CACHE) >= _MAX_TRACKED_SETUP_STATUS_IPS:
|
|
||||||
by_time = sorted(_SETUP_STATUS_CACHE.items(), key=lambda entry: entry[1][0])
|
|
||||||
for k, _ in by_time[: len(by_time) // 2]:
|
|
||||||
del _SETUP_STATUS_CACHE[k]
|
|
||||||
|
|
||||||
async def _compute_setup_status() -> dict:
|
|
||||||
admin_count = await get_local_provider().count_admin_users()
|
|
||||||
return {"needs_setup": admin_count == 0}
|
|
||||||
|
|
||||||
task = asyncio.create_task(_compute_setup_status())
|
|
||||||
_SETUP_STATUS_INFLIGHT[client_ip] = task
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await task
|
|
||||||
finally:
|
|
||||||
async with _SETUP_STATUS_INFLIGHT_GUARD:
|
|
||||||
if _SETUP_STATUS_INFLIGHT.get(client_ip) is task:
|
|
||||||
del _SETUP_STATUS_INFLIGHT[client_ip]
|
|
||||||
|
|
||||||
# Cache only the stable "initialized" result to avoid stale setup redirects.
|
|
||||||
if result["needs_setup"] is False:
|
|
||||||
_SETUP_STATUS_CACHE[client_ip] = (time.time(), result)
|
|
||||||
else:
|
|
||||||
_SETUP_STATUS_CACHE.pop(client_ip, None)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class InitializeAdminRequest(BaseModel):
|
|
||||||
"""Request model for first-boot admin account creation."""
|
|
||||||
|
|
||||||
email: EmailStr
|
|
||||||
password: str = Field(..., min_length=8)
|
|
||||||
|
|
||||||
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/initialize", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def initialize_admin(request: Request, response: Response, body: InitializeAdminRequest):
|
|
||||||
"""Create the first admin account on initial system setup.
|
|
||||||
|
|
||||||
Only callable when no admin exists. Returns 409 Conflict if an admin
|
|
||||||
already exists.
|
|
||||||
|
|
||||||
On success, the admin account is created with ``needs_setup=False`` and
|
|
||||||
the session cookie is set.
|
|
||||||
"""
|
|
||||||
admin_count = await get_local_provider().count_admin_users()
|
|
||||||
if admin_count > 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="admin", needs_setup=False)
|
|
||||||
except ValueError:
|
|
||||||
# DB unique-constraint race: another concurrent request beat us.
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(),
|
|
||||||
)
|
|
||||||
|
|
||||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
|
||||||
_set_session_cookie(response, token, request)
|
|
||||||
|
|
||||||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
|
|
||||||
|
|
||||||
|
|
||||||
# ── OAuth Endpoints (Future/Placeholder) ─────────────────────────────────
|
# ── OAuth Endpoints (Future/Placeholder) ─────────────────────────────────
|
||||||
|
|||||||
@@ -1,670 +0,0 @@
|
|||||||
"""Browser-facing APIs for user-owned IM channel bindings."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from app.channels.runtime_config_store import (
|
|
||||||
ChannelRuntimeConfigStore,
|
|
||||||
apply_runtime_connection_config,
|
|
||||||
merge_runtime_channel_configs,
|
|
||||||
)
|
|
||||||
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
|
|
||||||
from deerflow.persistence.channel_connections import ChannelConnectionRepository
|
|
||||||
from deerflow.persistence.engine import get_session_factory
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/channels", tags=["channel-connections"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_STATE_TTL_SECONDS = 600
|
|
||||||
_MASKED_CREDENTIAL_VALUE = "********"
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelCredentialFieldResponse(BaseModel):
|
|
||||||
name: str
|
|
||||||
label: str
|
|
||||||
type: str = "text"
|
|
||||||
required: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelProviderResponse(BaseModel):
|
|
||||||
provider: str
|
|
||||||
display_name: str
|
|
||||||
enabled: bool
|
|
||||||
configured: bool
|
|
||||||
connectable: bool
|
|
||||||
unavailable_reason: str | None = None
|
|
||||||
auth_mode: str
|
|
||||||
connection_status: str
|
|
||||||
credential_fields: list[ChannelCredentialFieldResponse] = Field(default_factory=list)
|
|
||||||
credential_values: dict[str, str] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelProvidersResponse(BaseModel):
|
|
||||||
enabled: bool
|
|
||||||
providers: list[ChannelProviderResponse]
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelConnectionResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
provider: str
|
|
||||||
status: str
|
|
||||||
external_account_id: str | None = None
|
|
||||||
external_account_name: str | None = None
|
|
||||||
workspace_id: str | None = None
|
|
||||||
workspace_name: str | None = None
|
|
||||||
scopes: list[str] = Field(default_factory=list)
|
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelConnectionsResponse(BaseModel):
|
|
||||||
connections: list[ChannelConnectionResponse]
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelConnectResponse(BaseModel):
|
|
||||||
provider: str
|
|
||||||
mode: str
|
|
||||||
url: str | None = None
|
|
||||||
code: str
|
|
||||||
instruction: str
|
|
||||||
expires_in: int
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelRuntimeConfigRequest(BaseModel):
|
|
||||||
values: dict[str, str] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
_PROVIDER_META: dict[str, dict[str, str]] = {
|
|
||||||
"telegram": {"display_name": "Telegram", "auth_mode": "deep_link"},
|
|
||||||
"slack": {"display_name": "Slack", "auth_mode": "binding_code"},
|
|
||||||
"discord": {"display_name": "Discord", "auth_mode": "binding_code"},
|
|
||||||
"feishu": {"display_name": "Feishu", "auth_mode": "binding_code"},
|
|
||||||
"dingtalk": {"display_name": "DingTalk", "auth_mode": "binding_code"},
|
|
||||||
"wechat": {"display_name": "WeChat", "auth_mode": "binding_code"},
|
|
||||||
"wecom": {"display_name": "WeCom", "auth_mode": "binding_code"},
|
|
||||||
}
|
|
||||||
|
|
||||||
_CREDENTIAL_FIELDS: dict[str, tuple[dict[str, str], ...]] = {
|
|
||||||
"telegram": (
|
|
||||||
{"name": "bot_token", "label": "Bot token", "type": "password"},
|
|
||||||
{"name": "bot_username", "label": "Bot username", "type": "text"},
|
|
||||||
),
|
|
||||||
"slack": (
|
|
||||||
{"name": "bot_token", "label": "Bot token", "type": "password"},
|
|
||||||
{"name": "app_token", "label": "App token", "type": "password"},
|
|
||||||
),
|
|
||||||
"discord": ({"name": "bot_token", "label": "Bot token", "type": "password"},),
|
|
||||||
"feishu": (
|
|
||||||
{"name": "app_id", "label": "App ID", "type": "text"},
|
|
||||||
{"name": "app_secret", "label": "App secret", "type": "password"},
|
|
||||||
),
|
|
||||||
"dingtalk": (
|
|
||||||
{"name": "client_id", "label": "Client ID", "type": "text"},
|
|
||||||
{"name": "client_secret", "label": "Client secret", "type": "password"},
|
|
||||||
),
|
|
||||||
"wechat": ({"name": "bot_token", "label": "Bot token", "type": "password"},),
|
|
||||||
"wecom": (
|
|
||||||
{"name": "bot_id", "label": "Bot ID", "type": "text"},
|
|
||||||
{"name": "bot_secret", "label": "Bot secret", "type": "password"},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
_RUNTIME_REQUIREMENTS: dict[str, tuple[str, ...]] = {
|
|
||||||
"telegram": ("bot_token",),
|
|
||||||
"slack": ("bot_token", "app_token"),
|
|
||||||
"discord": ("bot_token",),
|
|
||||||
"feishu": ("app_id", "app_secret"),
|
|
||||||
"dingtalk": ("client_id", "client_secret"),
|
|
||||||
"wechat": ("bot_token",),
|
|
||||||
"wecom": ("bot_id", "bot_secret"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_user_id(request: Request) -> str:
|
|
||||||
user = getattr(request.state, "user", None)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
|
||||||
return str(user.id)
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_admin_user(request: Request) -> None:
|
|
||||||
"""Require an admin caller for instance-wide channel runtime mutations.
|
|
||||||
|
|
||||||
Runtime credentials and the channel workers they start/stop are shared by
|
|
||||||
every user of the deployment, so only admins may change them (same model
|
|
||||||
as the MCP config API). Auth-disabled local mode uses a synthetic admin
|
|
||||||
user and is unaffected.
|
|
||||||
"""
|
|
||||||
user = getattr(request.state, "user", None)
|
|
||||||
if user is None:
|
|
||||||
from app.gateway.deps import get_current_user_from_request
|
|
||||||
|
|
||||||
user = await get_current_user_from_request(request)
|
|
||||||
|
|
||||||
if getattr(user, "system_role", None) != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Admin privileges required to manage channel runtime credentials.")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_app_config():
|
|
||||||
from deerflow.config.app_config import get_app_config
|
|
||||||
|
|
||||||
return get_app_config()
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_runtime_config_store(request: Request) -> ChannelRuntimeConfigStore:
|
|
||||||
store = getattr(request.app.state, "channel_runtime_config_store", None)
|
|
||||||
if isinstance(store, ChannelRuntimeConfigStore):
|
|
||||||
return store
|
|
||||||
# Constructing the store reads its JSON file from disk; keep it off the
|
|
||||||
# event loop.
|
|
||||||
store = await asyncio.to_thread(ChannelRuntimeConfigStore)
|
|
||||||
request.app.state.channel_runtime_config_store = store
|
|
||||||
return store
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_channel_connections_config(request: Request) -> ChannelConnectionsConfig:
|
|
||||||
config = getattr(request.app.state, "channel_connections_config", None)
|
|
||||||
if not isinstance(config, ChannelConnectionsConfig):
|
|
||||||
config = _get_app_config().channel_connections
|
|
||||||
config = apply_runtime_connection_config(config, store=await _get_runtime_config_store(request))
|
|
||||||
request.app.state.channel_connections_config = config
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_channels_config(request: Request) -> dict[str, Any]:
|
|
||||||
state_config = getattr(request.app.state, "channels_config", None)
|
|
||||||
if isinstance(state_config, dict):
|
|
||||||
return state_config
|
|
||||||
|
|
||||||
result = await _load_channels_config(request, await _get_channel_connections_config(request))
|
|
||||||
request.app.state.channels_config = result
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_channels_config(request: Request, config: ChannelConnectionsConfig) -> dict[str, Any]:
|
|
||||||
app_config = _get_app_config()
|
|
||||||
extra = app_config.model_extra or {}
|
|
||||||
channels_config = extra.get("channels")
|
|
||||||
result = dict(channels_config) if isinstance(channels_config, dict) else {}
|
|
||||||
merge_runtime_channel_configs(
|
|
||||||
result,
|
|
||||||
config,
|
|
||||||
store=await _get_runtime_config_store(request),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _get_repository(request: Request, config: ChannelConnectionsConfig) -> ChannelConnectionRepository:
|
|
||||||
repo = getattr(request.app.state, "channel_connection_repo", None)
|
|
||||||
if isinstance(repo, ChannelConnectionRepository):
|
|
||||||
return repo
|
|
||||||
|
|
||||||
sf = get_session_factory()
|
|
||||||
if sf is None:
|
|
||||||
raise HTTPException(status_code=503, detail="Channel connection persistence is not available")
|
|
||||||
|
|
||||||
repo = ChannelConnectionRepository(sf)
|
|
||||||
request.app.state.channel_connection_repo = repo
|
|
||||||
return repo
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_config(config: ChannelConnectionsConfig, provider: str):
|
|
||||||
provider_config = getattr(config, provider, None)
|
|
||||||
if provider_config is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Unknown channel provider")
|
|
||||||
return provider_config
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_channel_configured(provider: str, channels_config: dict[str, Any]) -> bool:
|
|
||||||
runtime_config = channels_config.get(provider)
|
|
||||||
if not isinstance(runtime_config, dict) or not runtime_config.get("enabled", False):
|
|
||||||
return False
|
|
||||||
return all(str(runtime_config.get(key) or "").strip() for key in _RUNTIME_REQUIREMENTS[provider])
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_unavailable_reason(provider: str) -> str:
|
|
||||||
meta = _PROVIDER_META.get(provider)
|
|
||||||
display_name = meta["display_name"] if meta else provider
|
|
||||||
return f"Enter the required {display_name} credentials to connect this channel."
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_not_running_reason(provider: str) -> str:
|
|
||||||
meta = _PROVIDER_META.get(provider)
|
|
||||||
display_name = meta["display_name"] if meta else provider
|
|
||||||
return f"{display_name} channel is configured but is not running. Check the credentials and service logs."
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_channel_running(provider: str) -> bool | None:
|
|
||||||
try:
|
|
||||||
from app.channels.service import get_channel_service
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Unable to inspect channel service status", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
service = get_channel_service()
|
|
||||||
if service is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
status = service.get_status()
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Unable to read channel service status", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not status.get("service_running"):
|
|
||||||
return False
|
|
||||||
channel_status = status.get("channels", {}).get(provider)
|
|
||||||
if not isinstance(channel_status, dict):
|
|
||||||
return None
|
|
||||||
return bool(channel_status.get("running"))
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_runtime_channel_ready_if_available(
|
|
||||||
provider: str,
|
|
||||||
channels_config: dict[str, Any],
|
|
||||||
) -> bool | None:
|
|
||||||
runtime_config = channels_config.get(provider)
|
|
||||||
if not isinstance(runtime_config, dict) or not runtime_config.get("enabled", False):
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app.channels.service import get_channel_service
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Unable to import channel service for readiness reconciliation", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
service = get_channel_service()
|
|
||||||
if service is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ensure_channel_ready = getattr(service, "ensure_channel_ready", None)
|
|
||||||
if ensure_channel_ready is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await ensure_channel_ready(provider, runtime_config)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to reconcile runtime channel readiness")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_unavailable_reason(
|
|
||||||
config: ChannelConnectionsConfig,
|
|
||||||
channels_config: dict[str, Any],
|
|
||||||
provider: str,
|
|
||||||
) -> str | None:
|
|
||||||
provider_config = _provider_config(config, provider)
|
|
||||||
if not provider_config.enabled:
|
|
||||||
return None
|
|
||||||
if not provider_config.configured:
|
|
||||||
return _runtime_unavailable_reason(provider)
|
|
||||||
if not _runtime_channel_configured(provider, channels_config):
|
|
||||||
return _runtime_unavailable_reason(provider)
|
|
||||||
if _runtime_channel_running(provider) is False:
|
|
||||||
return _runtime_not_running_reason(provider)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_status(
|
|
||||||
config: ChannelConnectionsConfig,
|
|
||||||
channels_config: dict[str, Any],
|
|
||||||
provider: str,
|
|
||||||
) -> tuple[dict[str, bool], str | None]:
|
|
||||||
declared = config.provider_status(provider)
|
|
||||||
unavailable_reason = _provider_unavailable_reason(config, channels_config, provider)
|
|
||||||
configured = declared["configured"] and _runtime_channel_configured(provider, channels_config)
|
|
||||||
return {"enabled": declared["enabled"], "configured": configured}, unavailable_reason
|
|
||||||
|
|
||||||
|
|
||||||
def _new_binding_code() -> str:
|
|
||||||
return secrets.token_urlsafe(16)
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_state(
|
|
||||||
repo: ChannelConnectionRepository,
|
|
||||||
*,
|
|
||||||
owner_user_id: str,
|
|
||||||
provider: str,
|
|
||||||
) -> str:
|
|
||||||
state = _new_binding_code()
|
|
||||||
await repo.create_oauth_state(
|
|
||||||
owner_user_id=owner_user_id,
|
|
||||||
provider=provider,
|
|
||||||
state=state,
|
|
||||||
expires_at=datetime.now(UTC) + timedelta(seconds=_STATE_TTL_SECONDS),
|
|
||||||
)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def _connect_instruction(provider: str, code: str) -> str:
|
|
||||||
if provider == "telegram":
|
|
||||||
return f"Send /start {code} to the DeerFlow Telegram bot."
|
|
||||||
meta = _PROVIDER_META.get(provider)
|
|
||||||
if meta is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Unknown channel provider")
|
|
||||||
return f"Send /connect {code} to the DeerFlow {meta['display_name']} bot."
|
|
||||||
|
|
||||||
|
|
||||||
def _connect_url(config: ChannelConnectionsConfig, provider: str, code: str) -> str | None:
|
|
||||||
if provider == "telegram":
|
|
||||||
provider_config = _provider_config(config, provider)
|
|
||||||
return f"https://t.me/{provider_config.bot_username}?start={code}"
|
|
||||||
if _PROVIDER_META.get(provider, {}).get("auth_mode") == "binding_code":
|
|
||||||
return None
|
|
||||||
raise HTTPException(status_code=404, detail="Unknown channel provider")
|
|
||||||
|
|
||||||
|
|
||||||
def _connection_updated_at(connection: dict[str, Any]) -> datetime:
|
|
||||||
value = connection.get("updated_at")
|
|
||||||
if isinstance(value, datetime):
|
|
||||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
||||||
if isinstance(value, str) and value:
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return datetime.min.replace(tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _newest_connection_by_provider(connections: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
||||||
by_provider: dict[str, dict[str, Any]] = {}
|
|
||||||
for item in connections:
|
|
||||||
existing = by_provider.get(item["provider"])
|
|
||||||
if existing is None or _connection_updated_at(item) > _connection_updated_at(existing):
|
|
||||||
by_provider[item["provider"]] = item
|
|
||||||
return by_provider
|
|
||||||
|
|
||||||
|
|
||||||
def _credential_fields(provider: str) -> list[ChannelCredentialFieldResponse]:
|
|
||||||
fields = _CREDENTIAL_FIELDS.get(provider)
|
|
||||||
if fields is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Unknown channel provider")
|
|
||||||
return [ChannelCredentialFieldResponse(**field) for field in fields]
|
|
||||||
|
|
||||||
|
|
||||||
def _credential_values(provider: str, channels_config: dict[str, Any]) -> dict[str, str]:
|
|
||||||
runtime_config = channels_config.get(provider)
|
|
||||||
if not isinstance(runtime_config, dict):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
values: dict[str, str] = {}
|
|
||||||
for field in _credential_fields(provider):
|
|
||||||
value = str(runtime_config.get(field.name) or "").strip()
|
|
||||||
if not value:
|
|
||||||
continue
|
|
||||||
values[field.name] = _MASKED_CREDENTIAL_VALUE if field.type == "password" else value
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_response(
|
|
||||||
config: ChannelConnectionsConfig,
|
|
||||||
channels_config: dict[str, Any],
|
|
||||||
provider: str,
|
|
||||||
meta: dict[str, str],
|
|
||||||
connection: dict[str, Any] | None = None,
|
|
||||||
) -> ChannelProviderResponse:
|
|
||||||
from app.gateway.auth_disabled import is_auth_disabled
|
|
||||||
|
|
||||||
status, unavailable_reason = _provider_status(config, channels_config, provider)
|
|
||||||
if connection:
|
|
||||||
connection_status = connection["status"]
|
|
||||||
elif is_auth_disabled() and status["configured"] and unavailable_reason is None:
|
|
||||||
# Auth-disabled local mode routes every channel message to the default
|
|
||||||
# user, so a configured running channel needs no per-user binding.
|
|
||||||
connection_status = "connected"
|
|
||||||
else:
|
|
||||||
connection_status = "not_connected"
|
|
||||||
credential_values = _credential_values(provider, channels_config)
|
|
||||||
if provider == "telegram" and not credential_values.get("bot_username"):
|
|
||||||
bot_username = str(_provider_config(config, provider).bot_username or "").strip()
|
|
||||||
if bot_username:
|
|
||||||
credential_values["bot_username"] = bot_username
|
|
||||||
return ChannelProviderResponse(
|
|
||||||
provider=provider,
|
|
||||||
display_name=meta["display_name"],
|
|
||||||
enabled=status["enabled"],
|
|
||||||
configured=status["configured"],
|
|
||||||
connectable=status["enabled"] and status["configured"] and unavailable_reason is None,
|
|
||||||
unavailable_reason=unavailable_reason,
|
|
||||||
auth_mode=meta["auth_mode"],
|
|
||||||
connection_status=connection_status,
|
|
||||||
credential_fields=_credential_fields(provider),
|
|
||||||
credential_values=credential_values,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _required_runtime_values(
|
|
||||||
provider: str,
|
|
||||||
values: dict[str, str],
|
|
||||||
existing_config: dict[str, Any] | None = None,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
fields = _credential_fields(provider)
|
|
||||||
cleaned: dict[str, str] = {}
|
|
||||||
missing: list[str] = []
|
|
||||||
existing_config = existing_config or {}
|
|
||||||
for field in fields:
|
|
||||||
raw_value = values.get(field.name, "")
|
|
||||||
if field.type == "password" and raw_value == _MASKED_CREDENTIAL_VALUE:
|
|
||||||
existing_value = str(existing_config.get(field.name) or "").strip()
|
|
||||||
if existing_value:
|
|
||||||
cleaned[field.name] = existing_value
|
|
||||||
continue
|
|
||||||
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value or "").strip()
|
|
||||||
if field.required and not value:
|
|
||||||
missing.append(field.label)
|
|
||||||
cleaned[field.name] = value
|
|
||||||
if missing:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Missing required channel configuration: {', '.join(missing)}")
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
|
|
||||||
async def _restart_runtime_channel_if_available(provider: str, runtime_config: dict[str, Any]) -> bool | None:
|
|
||||||
try:
|
|
||||||
from app.channels.service import get_channel_service
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to import channel service while configuring a runtime channel")
|
|
||||||
return None
|
|
||||||
|
|
||||||
service = get_channel_service()
|
|
||||||
if service is None:
|
|
||||||
return None
|
|
||||||
return await service.configure_channel(provider, runtime_config)
|
|
||||||
|
|
||||||
|
|
||||||
async def _sync_runtime_channel_after_removal(provider: str, channels_config: dict[str, Any]) -> bool | None:
|
|
||||||
try:
|
|
||||||
from app.channels.service import get_channel_service
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to import channel service while disconnecting a runtime channel")
|
|
||||||
return None
|
|
||||||
|
|
||||||
service = get_channel_service()
|
|
||||||
if service is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
runtime_config = channels_config.get(provider)
|
|
||||||
if isinstance(runtime_config, dict) and runtime_config.get("enabled", False):
|
|
||||||
return await service.configure_channel(provider, runtime_config)
|
|
||||||
return await service.remove_channel(provider)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers", response_model=ChannelProvidersResponse)
|
|
||||||
async def get_channel_providers(request: Request) -> ChannelProvidersResponse:
|
|
||||||
config = await _get_channel_connections_config(request)
|
|
||||||
channels_config = await _get_channels_config(request)
|
|
||||||
repo = None
|
|
||||||
if config.enabled:
|
|
||||||
try:
|
|
||||||
repo = _get_repository(request, config)
|
|
||||||
except HTTPException as exc:
|
|
||||||
if exc.status_code != 503:
|
|
||||||
raise
|
|
||||||
owner_user_id = _get_user_id(request)
|
|
||||||
connections = await repo.list_connections(owner_user_id) if repo is not None else []
|
|
||||||
by_provider = _newest_connection_by_provider(connections)
|
|
||||||
|
|
||||||
enabled_providers = [provider for provider in _PROVIDER_META if config.provider_status(provider)["enabled"]]
|
|
||||||
# Readiness reconciliation is independent per provider; run it
|
|
||||||
# concurrently so one slow channel restart does not serialize the
|
|
||||||
# whole /providers response.
|
|
||||||
await asyncio.gather(
|
|
||||||
*(_ensure_runtime_channel_ready_if_available(provider, channels_config) for provider in enabled_providers if _runtime_channel_configured(provider, channels_config)),
|
|
||||||
)
|
|
||||||
|
|
||||||
providers: list[ChannelProviderResponse] = []
|
|
||||||
for provider in enabled_providers:
|
|
||||||
connection = by_provider.get(provider)
|
|
||||||
providers.append(_provider_response(config, channels_config, provider, _PROVIDER_META[provider], connection))
|
|
||||||
return ChannelProvidersResponse(enabled=config.enabled, providers=providers)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/connections", response_model=ChannelConnectionsResponse)
|
|
||||||
async def get_channel_connections(request: Request) -> ChannelConnectionsResponse:
|
|
||||||
config = await _get_channel_connections_config(request)
|
|
||||||
if not config.enabled:
|
|
||||||
return ChannelConnectionsResponse(connections=[])
|
|
||||||
repo = _get_repository(request, config)
|
|
||||||
rows = await repo.list_connections(_get_user_id(request))
|
|
||||||
return ChannelConnectionsResponse(connections=[ChannelConnectionResponse(**row) for row in rows])
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/connections/{connection_id}", status_code=204)
|
|
||||||
async def disconnect_channel_connection(connection_id: str, request: Request) -> Response:
|
|
||||||
config = await _get_channel_connections_config(request)
|
|
||||||
if not config.enabled:
|
|
||||||
raise HTTPException(status_code=400, detail="Channel connections are disabled")
|
|
||||||
|
|
||||||
repo = _get_repository(request, config)
|
|
||||||
disconnected = await repo.disconnect_connection(
|
|
||||||
connection_id=connection_id,
|
|
||||||
owner_user_id=_get_user_id(request),
|
|
||||||
)
|
|
||||||
if not disconnected:
|
|
||||||
raise HTTPException(status_code=404, detail="Channel connection not found")
|
|
||||||
return Response(status_code=204)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{provider}/runtime-config", response_model=ChannelProviderResponse)
|
|
||||||
async def disconnect_channel_provider_runtime(provider: str, request: Request) -> ChannelProviderResponse:
|
|
||||||
await _require_admin_user(request)
|
|
||||||
config = await _get_channel_connections_config(request)
|
|
||||||
if not config.enabled:
|
|
||||||
raise HTTPException(status_code=400, detail="Channel connections are disabled")
|
|
||||||
|
|
||||||
provider_config = _provider_config(config, provider)
|
|
||||||
if not provider_config.enabled:
|
|
||||||
raise HTTPException(status_code=400, detail="Channel provider is not enabled")
|
|
||||||
|
|
||||||
owner_user_id = _get_user_id(request)
|
|
||||||
try:
|
|
||||||
repo = _get_repository(request, config)
|
|
||||||
except HTTPException as exc:
|
|
||||||
if exc.status_code != 503:
|
|
||||||
raise
|
|
||||||
repo = None
|
|
||||||
|
|
||||||
if repo is not None:
|
|
||||||
for connection in await repo.list_connections(owner_user_id):
|
|
||||||
if connection["provider"] == provider and connection["status"] != "revoked":
|
|
||||||
await repo.disconnect_connection(
|
|
||||||
connection_id=connection["id"],
|
|
||||||
owner_user_id=owner_user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
store = await _get_runtime_config_store(request)
|
|
||||||
await asyncio.to_thread(store.set_provider_disconnected, provider)
|
|
||||||
channels_config = await _load_channels_config(request, config)
|
|
||||||
request.app.state.channels_config = channels_config
|
|
||||||
|
|
||||||
stopped = await _sync_runtime_channel_after_removal(provider, channels_config)
|
|
||||||
if stopped is False:
|
|
||||||
display_name = _PROVIDER_META[provider]["display_name"]
|
|
||||||
raise HTTPException(status_code=400, detail=f"Failed to stop {display_name} channel. Try again.")
|
|
||||||
|
|
||||||
return _provider_response(config, channels_config, provider, _PROVIDER_META[provider])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{provider}/connect", response_model=ChannelConnectResponse)
|
|
||||||
async def connect_channel_provider(provider: str, request: Request) -> ChannelConnectResponse:
|
|
||||||
config = await _get_channel_connections_config(request)
|
|
||||||
channels_config = await _get_channels_config(request)
|
|
||||||
if not config.enabled:
|
|
||||||
raise HTTPException(status_code=400, detail="Channel connections are disabled")
|
|
||||||
|
|
||||||
provider_config = _provider_config(config, provider)
|
|
||||||
if provider_config.enabled and _runtime_channel_configured(provider, channels_config):
|
|
||||||
await _ensure_runtime_channel_ready_if_available(provider, channels_config)
|
|
||||||
|
|
||||||
status, unavailable_reason = _provider_status(config, channels_config, provider)
|
|
||||||
if not status["enabled"]:
|
|
||||||
raise HTTPException(status_code=400, detail="Channel provider is not enabled")
|
|
||||||
if unavailable_reason:
|
|
||||||
raise HTTPException(status_code=400, detail=unavailable_reason)
|
|
||||||
if not status["configured"]:
|
|
||||||
raise HTTPException(status_code=400, detail="Channel provider is not configured")
|
|
||||||
|
|
||||||
repo = _get_repository(request, config)
|
|
||||||
code = await _create_state(
|
|
||||||
repo,
|
|
||||||
owner_user_id=_get_user_id(request),
|
|
||||||
provider=provider,
|
|
||||||
)
|
|
||||||
return ChannelConnectResponse(
|
|
||||||
provider=provider,
|
|
||||||
mode=_PROVIDER_META[provider]["auth_mode"],
|
|
||||||
url=_connect_url(config, provider, code),
|
|
||||||
code=code,
|
|
||||||
instruction=_connect_instruction(provider, code),
|
|
||||||
expires_in=_STATE_TTL_SECONDS,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{provider}/runtime-config", response_model=ChannelProviderResponse)
|
|
||||||
async def configure_channel_provider_runtime(
|
|
||||||
provider: str,
|
|
||||||
body: ChannelRuntimeConfigRequest,
|
|
||||||
request: Request,
|
|
||||||
) -> ChannelProviderResponse:
|
|
||||||
await _require_admin_user(request)
|
|
||||||
config = await _get_channel_connections_config(request)
|
|
||||||
if not config.enabled:
|
|
||||||
raise HTTPException(status_code=400, detail="Channel connections are disabled")
|
|
||||||
|
|
||||||
provider_config = _provider_config(config, provider)
|
|
||||||
if not provider_config.enabled:
|
|
||||||
raise HTTPException(status_code=400, detail="Channel provider is not enabled")
|
|
||||||
|
|
||||||
channels_config = await _get_channels_config(request)
|
|
||||||
existing = channels_config.get(provider)
|
|
||||||
runtime_config = dict(existing) if isinstance(existing, dict) else {}
|
|
||||||
values = _required_runtime_values(provider, body.values, runtime_config)
|
|
||||||
runtime_config["enabled"] = True
|
|
||||||
|
|
||||||
for key in _RUNTIME_REQUIREMENTS[provider]:
|
|
||||||
runtime_config[key] = values[key]
|
|
||||||
|
|
||||||
if provider == "telegram":
|
|
||||||
# The deep-link username is persisted with the runtime channel config
|
|
||||||
# (set_provider_config below) and applied to future requests via
|
|
||||||
# apply_runtime_connection_config; never mutate the config instance
|
|
||||||
# cached by get_app_config().
|
|
||||||
runtime_config["bot_username"] = values["bot_username"]
|
|
||||||
|
|
||||||
channels_config[provider] = runtime_config
|
|
||||||
request.app.state.channels_config = channels_config
|
|
||||||
|
|
||||||
started = await _restart_runtime_channel_if_available(provider, runtime_config)
|
|
||||||
if started is False:
|
|
||||||
display_name = _PROVIDER_META[provider]["display_name"]
|
|
||||||
raise HTTPException(status_code=400, detail=f"Failed to start {display_name} channel. Check the values and try again.")
|
|
||||||
|
|
||||||
store = await _get_runtime_config_store(request)
|
|
||||||
await asyncio.to_thread(store.set_provider_config, provider, runtime_config)
|
|
||||||
|
|
||||||
return _provider_response(config, channels_config, provider, _PROVIDER_META[provider])
|
|
||||||
@@ -30,16 +30,11 @@ class FeedbackCreateRequest(BaseModel):
|
|||||||
message_id: str | None = Field(default=None, description="Optional: scope feedback to a specific message")
|
message_id: str | None = Field(default=None, description="Optional: scope feedback to a specific message")
|
||||||
|
|
||||||
|
|
||||||
class FeedbackUpsertRequest(BaseModel):
|
|
||||||
rating: int = Field(..., description="Feedback rating: +1 (positive) or -1 (negative)")
|
|
||||||
comment: str | None = Field(default=None, description="Optional text feedback")
|
|
||||||
|
|
||||||
|
|
||||||
class FeedbackResponse(BaseModel):
|
class FeedbackResponse(BaseModel):
|
||||||
feedback_id: str
|
feedback_id: str
|
||||||
run_id: str
|
run_id: str
|
||||||
thread_id: str
|
thread_id: str
|
||||||
user_id: str | None = None
|
owner_id: str | None = None
|
||||||
message_id: str | None = None
|
message_id: str | None = None
|
||||||
rating: int
|
rating: int
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
@@ -58,57 +53,6 @@ class FeedbackStatsResponse(BaseModel):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{thread_id}/runs/{run_id}/feedback", response_model=FeedbackResponse)
|
|
||||||
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
|
||||||
async def upsert_feedback(
|
|
||||||
thread_id: str,
|
|
||||||
run_id: str,
|
|
||||||
body: FeedbackUpsertRequest,
|
|
||||||
request: Request,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Create or update feedback for a run (idempotent)."""
|
|
||||||
if body.rating not in (1, -1):
|
|
||||||
raise HTTPException(status_code=400, detail="rating must be +1 or -1")
|
|
||||||
|
|
||||||
user_id = await get_current_user(request)
|
|
||||||
|
|
||||||
run_store = get_run_store(request)
|
|
||||||
run = await run_store.get(run_id)
|
|
||||||
if run is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
||||||
if run.get("thread_id") != thread_id:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found in thread {thread_id}")
|
|
||||||
|
|
||||||
feedback_repo = get_feedback_repo(request)
|
|
||||||
return await feedback_repo.upsert(
|
|
||||||
run_id=run_id,
|
|
||||||
thread_id=thread_id,
|
|
||||||
rating=body.rating,
|
|
||||||
user_id=user_id,
|
|
||||||
comment=body.comment,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{thread_id}/runs/{run_id}/feedback")
|
|
||||||
@require_permission("threads", "delete", owner_check=True, require_existing=True)
|
|
||||||
async def delete_run_feedback(
|
|
||||||
thread_id: str,
|
|
||||||
run_id: str,
|
|
||||||
request: Request,
|
|
||||||
) -> dict[str, bool]:
|
|
||||||
"""Delete the current user's feedback for a run."""
|
|
||||||
user_id = await get_current_user(request)
|
|
||||||
feedback_repo = get_feedback_repo(request)
|
|
||||||
deleted = await feedback_repo.delete_by_run(
|
|
||||||
thread_id=thread_id,
|
|
||||||
run_id=run_id,
|
|
||||||
user_id=user_id,
|
|
||||||
)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="No feedback found for this run")
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{thread_id}/runs/{run_id}/feedback", response_model=FeedbackResponse)
|
@router.post("/{thread_id}/runs/{run_id}/feedback", response_model=FeedbackResponse)
|
||||||
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
||||||
async def create_feedback(
|
async def create_feedback(
|
||||||
@@ -136,7 +80,7 @@ async def create_feedback(
|
|||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
rating=body.rating,
|
rating=body.rating,
|
||||||
user_id=user_id,
|
owner_id=user_id,
|
||||||
message_id=body.message_id,
|
message_id=body.message_id,
|
||||||
comment=body.comment,
|
comment=body.comment,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
|
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
|
||||||
@@ -13,11 +12,6 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api", tags=["mcp"])
|
router = APIRouter(prefix="/api", tags=["mcp"])
|
||||||
|
|
||||||
|
|
||||||
_MCP_STDIO_COMMAND_ALLOWLIST_ENV = "DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST"
|
|
||||||
_DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST = frozenset({"npx", "uvx"})
|
|
||||||
_SHELL_METACHARS = frozenset(";|&`$<>\n\r")
|
|
||||||
|
|
||||||
|
|
||||||
class McpOAuthConfigResponse(BaseModel):
|
class McpOAuthConfigResponse(BaseModel):
|
||||||
"""OAuth configuration for an MCP server."""
|
"""OAuth configuration for an MCP server."""
|
||||||
|
|
||||||
@@ -69,178 +63,13 @@ class McpConfigUpdateRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_MASKED_VALUE = "***"
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_admin_user(request: Request) -> None:
|
|
||||||
"""Require the authenticated caller to be an admin user.
|
|
||||||
|
|
||||||
``AuthMiddleware`` normally stamps ``request.state.user`` before the
|
|
||||||
request reaches this router. Falling back to the strict dependency keeps
|
|
||||||
this route safe even in tests or alternative ASGI compositions that mount
|
|
||||||
the router without the global middleware.
|
|
||||||
"""
|
|
||||||
user = getattr(request.state, "user", None)
|
|
||||||
if user is None:
|
|
||||||
from app.gateway.deps import get_current_user_from_request
|
|
||||||
|
|
||||||
user = await get_current_user_from_request(request)
|
|
||||||
|
|
||||||
if getattr(user, "system_role", None) != "admin":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Admin privileges required to manage MCP configuration.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _allowed_stdio_commands() -> set[str]:
|
|
||||||
"""Return executable names allowed for API-managed stdio MCP servers."""
|
|
||||||
raw = os.environ.get(_MCP_STDIO_COMMAND_ALLOWLIST_ENV)
|
|
||||||
base = set(_DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST)
|
|
||||||
if raw is None:
|
|
||||||
return base
|
|
||||||
extra = {item.strip() for item in raw.split(",") if item.strip()}
|
|
||||||
return base | extra
|
|
||||||
|
|
||||||
|
|
||||||
def _stdio_command_name(command: str | None, *, server_name: str) -> str:
|
|
||||||
"""Normalize and validate a stdio command field from the API boundary."""
|
|
||||||
if command is None or not command.strip():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"MCP server '{server_name}' with stdio transport requires a command.",
|
|
||||||
)
|
|
||||||
|
|
||||||
stripped = command.strip()
|
|
||||||
has_path_separator = "/" in stripped or "\\" in stripped
|
|
||||||
if stripped != command or has_path_separator or any(ch.isspace() for ch in stripped) or any(ch in stripped for ch in _SHELL_METACHARS):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=(f"MCP server '{server_name}' command must be a single executable name; put parameters in args instead."),
|
|
||||||
)
|
|
||||||
|
|
||||||
return stripped
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_mcp_update_request(request: McpConfigUpdateRequest) -> None:
|
|
||||||
"""Validate API-submitted MCP config before it is persisted.
|
|
||||||
|
|
||||||
Local config files can still express arbitrary advanced setups, but the
|
|
||||||
HTTP API is an untrusted boundary. Restricting stdio commands here reduces
|
|
||||||
the blast radius of a compromised authenticated browser session.
|
|
||||||
"""
|
|
||||||
allowed_commands = _allowed_stdio_commands()
|
|
||||||
for name, server in request.mcp_servers.items():
|
|
||||||
transport_type = (server.type or "stdio").lower()
|
|
||||||
if transport_type != "stdio":
|
|
||||||
continue
|
|
||||||
|
|
||||||
command_name = _stdio_command_name(server.command, server_name=name)
|
|
||||||
if command_name not in allowed_commands:
|
|
||||||
allowed = ", ".join(sorted(allowed_commands)) or "<none>"
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=(f"MCP server '{name}' uses disallowed stdio command '{command_name}'. Allowed commands: {allowed}. Configure {_MCP_STDIO_COMMAND_ALLOWLIST_ENV} to extend this list."),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigResponse:
|
|
||||||
"""Return a copy of server config with sensitive fields masked.
|
|
||||||
|
|
||||||
Masks env values, header values, and removes OAuth secrets so they
|
|
||||||
are not exposed through the GET API endpoint.
|
|
||||||
"""
|
|
||||||
masked_env = {k: _MASKED_VALUE for k in server.env}
|
|
||||||
masked_headers = {k: _MASKED_VALUE for k in server.headers}
|
|
||||||
masked_oauth = None
|
|
||||||
if server.oauth is not None:
|
|
||||||
masked_oauth = server.oauth.model_copy(
|
|
||||||
update={
|
|
||||||
"client_secret": None,
|
|
||||||
"refresh_token": None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return server.model_copy(
|
|
||||||
update={
|
|
||||||
"env": masked_env,
|
|
||||||
"headers": masked_headers,
|
|
||||||
"oauth": masked_oauth,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_preserving_secrets(
|
|
||||||
incoming: McpServerConfigResponse,
|
|
||||||
existing: McpServerConfigResponse,
|
|
||||||
) -> McpServerConfigResponse:
|
|
||||||
"""Merge incoming config with existing, preserving secrets masked by GET.
|
|
||||||
|
|
||||||
When the frontend toggles ``enabled`` it round-trips the full config:
|
|
||||||
GET (masked) → modify enabled → PUT (masked values sent back).
|
|
||||||
This function ensures masked values (``***``) are replaced with the
|
|
||||||
real secrets from the current on-disk config.
|
|
||||||
|
|
||||||
``***`` is only accepted for keys that already exist in *existing*.
|
|
||||||
New keys must provide a real value.
|
|
||||||
|
|
||||||
For OAuth secrets, ``None`` means "preserve the existing stored value"
|
|
||||||
so masked GET responses can be safely round-tripped. To explicitly clear
|
|
||||||
a stored secret, clients may send an empty string, which is converted
|
|
||||||
to ``None`` before persisting.
|
|
||||||
"""
|
|
||||||
merged_env = {}
|
|
||||||
for k, v in incoming.env.items():
|
|
||||||
if v == _MASKED_VALUE:
|
|
||||||
if k in existing.env:
|
|
||||||
merged_env[k] = existing.env[k]
|
|
||||||
else:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Cannot set env key '{k}' to masked value '***'; provide a real value.",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
merged_env[k] = v
|
|
||||||
|
|
||||||
merged_headers = {}
|
|
||||||
for k, v in incoming.headers.items():
|
|
||||||
if v == _MASKED_VALUE:
|
|
||||||
if k in existing.headers:
|
|
||||||
merged_headers[k] = existing.headers[k]
|
|
||||||
else:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Cannot set header '{k}' to masked value '***'; provide a real value.",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
merged_headers[k] = v
|
|
||||||
|
|
||||||
merged_oauth = incoming.oauth
|
|
||||||
if incoming.oauth is not None and existing.oauth is not None:
|
|
||||||
# None = preserve (masked round-trip), "" = explicitly clear, else = new value
|
|
||||||
merged_client_secret = existing.oauth.client_secret if incoming.oauth.client_secret is None else (None if incoming.oauth.client_secret == "" else incoming.oauth.client_secret)
|
|
||||||
merged_refresh_token = existing.oauth.refresh_token if incoming.oauth.refresh_token is None else (None if incoming.oauth.refresh_token == "" else incoming.oauth.refresh_token)
|
|
||||||
merged_oauth = incoming.oauth.model_copy(
|
|
||||||
update={
|
|
||||||
"client_secret": merged_client_secret,
|
|
||||||
"refresh_token": merged_refresh_token,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return incoming.model_copy(
|
|
||||||
update={
|
|
||||||
"env": merged_env,
|
|
||||||
"headers": merged_headers,
|
|
||||||
"oauth": merged_oauth,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/mcp/config",
|
"/mcp/config",
|
||||||
response_model=McpConfigResponse,
|
response_model=McpConfigResponse,
|
||||||
summary="Get MCP Configuration",
|
summary="Get MCP Configuration",
|
||||||
description="Retrieve the current Model Context Protocol (MCP) server configurations.",
|
description="Retrieve the current Model Context Protocol (MCP) server configurations.",
|
||||||
)
|
)
|
||||||
async def get_mcp_configuration(request: Request) -> McpConfigResponse:
|
async def get_mcp_configuration() -> McpConfigResponse:
|
||||||
"""Get the current MCP configuration.
|
"""Get the current MCP configuration.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -254,19 +83,16 @@ async def get_mcp_configuration(request: Request) -> McpConfigResponse:
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||||
"env": {"GITHUB_TOKEN": "***"},
|
"env": {"GITHUB_TOKEN": "ghp_xxx"},
|
||||||
"description": "GitHub MCP server for repository operations"
|
"description": "GitHub MCP server for repository operations"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
await _require_admin_user(request)
|
|
||||||
|
|
||||||
config = get_extensions_config()
|
config = get_extensions_config()
|
||||||
|
|
||||||
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in config.mcp_servers.items()}
|
return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in config.mcp_servers.items()})
|
||||||
return McpConfigResponse(mcp_servers=servers)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -275,7 +101,7 @@ async def get_mcp_configuration(request: Request) -> McpConfigResponse:
|
|||||||
summary="Update MCP Configuration",
|
summary="Update MCP Configuration",
|
||||||
description="Update Model Context Protocol (MCP) server configurations and save to file.",
|
description="Update Model Context Protocol (MCP) server configurations and save to file.",
|
||||||
)
|
)
|
||||||
async def update_mcp_configuration(request: Request, body: McpConfigUpdateRequest) -> McpConfigResponse:
|
async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfigResponse:
|
||||||
"""Update the MCP configuration.
|
"""Update the MCP configuration.
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
@@ -308,9 +134,6 @@ async def update_mcp_configuration(request: Request, body: McpConfigUpdateReques
|
|||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await _require_admin_user(request)
|
|
||||||
_validate_mcp_update_request(body)
|
|
||||||
|
|
||||||
# Get the current config path (or determine where to save it)
|
# Get the current config path (or determine where to save it)
|
||||||
config_path = ExtensionsConfig.resolve_config_path()
|
config_path = ExtensionsConfig.resolve_config_path()
|
||||||
|
|
||||||
@@ -319,39 +142,14 @@ async def update_mcp_configuration(request: Request, body: McpConfigUpdateReques
|
|||||||
config_path = Path.cwd().parent / "extensions_config.json"
|
config_path = Path.cwd().parent / "extensions_config.json"
|
||||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||||
|
|
||||||
# Load current config to preserve skills
|
# Load current config to preserve skills configuration
|
||||||
current_config = get_extensions_config()
|
current_config = get_extensions_config()
|
||||||
|
|
||||||
# Load raw (un-resolved) JSON from disk to use as the merge source.
|
# Convert request to dict format for JSON serialization
|
||||||
# This preserves $VAR placeholders in env values and top-level keys
|
config_data = {
|
||||||
# like mcpInterceptors that would otherwise be lost.
|
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()},
|
||||||
raw_servers: dict[str, dict] = {}
|
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
|
||||||
raw_other_keys: dict = {}
|
}
|
||||||
if config_path is not None and config_path.exists():
|
|
||||||
with open(config_path, encoding="utf-8") as f:
|
|
||||||
raw_data = json.load(f)
|
|
||||||
raw_servers = raw_data.get("mcpServers", {})
|
|
||||||
# Preserve any top-level keys beyond mcpServers/skills
|
|
||||||
for key, value in raw_data.items():
|
|
||||||
if key not in ("mcpServers", "skills"):
|
|
||||||
raw_other_keys[key] = value
|
|
||||||
|
|
||||||
# Merge incoming server configs with raw on-disk secrets
|
|
||||||
merged_servers: dict[str, McpServerConfigResponse] = {}
|
|
||||||
for name, incoming in body.mcp_servers.items():
|
|
||||||
raw_server = raw_servers.get(name)
|
|
||||||
if raw_server is not None:
|
|
||||||
merged_servers[name] = _merge_preserving_secrets(
|
|
||||||
incoming,
|
|
||||||
McpServerConfigResponse(**raw_server),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
merged_servers[name] = incoming
|
|
||||||
|
|
||||||
# Build config data preserving all top-level keys from the original file
|
|
||||||
config_data = dict(raw_other_keys)
|
|
||||||
config_data["mcpServers"] = {name: server.model_dump() for name, server in merged_servers.items()}
|
|
||||||
config_data["skills"] = {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}
|
|
||||||
|
|
||||||
# Write the configuration to file
|
# Write the configuration to file
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
@@ -359,15 +157,13 @@ async def update_mcp_configuration(request: Request, body: McpConfigUpdateReques
|
|||||||
|
|
||||||
logger.info(f"MCP configuration updated and saved to: {config_path}")
|
logger.info(f"MCP configuration updated and saved to: {config_path}")
|
||||||
|
|
||||||
# Reload the Gateway configuration and update the global cache. The
|
# NOTE: No need to reload/reset cache here - LangGraph Server (separate process)
|
||||||
# agent runtime lives in Gateway, so this keeps API reads and tool
|
# will detect config file changes via mtime and reinitialize MCP tools automatically
|
||||||
# execution aligned after extensions_config.json changes.
|
|
||||||
reloaded_config = reload_extensions_config()
|
# Reload the configuration and update the global cache
|
||||||
servers = {name: _mask_server_config(McpServerConfigResponse(**server.model_dump())) for name, server in reloaded_config.mcp_servers.items()}
|
reloaded_config = reload_extensions_config()
|
||||||
return McpConfigResponse(mcp_servers=servers)
|
return McpConfigResponse(mcp_servers={name: McpServerConfigResponse(**server.model_dump()) for name, server in reloaded_config.mcp_servers.items()})
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update MCP configuration: {e}", exc_info=True)
|
logger.error(f"Failed to update MCP configuration: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to update MCP configuration: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update MCP configuration: {str(e)}")
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from deerflow.agents.memory.updater import (
|
|||||||
update_memory_fact,
|
update_memory_fact,
|
||||||
)
|
)
|
||||||
from deerflow.config.memory_config import get_memory_config
|
from deerflow.config.memory_config import get_memory_config
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["memory"])
|
router = APIRouter(prefix="/api", tags=["memory"])
|
||||||
|
|
||||||
@@ -98,7 +97,6 @@ class MemoryConfigResponse(BaseModel):
|
|||||||
fact_confidence_threshold: float = Field(..., description="Minimum confidence threshold for facts")
|
fact_confidence_threshold: float = Field(..., description="Minimum confidence threshold for facts")
|
||||||
injection_enabled: bool = Field(..., description="Whether memory injection is enabled")
|
injection_enabled: bool = Field(..., description="Whether memory injection is enabled")
|
||||||
max_injection_tokens: int = Field(..., description="Maximum tokens for memory injection")
|
max_injection_tokens: int = Field(..., description="Maximum tokens for memory injection")
|
||||||
token_counting: str = Field(..., description="Token counting strategy for memory injection ('tiktoken' or 'char')")
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryStatusResponse(BaseModel):
|
class MemoryStatusResponse(BaseModel):
|
||||||
@@ -149,7 +147,7 @@ async def get_memory() -> MemoryResponse:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
memory_data = get_memory_data(user_id=get_effective_user_id())
|
memory_data = get_memory_data()
|
||||||
return MemoryResponse(**memory_data)
|
return MemoryResponse(**memory_data)
|
||||||
|
|
||||||
|
|
||||||
@@ -169,7 +167,7 @@ async def reload_memory() -> MemoryResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
The reloaded memory data.
|
The reloaded memory data.
|
||||||
"""
|
"""
|
||||||
memory_data = reload_memory_data(user_id=get_effective_user_id())
|
memory_data = reload_memory_data()
|
||||||
return MemoryResponse(**memory_data)
|
return MemoryResponse(**memory_data)
|
||||||
|
|
||||||
|
|
||||||
@@ -183,7 +181,7 @@ async def reload_memory() -> MemoryResponse:
|
|||||||
async def clear_memory() -> MemoryResponse:
|
async def clear_memory() -> MemoryResponse:
|
||||||
"""Clear all persisted memory data."""
|
"""Clear all persisted memory data."""
|
||||||
try:
|
try:
|
||||||
memory_data = clear_memory_data(user_id=get_effective_user_id())
|
memory_data = clear_memory_data()
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(status_code=500, detail="Failed to clear memory data.") from exc
|
raise HTTPException(status_code=500, detail="Failed to clear memory data.") from exc
|
||||||
|
|
||||||
@@ -204,7 +202,6 @@ async def create_memory_fact_endpoint(request: FactCreateRequest) -> MemoryRespo
|
|||||||
content=request.content,
|
content=request.content,
|
||||||
category=request.category,
|
category=request.category,
|
||||||
confidence=request.confidence,
|
confidence=request.confidence,
|
||||||
user_id=get_effective_user_id(),
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _map_memory_fact_value_error(exc) from exc
|
raise _map_memory_fact_value_error(exc) from exc
|
||||||
@@ -224,7 +221,7 @@ async def create_memory_fact_endpoint(request: FactCreateRequest) -> MemoryRespo
|
|||||||
async def delete_memory_fact_endpoint(fact_id: str) -> MemoryResponse:
|
async def delete_memory_fact_endpoint(fact_id: str) -> MemoryResponse:
|
||||||
"""Delete a single fact from memory by fact id."""
|
"""Delete a single fact from memory by fact id."""
|
||||||
try:
|
try:
|
||||||
memory_data = delete_memory_fact(fact_id, user_id=get_effective_user_id())
|
memory_data = delete_memory_fact(fact_id)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail=f"Memory fact '{fact_id}' not found.") from exc
|
raise HTTPException(status_code=404, detail=f"Memory fact '{fact_id}' not found.") from exc
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -248,7 +245,6 @@ async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) -
|
|||||||
content=request.content,
|
content=request.content,
|
||||||
category=request.category,
|
category=request.category,
|
||||||
confidence=request.confidence,
|
confidence=request.confidence,
|
||||||
user_id=get_effective_user_id(),
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _map_memory_fact_value_error(exc) from exc
|
raise _map_memory_fact_value_error(exc) from exc
|
||||||
@@ -269,7 +265,7 @@ async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) -
|
|||||||
)
|
)
|
||||||
async def export_memory() -> MemoryResponse:
|
async def export_memory() -> MemoryResponse:
|
||||||
"""Export the current memory data."""
|
"""Export the current memory data."""
|
||||||
memory_data = get_memory_data(user_id=get_effective_user_id())
|
memory_data = get_memory_data()
|
||||||
return MemoryResponse(**memory_data)
|
return MemoryResponse(**memory_data)
|
||||||
|
|
||||||
|
|
||||||
@@ -283,7 +279,7 @@ async def export_memory() -> MemoryResponse:
|
|||||||
async def import_memory(request: MemoryResponse) -> MemoryResponse:
|
async def import_memory(request: MemoryResponse) -> MemoryResponse:
|
||||||
"""Import and persist memory data."""
|
"""Import and persist memory data."""
|
||||||
try:
|
try:
|
||||||
memory_data = import_memory_data(request.model_dump(), user_id=get_effective_user_id())
|
memory_data = import_memory_data(request.model_dump())
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(status_code=500, detail="Failed to import memory data.") from exc
|
raise HTTPException(status_code=500, detail="Failed to import memory data.") from exc
|
||||||
|
|
||||||
@@ -311,8 +307,7 @@ async def get_memory_config_endpoint() -> MemoryConfigResponse:
|
|||||||
"max_facts": 100,
|
"max_facts": 100,
|
||||||
"fact_confidence_threshold": 0.7,
|
"fact_confidence_threshold": 0.7,
|
||||||
"injection_enabled": true,
|
"injection_enabled": true,
|
||||||
"max_injection_tokens": 2000,
|
"max_injection_tokens": 2000
|
||||||
"token_counting": "tiktoken"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
@@ -325,7 +320,6 @@ async def get_memory_config_endpoint() -> MemoryConfigResponse:
|
|||||||
fact_confidence_threshold=config.fact_confidence_threshold,
|
fact_confidence_threshold=config.fact_confidence_threshold,
|
||||||
injection_enabled=config.injection_enabled,
|
injection_enabled=config.injection_enabled,
|
||||||
max_injection_tokens=config.max_injection_tokens,
|
max_injection_tokens=config.max_injection_tokens,
|
||||||
token_counting=config.token_counting,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -343,7 +337,7 @@ async def get_memory_status() -> MemoryStatusResponse:
|
|||||||
Combined memory configuration and current data.
|
Combined memory configuration and current data.
|
||||||
"""
|
"""
|
||||||
config = get_memory_config()
|
config = get_memory_config()
|
||||||
memory_data = get_memory_data(user_id=get_effective_user_id())
|
memory_data = get_memory_data()
|
||||||
|
|
||||||
return MemoryStatusResponse(
|
return MemoryStatusResponse(
|
||||||
config=MemoryConfigResponse(
|
config=MemoryConfigResponse(
|
||||||
@@ -354,7 +348,6 @@ async def get_memory_status() -> MemoryStatusResponse:
|
|||||||
fact_confidence_threshold=config.fact_confidence_threshold,
|
fact_confidence_threshold=config.fact_confidence_threshold,
|
||||||
injection_enabled=config.injection_enabled,
|
injection_enabled=config.injection_enabled,
|
||||||
max_injection_tokens=config.max_injection_tokens,
|
max_injection_tokens=config.max_injection_tokens,
|
||||||
token_counting=config.token_counting,
|
|
||||||
),
|
),
|
||||||
data=MemoryResponse(**memory_data),
|
data=MemoryResponse(**memory_data),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_config
|
from deerflow.config import get_app_config
|
||||||
from deerflow.config.app_config import AppConfig
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["models"])
|
router = APIRouter(prefix="/api", tags=["models"])
|
||||||
|
|
||||||
@@ -18,17 +17,10 @@ class ModelResponse(BaseModel):
|
|||||||
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
|
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
|
||||||
|
|
||||||
|
|
||||||
class TokenUsageResponse(BaseModel):
|
|
||||||
"""Token usage display configuration."""
|
|
||||||
|
|
||||||
enabled: bool = Field(default=False, description="Whether token usage display is enabled")
|
|
||||||
|
|
||||||
|
|
||||||
class ModelsListResponse(BaseModel):
|
class ModelsListResponse(BaseModel):
|
||||||
"""Response model for listing all models."""
|
"""Response model for listing all models."""
|
||||||
|
|
||||||
models: list[ModelResponse]
|
models: list[ModelResponse]
|
||||||
token_usage: TokenUsageResponse
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -37,14 +29,14 @@ class ModelsListResponse(BaseModel):
|
|||||||
summary="List All Models",
|
summary="List All Models",
|
||||||
description="Retrieve a list of all available AI models configured in the system.",
|
description="Retrieve a list of all available AI models configured in the system.",
|
||||||
)
|
)
|
||||||
async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResponse:
|
async def list_models() -> ModelsListResponse:
|
||||||
"""List all available models from configuration.
|
"""List all available models from configuration.
|
||||||
|
|
||||||
Returns model information suitable for frontend display,
|
Returns model information suitable for frontend display,
|
||||||
excluding sensitive fields like API keys and internal configuration.
|
excluding sensitive fields like API keys and internal configuration.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of all configured models with their metadata and token usage display settings.
|
A list of all configured models with their metadata.
|
||||||
|
|
||||||
Example Response:
|
Example Response:
|
||||||
```json
|
```json
|
||||||
@@ -52,27 +44,21 @@ async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResp
|
|||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
"name": "gpt-4",
|
"name": "gpt-4",
|
||||||
"model": "gpt-4",
|
|
||||||
"display_name": "GPT-4",
|
"display_name": "GPT-4",
|
||||||
"description": "OpenAI GPT-4 model",
|
"description": "OpenAI GPT-4 model",
|
||||||
"supports_thinking": false,
|
"supports_thinking": false
|
||||||
"supports_reasoning_effort": false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "claude-3-opus",
|
"name": "claude-3-opus",
|
||||||
"model": "claude-3-opus",
|
|
||||||
"display_name": "Claude 3 Opus",
|
"display_name": "Claude 3 Opus",
|
||||||
"description": "Anthropic Claude 3 Opus model",
|
"description": "Anthropic Claude 3 Opus model",
|
||||||
"supports_thinking": true,
|
"supports_thinking": true
|
||||||
"supports_reasoning_effort": false
|
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"token_usage": {
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
config = get_app_config()
|
||||||
models = [
|
models = [
|
||||||
ModelResponse(
|
ModelResponse(
|
||||||
name=model.name,
|
name=model.name,
|
||||||
@@ -84,10 +70,7 @@ async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResp
|
|||||||
)
|
)
|
||||||
for model in config.models
|
for model in config.models
|
||||||
]
|
]
|
||||||
return ModelsListResponse(
|
return ModelsListResponse(models=models)
|
||||||
models=models,
|
|
||||||
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -96,7 +79,7 @@ async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResp
|
|||||||
summary="Get Model Details",
|
summary="Get Model Details",
|
||||||
description="Retrieve detailed information about a specific AI model by its name.",
|
description="Retrieve detailed information about a specific AI model by its name.",
|
||||||
)
|
)
|
||||||
async def get_model(model_name: str, config: AppConfig = Depends(get_config)) -> ModelResponse:
|
async def get_model(model_name: str) -> ModelResponse:
|
||||||
"""Get a specific model by name.
|
"""Get a specific model by name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -118,6 +101,7 @@ async def get_model(model_name: str, config: AppConfig = Depends(get_config)) ->
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
config = get_app_config()
|
||||||
model = config.get_model_config(model_name)
|
model = config.get_model_config(model_name)
|
||||||
if model is None:
|
if model is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
||||||
|
|||||||
@@ -7,18 +7,17 @@ is reused so that conversation history is preserved across calls.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.deps import get_checkpointer, get_run_manager, get_stream_bridge
|
||||||
from app.gateway.deps import get_checkpointer, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
|
|
||||||
from app.gateway.pagination import trim_run_message_page
|
|
||||||
from app.gateway.routers.thread_runs import RunCreateRequest
|
from app.gateway.routers.thread_runs import RunCreateRequest
|
||||||
from app.gateway.services import sse_consumer, start_run, wait_for_run_completion
|
from app.gateway.services import sse_consumer, start_run
|
||||||
from deerflow.runtime import serialize_channel_values_for_api
|
from deerflow.runtime import serialize_channel_values
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/runs", tags=["runs"])
|
router = APIRouter(prefix="/api/runs", tags=["runs"])
|
||||||
@@ -66,78 +65,23 @@ async def stateless_wait(body: RunCreateRequest, request: Request) -> dict:
|
|||||||
Otherwise a new temporary thread is created.
|
Otherwise a new temporary thread is created.
|
||||||
"""
|
"""
|
||||||
thread_id = _resolve_thread_id(body)
|
thread_id = _resolve_thread_id(body)
|
||||||
bridge = get_stream_bridge(request)
|
|
||||||
run_mgr = get_run_manager(request)
|
|
||||||
record = await start_run(body, thread_id, request)
|
record = await start_run(body, thread_id, request)
|
||||||
|
|
||||||
completed = True
|
|
||||||
if record.task is not None:
|
if record.task is not None:
|
||||||
completed = await wait_for_run_completion(bridge, record, request, run_mgr)
|
|
||||||
|
|
||||||
if completed:
|
|
||||||
checkpointer = get_checkpointer(request)
|
|
||||||
config = {"configurable": {"thread_id": thread_id}}
|
|
||||||
try:
|
try:
|
||||||
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
await record.task
|
||||||
if checkpoint_tuple is not None:
|
except asyncio.CancelledError:
|
||||||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
pass
|
||||||
channel_values = checkpoint.get("channel_values", {})
|
|
||||||
return serialize_channel_values_for_api(channel_values)
|
checkpointer = get_checkpointer(request)
|
||||||
except Exception:
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
logger.exception("Failed to fetch final state for run %s", record.run_id)
|
try:
|
||||||
|
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||||||
|
if checkpoint_tuple is not None:
|
||||||
|
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||||
|
channel_values = checkpoint.get("channel_values", {})
|
||||||
|
return serialize_channel_values(channel_values)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch final state for run %s", record.run_id)
|
||||||
|
|
||||||
return {"status": record.status.value, "error": record.error}
|
return {"status": record.status.value, "error": record.error}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Run-scoped read endpoints
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_run(run_id: str, request: Request) -> dict:
|
|
||||||
"""Fetch run by run_id with user ownership check. Raises 404 if not found."""
|
|
||||||
run_store = get_run_store(request)
|
|
||||||
record = await run_store.get(run_id) # user_id=AUTO filters by contextvar
|
|
||||||
if record is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{run_id}/messages")
|
|
||||||
@require_permission("runs", "read")
|
|
||||||
async def run_messages(
|
|
||||||
run_id: str,
|
|
||||||
request: Request,
|
|
||||||
limit: int = Query(default=50, le=200, ge=1),
|
|
||||||
before_seq: int | None = Query(default=None),
|
|
||||||
after_seq: int | None = Query(default=None),
|
|
||||||
) -> dict:
|
|
||||||
"""Return paginated messages for a run (cursor-based).
|
|
||||||
|
|
||||||
Pagination:
|
|
||||||
- after_seq: messages with seq > after_seq (forward)
|
|
||||||
- before_seq: messages with seq < before_seq (backward)
|
|
||||||
- neither: latest messages
|
|
||||||
|
|
||||||
Response: { data: [...], has_more: bool }
|
|
||||||
"""
|
|
||||||
run = await _resolve_run(run_id, request)
|
|
||||||
event_store = get_run_event_store(request)
|
|
||||||
rows = await event_store.list_messages_by_run(
|
|
||||||
run["thread_id"],
|
|
||||||
run_id,
|
|
||||||
limit=limit + 1,
|
|
||||||
before_seq=before_seq,
|
|
||||||
after_seq=after_seq,
|
|
||||||
)
|
|
||||||
data, has_more = trim_run_message_page(rows, limit=limit, after_seq=after_seq)
|
|
||||||
return {"data": data, "has_more": has_more}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{run_id}/feedback")
|
|
||||||
@require_permission("runs", "read")
|
|
||||||
async def run_feedback(run_id: str, request: Request) -> list[dict]:
|
|
||||||
"""Return all feedback for a run."""
|
|
||||||
run = await _resolve_run(run_id, request)
|
|
||||||
feedback_repo = get_feedback_repo(request)
|
|
||||||
return await feedback_repo.list_by_run(run["thread_id"], run_id)
|
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_config
|
|
||||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.app_config import AppConfig
|
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||||
from deerflow.skills import Skill
|
from deerflow.skills import Skill, load_skills
|
||||||
from deerflow.skills.installer import SkillAlreadyExistsError
|
from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive
|
||||||
|
from deerflow.skills.manager import (
|
||||||
|
append_history,
|
||||||
|
atomic_write,
|
||||||
|
custom_skill_exists,
|
||||||
|
ensure_custom_skill_is_editable,
|
||||||
|
get_custom_skill_dir,
|
||||||
|
get_custom_skill_file,
|
||||||
|
get_skill_history_file,
|
||||||
|
read_custom_skill_content,
|
||||||
|
read_history,
|
||||||
|
validate_skill_markdown_content,
|
||||||
|
)
|
||||||
from deerflow.skills.security_scanner import scan_skill_content
|
from deerflow.skills.security_scanner import scan_skill_content
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
|
||||||
from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,7 +36,7 @@ class SkillResponse(BaseModel):
|
|||||||
name: str = Field(..., description="Name of the skill")
|
name: str = Field(..., description="Name of the skill")
|
||||||
description: str = Field(..., description="Description of what the skill does")
|
description: str = Field(..., description="Description of what the skill does")
|
||||||
license: str | None = Field(None, description="License information")
|
license: str | None = Field(None, description="License information")
|
||||||
category: SkillCategory = Field(..., description="Category of the skill (public or custom)")
|
category: str = Field(..., description="Category of the skill (public or custom)")
|
||||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
||||||
|
|
||||||
|
|
||||||
@@ -91,9 +100,9 @@ def _skill_to_response(skill: Skill) -> SkillResponse:
|
|||||||
summary="List All Skills",
|
summary="List All Skills",
|
||||||
description="Retrieve a list of all available skills from both public and custom directories.",
|
description="Retrieve a list of all available skills from both public and custom directories.",
|
||||||
)
|
)
|
||||||
async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
async def list_skills() -> SkillsListResponse:
|
||||||
try:
|
try:
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
skills = load_skills(enabled_only=False)
|
||||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
||||||
@@ -106,10 +115,10 @@ async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResp
|
|||||||
summary="Install Skill",
|
summary="Install Skill",
|
||||||
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
||||||
)
|
)
|
||||||
async def install_skill(request: SkillInstallRequest, config: AppConfig = Depends(get_config)) -> SkillInstallResponse:
|
async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse:
|
||||||
try:
|
try:
|
||||||
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
||||||
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
|
result = install_skill_from_archive(skill_file_path)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return SkillInstallResponse(**result)
|
return SkillInstallResponse(**result)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@@ -126,9 +135,9 @@ async def install_skill(request: SkillInstallRequest, config: AppConfig = Depend
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
||||||
async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
async def list_custom_skills() -> SkillsListResponse:
|
||||||
try:
|
try:
|
||||||
skills = [skill for skill in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM]
|
skills = [skill for skill in load_skills(enabled_only=False) if skill.category == "custom"]
|
||||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to list custom skills: %s", e, exc_info=True)
|
logger.error("Failed to list custom skills: %s", e, exc_info=True)
|
||||||
@@ -136,14 +145,13 @@ async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsL
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
|
@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
|
||||||
async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
async def get_custom_skill(skill_name: str) -> CustomSkillContentResponse:
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skills = load_skills(enabled_only=False)
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
skill = next((s for s in skills if s.name == skill_name and s.category == "custom"), None)
|
||||||
skill = next((s for s in skills if s.name == skill_name and s.category == SkillCategory.CUSTOM), None)
|
|
||||||
if skill is None:
|
if skill is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
||||||
return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=get_or_new_skill_storage(app_config=config).read_custom_skill(skill_name))
|
return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=read_custom_skill_content(skill_name))
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -152,31 +160,30 @@ async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_conf
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
|
@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
|
||||||
async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest) -> CustomSkillContentResponse:
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
ensure_custom_skill_is_editable(skill_name)
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
validate_skill_markdown_content(skill_name, request.content)
|
||||||
storage.ensure_custom_skill_is_editable(skill_name)
|
scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/SKILL.md")
|
||||||
storage.validate_skill_markdown_content(skill_name, request.content)
|
|
||||||
scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
|
|
||||||
if scan.decision == "block":
|
if scan.decision == "block":
|
||||||
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
|
raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
|
||||||
prev_content = storage.read_custom_skill(skill_name)
|
skill_file = get_custom_skill_dir(skill_name) / "SKILL.md"
|
||||||
storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content)
|
prev_content = skill_file.read_text(encoding="utf-8")
|
||||||
storage.append_history(
|
atomic_write(skill_file, request.content)
|
||||||
|
append_history(
|
||||||
skill_name,
|
skill_name,
|
||||||
{
|
{
|
||||||
"action": "human_edit",
|
"action": "human_edit",
|
||||||
"author": "human",
|
"author": "human",
|
||||||
"thread_id": None,
|
"thread_id": None,
|
||||||
"file_path": SKILL_MD_FILE,
|
"file_path": "SKILL.md",
|
||||||
"prev_content": prev_content,
|
"prev_content": prev_content,
|
||||||
"new_content": request.content,
|
"new_content": request.content,
|
||||||
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return await get_custom_skill(skill_name, config)
|
return await get_custom_skill(skill_name)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@@ -189,22 +196,24 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
||||||
async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> dict[str, bool]:
|
async def delete_custom_skill(skill_name: str) -> dict[str, bool]:
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
ensure_custom_skill_is_editable(skill_name)
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
skill_dir = get_custom_skill_dir(skill_name)
|
||||||
storage.delete_custom_skill(
|
prev_content = read_custom_skill_content(skill_name)
|
||||||
|
append_history(
|
||||||
skill_name,
|
skill_name,
|
||||||
history_meta={
|
{
|
||||||
"action": "human_delete",
|
"action": "human_delete",
|
||||||
"author": "human",
|
"author": "human",
|
||||||
"thread_id": None,
|
"thread_id": None,
|
||||||
"file_path": SKILL_MD_FILE,
|
"file_path": "SKILL.md",
|
||||||
"prev_content": None,
|
"prev_content": prev_content,
|
||||||
"new_content": None,
|
"new_content": None,
|
||||||
"scanner": {"decision": "allow", "reason": "Deletion requested."},
|
"scanner": {"decision": "allow", "reason": "Deletion requested."},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
shutil.rmtree(skill_dir)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@@ -217,13 +226,11 @@ async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_c
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
||||||
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
async def get_custom_skill_history(skill_name: str) -> CustomSkillHistoryResponse:
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
if not custom_skill_exists(skill_name) and not get_skill_history_file(skill_name).exists():
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
|
||||||
if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
|
|
||||||
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
||||||
return CustomSkillHistoryResponse(history=storage.read_history(skill_name))
|
return CustomSkillHistoryResponse(history=read_history(skill_name))
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -232,39 +239,38 @@ async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
|
@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
|
||||||
async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
|
async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest) -> CustomSkillContentResponse:
|
||||||
try:
|
try:
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
if not custom_skill_exists(skill_name) and not get_skill_history_file(skill_name).exists():
|
||||||
if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
|
|
||||||
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
||||||
history = storage.read_history(skill_name)
|
history = read_history(skill_name)
|
||||||
if not history:
|
if not history:
|
||||||
raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
|
raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
|
||||||
record = history[request.history_index]
|
record = history[request.history_index]
|
||||||
target_content = record.get("prev_content")
|
target_content = record.get("prev_content")
|
||||||
if target_content is None:
|
if target_content is None:
|
||||||
raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
|
raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
|
||||||
storage.validate_skill_markdown_content(skill_name, target_content)
|
validate_skill_markdown_content(skill_name, target_content)
|
||||||
scan = await scan_skill_content(target_content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
|
scan = await scan_skill_content(target_content, executable=False, location=f"{skill_name}/SKILL.md")
|
||||||
skill_file = storage.get_custom_skill_file(skill_name)
|
skill_file = get_custom_skill_file(skill_name)
|
||||||
current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None
|
current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None
|
||||||
history_entry = {
|
history_entry = {
|
||||||
"action": "rollback",
|
"action": "rollback",
|
||||||
"author": "human",
|
"author": "human",
|
||||||
"thread_id": None,
|
"thread_id": None,
|
||||||
"file_path": SKILL_MD_FILE,
|
"file_path": "SKILL.md",
|
||||||
"prev_content": current_content,
|
"prev_content": current_content,
|
||||||
"new_content": target_content,
|
"new_content": target_content,
|
||||||
"rollback_from_ts": record.get("ts"),
|
"rollback_from_ts": record.get("ts"),
|
||||||
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
"scanner": {"decision": scan.decision, "reason": scan.reason},
|
||||||
}
|
}
|
||||||
if scan.decision == "block":
|
if scan.decision == "block":
|
||||||
storage.append_history(skill_name, history_entry)
|
append_history(skill_name, history_entry)
|
||||||
raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
|
raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
|
||||||
storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
|
atomic_write(skill_file, target_content)
|
||||||
storage.append_history(skill_name, history_entry)
|
append_history(skill_name, history_entry)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return await get_custom_skill(skill_name, config)
|
return await get_custom_skill(skill_name)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except IndexError:
|
except IndexError:
|
||||||
@@ -284,10 +290,9 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
|
|||||||
summary="Get Skill Details",
|
summary="Get Skill Details",
|
||||||
description="Retrieve detailed information about a specific skill by its name.",
|
description="Retrieve detailed information about a specific skill by its name.",
|
||||||
)
|
)
|
||||||
async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
async def get_skill(skill_name: str) -> SkillResponse:
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skills = load_skills(enabled_only=False)
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
|
||||||
skill = next((s for s in skills if s.name == skill_name), None)
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
|
||||||
if skill is None:
|
if skill is None:
|
||||||
@@ -307,10 +312,9 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
|
|||||||
summary="Update Skill",
|
summary="Update Skill",
|
||||||
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
||||||
)
|
)
|
||||||
async def update_skill(skill_name: str, request: SkillUpdateRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillResponse:
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skills = load_skills(enabled_only=False)
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
|
||||||
skill = next((s for s in skills if s.name == skill_name), None)
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
|
||||||
if skill is None:
|
if skill is None:
|
||||||
@@ -336,7 +340,7 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
|||||||
reload_extensions_config()
|
reload_extensions_config()
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
|
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
skills = load_skills(enabled_only=False)
|
||||||
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
|
||||||
if updated_skill is None:
|
if updated_skill is None:
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Request
|
||||||
from langchain_core.messages import HumanMessage, SystemMessage
|
from langchain_core.messages import HumanMessage, SystemMessage
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
from app.gateway.deps import get_config
|
|
||||||
from deerflow.config.app_config import AppConfig
|
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -31,31 +28,6 @@ class SuggestionsResponse(BaseModel):
|
|||||||
suggestions: list[str] = Field(default_factory=list, description="Suggested follow-up questions")
|
suggestions: list[str] = Field(default_factory=list, description="Suggested follow-up questions")
|
||||||
|
|
||||||
|
|
||||||
# Matches a complete <think>...</think> block (case-insensitive, spans newlines).
|
|
||||||
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re.DOTALL)
|
|
||||||
# Matches a dangling, unclosed <think> (model truncated at max_tokens mid-thought).
|
|
||||||
_OPEN_THINK_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_think_blocks(text: str) -> str:
|
|
||||||
"""Remove reasoning-model ``<think>...</think>`` blocks from the response.
|
|
||||||
|
|
||||||
Reasoning models such as MiniMax-M3 inline their chain-of-thought into the
|
|
||||||
message ``content`` wrapped in ``<think>...</think>`` (``reasoning_split``
|
|
||||||
defaults to false), rather than exposing a separate ``reasoning_content``
|
|
||||||
field. The thinking text frequently contains ``[`` / ``]`` characters, which
|
|
||||||
corrupted the downstream ``find('[')`` / ``rfind(']')`` JSON extraction and
|
|
||||||
produced empty suggestions. We strip the reasoning before parsing so only
|
|
||||||
the actual answer remains.
|
|
||||||
"""
|
|
||||||
text = _THINK_BLOCK_RE.sub("", text)
|
|
||||||
# Drop any unclosed <think> (and everything after it) left by truncation.
|
|
||||||
open_match = _OPEN_THINK_RE.search(text)
|
|
||||||
if open_match:
|
|
||||||
text = text[: open_match.start()]
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_markdown_code_fence(text: str) -> str:
|
def _strip_markdown_code_fence(text: str) -> str:
|
||||||
stripped = text.strip()
|
stripped = text.strip()
|
||||||
if not stripped.startswith("```"):
|
if not stripped.startswith("```"):
|
||||||
@@ -67,8 +39,7 @@ def _strip_markdown_code_fence(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_json_string_list(text: str) -> list[str] | None:
|
def _parse_json_string_list(text: str) -> list[str] | None:
|
||||||
candidate = _strip_think_blocks(text)
|
candidate = _strip_markdown_code_fence(text)
|
||||||
candidate = _strip_markdown_code_fence(candidate)
|
|
||||||
start = candidate.find("[")
|
start = candidate.find("[")
|
||||||
end = candidate.rfind("]")
|
end = candidate.rfind("]")
|
||||||
if start == -1 or end == -1 or end <= start:
|
if start == -1 or end == -1 or end <= start:
|
||||||
@@ -129,12 +100,7 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str:
|
|||||||
description="Generate short follow-up questions a user might ask next, based on recent conversation context.",
|
description="Generate short follow-up questions a user might ask next, based on recent conversation context.",
|
||||||
)
|
)
|
||||||
@require_permission("threads", "read", owner_check=True)
|
@require_permission("threads", "read", owner_check=True)
|
||||||
async def generate_suggestions(
|
async def generate_suggestions(thread_id: str, body: SuggestionsRequest, request: Request) -> SuggestionsResponse:
|
||||||
thread_id: str,
|
|
||||||
body: SuggestionsRequest,
|
|
||||||
request: Request,
|
|
||||||
config: AppConfig = Depends(get_config),
|
|
||||||
) -> SuggestionsResponse:
|
|
||||||
if not body.messages:
|
if not body.messages:
|
||||||
return SuggestionsResponse(suggestions=[])
|
return SuggestionsResponse(suggestions=[])
|
||||||
|
|
||||||
@@ -156,8 +122,8 @@ async def generate_suggestions(
|
|||||||
user_content = f"Conversation Context:\n{conversation}\n\nGenerate {n} follow-up questions"
|
user_content = f"Conversation Context:\n{conversation}\n\nGenerate {n} follow-up questions"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=config)
|
model = create_chat_model(name=body.model_name, thinking_enabled=False)
|
||||||
response = await model.ainvoke([SystemMessage(content=system_instruction), HumanMessage(content=user_content)], config={"run_name": "suggest_agent"})
|
response = await model.ainvoke([SystemMessage(content=system_instruction), HumanMessage(content=user_content)])
|
||||||
raw = _extract_response_text(response.content)
|
raw = _extract_response_text(response.content)
|
||||||
suggestions = _parse_json_string_list(raw) or []
|
suggestions = _parse_json_string_list(raw) or []
|
||||||
cleaned = [s.replace("\n", " ").strip() for s in suggestions if s.strip()]
|
cleaned = [s.replace("\n", " ").strip() for s in suggestions if s.strip()]
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ from fastapi.responses import Response, StreamingResponse
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
from app.gateway.deps import get_checkpointer, get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
|
from app.gateway.deps import get_checkpointer, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
|
||||||
from app.gateway.pagination import trim_run_message_page
|
from app.gateway.services import sse_consumer, start_run
|
||||||
from app.gateway.services import sse_consumer, start_run, wait_for_run_completion
|
from deerflow.runtime import RunRecord, serialize_channel_values
|
||||||
from deerflow.runtime import RunRecord, RunStatus, serialize_channel_values_for_api
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/threads", tags=["runs"])
|
router = APIRouter(prefix="/api/threads", tags=["runs"])
|
||||||
@@ -55,6 +54,7 @@ class RunCreateRequest(BaseModel):
|
|||||||
after_seconds: float | None = Field(default=None, description="Delayed execution")
|
after_seconds: float | None = Field(default=None, description="Delayed execution")
|
||||||
if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy")
|
if_not_exists: Literal["reject", "create"] = Field(default="create", description="Thread creation policy")
|
||||||
feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys")
|
feedback_keys: list[str] | None = Field(default=None, description="LangSmith feedback keys")
|
||||||
|
follow_up_to_run_id: str | None = Field(default=None, description="Run ID this message follows up on. Auto-detected from latest successful run if not provided.")
|
||||||
|
|
||||||
|
|
||||||
class RunResponse(BaseModel):
|
class RunResponse(BaseModel):
|
||||||
@@ -67,35 +67,6 @@ class RunResponse(BaseModel):
|
|||||||
multitask_strategy: str = "reject"
|
multitask_strategy: str = "reject"
|
||||||
created_at: str = ""
|
created_at: str = ""
|
||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
total_input_tokens: int = 0
|
|
||||||
total_output_tokens: int = 0
|
|
||||||
total_tokens: int = 0
|
|
||||||
llm_call_count: int = 0
|
|
||||||
lead_agent_tokens: int = 0
|
|
||||||
subagent_tokens: int = 0
|
|
||||||
middleware_tokens: int = 0
|
|
||||||
message_count: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadTokenUsageModelBreakdown(BaseModel):
|
|
||||||
tokens: int = 0
|
|
||||||
runs: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadTokenUsageCallerBreakdown(BaseModel):
|
|
||||||
lead_agent: int = 0
|
|
||||||
subagent: int = 0
|
|
||||||
middleware: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadTokenUsageResponse(BaseModel):
|
|
||||||
thread_id: str
|
|
||||||
total_tokens: int = 0
|
|
||||||
total_input_tokens: int = 0
|
|
||||||
total_output_tokens: int = 0
|
|
||||||
total_runs: int = 0
|
|
||||||
by_model: dict[str, ThreadTokenUsageModelBreakdown] = Field(default_factory=dict)
|
|
||||||
by_caller: ThreadTokenUsageCallerBreakdown = Field(default_factory=ThreadTokenUsageCallerBreakdown)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -103,12 +74,6 @@ class ThreadTokenUsageResponse(BaseModel):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _cancel_conflict_detail(run_id: str, record: RunRecord) -> str:
|
|
||||||
if record.status in (RunStatus.pending, RunStatus.running):
|
|
||||||
return f"Run {run_id} is not active on this worker and cannot be cancelled"
|
|
||||||
return f"Run {run_id} is not cancellable (status: {record.status.value})"
|
|
||||||
|
|
||||||
|
|
||||||
def _record_to_response(record: RunRecord) -> RunResponse:
|
def _record_to_response(record: RunRecord) -> RunResponse:
|
||||||
return RunResponse(
|
return RunResponse(
|
||||||
run_id=record.run_id,
|
run_id=record.run_id,
|
||||||
@@ -120,14 +85,6 @@ def _record_to_response(record: RunRecord) -> RunResponse:
|
|||||||
multitask_strategy=record.multitask_strategy,
|
multitask_strategy=record.multitask_strategy,
|
||||||
created_at=record.created_at,
|
created_at=record.created_at,
|
||||||
updated_at=record.updated_at,
|
updated_at=record.updated_at,
|
||||||
total_input_tokens=record.total_input_tokens,
|
|
||||||
total_output_tokens=record.total_output_tokens,
|
|
||||||
total_tokens=record.total_tokens,
|
|
||||||
llm_call_count=record.llm_call_count,
|
|
||||||
lead_agent_tokens=record.lead_agent_tokens,
|
|
||||||
subagent_tokens=record.subagent_tokens,
|
|
||||||
middleware_tokens=record.middleware_tokens,
|
|
||||||
message_count=record.message_count,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -176,25 +133,24 @@ async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -
|
|||||||
@require_permission("runs", "create", owner_check=True, require_existing=True)
|
@require_permission("runs", "create", owner_check=True, require_existing=True)
|
||||||
async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> dict:
|
async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> dict:
|
||||||
"""Create a run and block until it completes, returning the final state."""
|
"""Create a run and block until it completes, returning the final state."""
|
||||||
bridge = get_stream_bridge(request)
|
|
||||||
run_mgr = get_run_manager(request)
|
|
||||||
record = await start_run(body, thread_id, request)
|
record = await start_run(body, thread_id, request)
|
||||||
|
|
||||||
completed = True
|
|
||||||
if record.task is not None:
|
if record.task is not None:
|
||||||
completed = await wait_for_run_completion(bridge, record, request, run_mgr)
|
|
||||||
|
|
||||||
if completed:
|
|
||||||
checkpointer = get_checkpointer(request)
|
|
||||||
config = {"configurable": {"thread_id": thread_id}}
|
|
||||||
try:
|
try:
|
||||||
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
await record.task
|
||||||
if checkpoint_tuple is not None:
|
except asyncio.CancelledError:
|
||||||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
pass
|
||||||
channel_values = checkpoint.get("channel_values", {})
|
|
||||||
return serialize_channel_values_for_api(channel_values)
|
checkpointer = get_checkpointer(request)
|
||||||
except Exception:
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
logger.exception("Failed to fetch final state for run %s", record.run_id)
|
try:
|
||||||
|
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||||||
|
if checkpoint_tuple is not None:
|
||||||
|
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||||
|
channel_values = checkpoint.get("channel_values", {})
|
||||||
|
return serialize_channel_values(channel_values)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch final state for run %s", record.run_id)
|
||||||
|
|
||||||
return {"status": record.status.value, "error": record.error}
|
return {"status": record.status.value, "error": record.error}
|
||||||
|
|
||||||
@@ -204,8 +160,7 @@ async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) ->
|
|||||||
async def list_runs(thread_id: str, request: Request) -> list[RunResponse]:
|
async def list_runs(thread_id: str, request: Request) -> list[RunResponse]:
|
||||||
"""List all runs for a thread."""
|
"""List all runs for a thread."""
|
||||||
run_mgr = get_run_manager(request)
|
run_mgr = get_run_manager(request)
|
||||||
user_id = await get_current_user(request)
|
records = await run_mgr.list_by_thread(thread_id)
|
||||||
records = await run_mgr.list_by_thread(thread_id, user_id=user_id)
|
|
||||||
return [_record_to_response(r) for r in records]
|
return [_record_to_response(r) for r in records]
|
||||||
|
|
||||||
|
|
||||||
@@ -214,8 +169,7 @@ async def list_runs(thread_id: str, request: Request) -> list[RunResponse]:
|
|||||||
async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse:
|
async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse:
|
||||||
"""Get details of a specific run."""
|
"""Get details of a specific run."""
|
||||||
run_mgr = get_run_manager(request)
|
run_mgr = get_run_manager(request)
|
||||||
user_id = await get_current_user(request)
|
record = run_mgr.get(run_id)
|
||||||
record = await run_mgr.get(run_id, user_id=user_id)
|
|
||||||
if record is None or record.thread_id != thread_id:
|
if record is None or record.thread_id != thread_id:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
return _record_to_response(record)
|
return _record_to_response(record)
|
||||||
@@ -238,13 +192,16 @@ async def cancel_run(
|
|||||||
- wait=false: Return immediately with 202
|
- wait=false: Return immediately with 202
|
||||||
"""
|
"""
|
||||||
run_mgr = get_run_manager(request)
|
run_mgr = get_run_manager(request)
|
||||||
record = await run_mgr.get(run_id)
|
record = run_mgr.get(run_id)
|
||||||
if record is None or record.thread_id != thread_id:
|
if record is None or record.thread_id != thread_id:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
|
|
||||||
cancelled = await run_mgr.cancel(run_id, action=action)
|
cancelled = await run_mgr.cancel(run_id, action=action)
|
||||||
if not cancelled:
|
if not cancelled:
|
||||||
raise HTTPException(status_code=409, detail=_cancel_conflict_detail(run_id, record))
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Run {run_id} is not cancellable (status: {record.status.value})",
|
||||||
|
)
|
||||||
|
|
||||||
if wait and record.task is not None:
|
if wait and record.task is not None:
|
||||||
try:
|
try:
|
||||||
@@ -260,14 +217,12 @@ async def cancel_run(
|
|||||||
@require_permission("runs", "read", owner_check=True)
|
@require_permission("runs", "read", owner_check=True)
|
||||||
async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingResponse:
|
async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingResponse:
|
||||||
"""Join an existing run's SSE stream."""
|
"""Join an existing run's SSE stream."""
|
||||||
|
bridge = get_stream_bridge(request)
|
||||||
run_mgr = get_run_manager(request)
|
run_mgr = get_run_manager(request)
|
||||||
record = await run_mgr.get(run_id)
|
record = run_mgr.get(run_id)
|
||||||
if record is None or record.thread_id != thread_id:
|
if record is None or record.thread_id != thread_id:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
if record.store_only:
|
|
||||||
raise HTTPException(status_code=409, detail=f"Run {run_id} is not active on this worker and cannot be streamed")
|
|
||||||
|
|
||||||
bridge = get_stream_bridge(request)
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
sse_consumer(bridge, record, request, run_mgr),
|
sse_consumer(bridge, record, request, run_mgr),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
@@ -279,12 +234,7 @@ async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingRe
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Register GET and POST as separate routes so each method gets a unique OpenAPI
|
@router.api_route("/{thread_id}/runs/{run_id}/stream", methods=["GET", "POST"], response_model=None)
|
||||||
# operationId. ``api_route(methods=["GET", "POST"])`` shares one route registration
|
|
||||||
# across both methods, which makes FastAPI emit the same ``operationId`` twice and
|
|
||||||
# warn about a duplicate operation id during OpenAPI generation.
|
|
||||||
@router.get("/{thread_id}/runs/{run_id}/stream", response_model=None)
|
|
||||||
@router.post("/{thread_id}/runs/{run_id}/stream", response_model=None)
|
|
||||||
@require_permission("runs", "read", owner_check=True)
|
@require_permission("runs", "read", owner_check=True)
|
||||||
async def stream_existing_run(
|
async def stream_existing_run(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
@@ -301,18 +251,14 @@ async def stream_existing_run(
|
|||||||
remaining buffered events so the client observes a clean shutdown.
|
remaining buffered events so the client observes a clean shutdown.
|
||||||
"""
|
"""
|
||||||
run_mgr = get_run_manager(request)
|
run_mgr = get_run_manager(request)
|
||||||
record = await run_mgr.get(run_id)
|
record = run_mgr.get(run_id)
|
||||||
if record is None or record.thread_id != thread_id:
|
if record is None or record.thread_id != thread_id:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
if record.store_only and action is None:
|
|
||||||
raise HTTPException(status_code=409, detail=f"Run {run_id} is not active on this worker and cannot be streamed")
|
|
||||||
|
|
||||||
# Cancel if an action was requested (stop-button / interrupt flow)
|
# Cancel if an action was requested (stop-button / interrupt flow)
|
||||||
if action is not None:
|
if action is not None:
|
||||||
cancelled = await run_mgr.cancel(run_id, action=action)
|
cancelled = await run_mgr.cancel(run_id, action=action)
|
||||||
if not cancelled:
|
if cancelled and wait and record.task is not None:
|
||||||
raise HTTPException(status_code=409, detail=_cancel_conflict_detail(run_id, record))
|
|
||||||
if wait and record.task is not None:
|
|
||||||
try:
|
try:
|
||||||
await record.task
|
await record.task
|
||||||
except (asyncio.CancelledError, Exception):
|
except (asyncio.CancelledError, Exception):
|
||||||
@@ -345,66 +291,17 @@ async def list_thread_messages(
|
|||||||
before_seq: int | None = Query(default=None),
|
before_seq: int | None = Query(default=None),
|
||||||
after_seq: int | None = Query(default=None),
|
after_seq: int | None = Query(default=None),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return displayable messages for a thread (across all runs), with feedback attached."""
|
"""Return displayable messages for a thread (across all runs)."""
|
||||||
event_store = get_run_event_store(request)
|
event_store = get_run_event_store(request)
|
||||||
messages = await event_store.list_messages(thread_id, limit=limit, before_seq=before_seq, after_seq=after_seq)
|
return await event_store.list_messages(thread_id, limit=limit, before_seq=before_seq, after_seq=after_seq)
|
||||||
|
|
||||||
# Attach feedback to the last AI message of each run
|
|
||||||
feedback_repo = get_feedback_repo(request)
|
|
||||||
user_id = await get_current_user(request)
|
|
||||||
feedback_map = await feedback_repo.list_by_thread_grouped(thread_id, user_id=user_id)
|
|
||||||
|
|
||||||
# Find the last ai_message per run_id
|
|
||||||
last_ai_per_run: dict[str, int] = {} # run_id -> index in messages list
|
|
||||||
for i, msg in enumerate(messages):
|
|
||||||
if msg.get("event_type") == "ai_message":
|
|
||||||
last_ai_per_run[msg["run_id"]] = i
|
|
||||||
|
|
||||||
# Attach feedback field
|
|
||||||
last_ai_indices = set(last_ai_per_run.values())
|
|
||||||
for i, msg in enumerate(messages):
|
|
||||||
if i in last_ai_indices:
|
|
||||||
run_id = msg["run_id"]
|
|
||||||
fb = feedback_map.get(run_id)
|
|
||||||
msg["feedback"] = (
|
|
||||||
{
|
|
||||||
"feedback_id": fb["feedback_id"],
|
|
||||||
"rating": fb["rating"],
|
|
||||||
"comment": fb.get("comment"),
|
|
||||||
}
|
|
||||||
if fb
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg["feedback"] = None
|
|
||||||
|
|
||||||
return messages
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{thread_id}/runs/{run_id}/messages")
|
@router.get("/{thread_id}/runs/{run_id}/messages")
|
||||||
@require_permission("runs", "read", owner_check=True)
|
@require_permission("runs", "read", owner_check=True)
|
||||||
async def list_run_messages(
|
async def list_run_messages(thread_id: str, run_id: str, request: Request) -> list[dict]:
|
||||||
thread_id: str,
|
"""Return displayable messages for a specific run."""
|
||||||
run_id: str,
|
|
||||||
request: Request,
|
|
||||||
limit: int = Query(default=50, le=200, ge=1),
|
|
||||||
before_seq: int | None = Query(default=None),
|
|
||||||
after_seq: int | None = Query(default=None),
|
|
||||||
) -> dict:
|
|
||||||
"""Return paginated messages for a specific run.
|
|
||||||
|
|
||||||
Response: { data: [...], has_more: bool }
|
|
||||||
"""
|
|
||||||
event_store = get_run_event_store(request)
|
event_store = get_run_event_store(request)
|
||||||
rows = await event_store.list_messages_by_run(
|
return await event_store.list_messages_by_run(thread_id, run_id)
|
||||||
thread_id,
|
|
||||||
run_id,
|
|
||||||
limit=limit + 1,
|
|
||||||
before_seq=before_seq,
|
|
||||||
after_seq=after_seq,
|
|
||||||
)
|
|
||||||
data, has_more = trim_run_message_page(rows, limit=limit, after_seq=after_seq)
|
|
||||||
return {"data": data, "has_more": has_more}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{thread_id}/runs/{run_id}/events")
|
@router.get("/{thread_id}/runs/{run_id}/events")
|
||||||
@@ -422,17 +319,10 @@ async def list_run_events(
|
|||||||
return await event_store.list_events(thread_id, run_id, event_types=types, limit=limit)
|
return await event_store.list_events(thread_id, run_id, event_types=types, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{thread_id}/token-usage", response_model=ThreadTokenUsageResponse)
|
@router.get("/{thread_id}/token-usage")
|
||||||
@require_permission("threads", "read", owner_check=True)
|
@require_permission("threads", "read", owner_check=True)
|
||||||
async def thread_token_usage(
|
async def thread_token_usage(thread_id: str, request: Request) -> dict:
|
||||||
thread_id: str,
|
|
||||||
request: Request,
|
|
||||||
include_active: bool = Query(default=False, description="Include running run progress snapshots"),
|
|
||||||
) -> ThreadTokenUsageResponse:
|
|
||||||
"""Thread-level token usage aggregation."""
|
"""Thread-level token usage aggregation."""
|
||||||
run_store = get_run_store(request)
|
run_store = get_run_store(request)
|
||||||
if include_active:
|
agg = await run_store.aggregate_tokens_by_thread(thread_id)
|
||||||
agg = await run_store.aggregate_tokens_by_thread(thread_id, include_active=True)
|
return {"thread_id": thread_id, **agg}
|
||||||
else:
|
|
||||||
agg = await run_store.aggregate_tokens_by_thread(thread_id)
|
|
||||||
return ThreadTokenUsageResponse(thread_id=thread_id, **agg)
|
|
||||||
|
|||||||
@@ -13,21 +13,18 @@ matching the LangGraph Platform wire format expected by the
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from langgraph.checkpoint.base import empty_checkpoint, uuid6
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
from app.gateway.deps import get_checkpointer
|
from app.gateway.deps import get_checkpointer
|
||||||
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
|
|
||||||
from app.gateway.utils import sanitize_log_param
|
from app.gateway.utils import sanitize_log_param
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
from deerflow.runtime import serialize_channel_values_for_api
|
from deerflow.runtime import serialize_channel_values
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
|
||||||
from deerflow.utils.time import coerce_iso, now_iso
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/threads", tags=["threads"])
|
router = APIRouter(prefix="/api/threads", tags=["threads"])
|
||||||
@@ -37,7 +34,7 @@ router = APIRouter(prefix="/api/threads", tags=["threads"])
|
|||||||
# them. Pydantic ``@field_validator("metadata")`` strips them on every
|
# them. Pydantic ``@field_validator("metadata")`` strips them on every
|
||||||
# inbound model below so a malicious client cannot reflect a forged
|
# inbound model below so a malicious client cannot reflect a forged
|
||||||
# owner identity through the API surface. Defense-in-depth — the
|
# owner identity through the API surface. Defense-in-depth — the
|
||||||
# row-level invariant is still ``threads_meta.user_id`` populated from
|
# row-level invariant is still ``threads_meta.owner_id`` populated from
|
||||||
# the auth contextvar; this list closes the metadata-blob echo gap.
|
# the auth contextvar; this list closes the metadata-blob echo gap.
|
||||||
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id"})
|
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id"})
|
||||||
|
|
||||||
@@ -91,28 +88,6 @@ class ThreadSearchRequest(BaseModel):
|
|||||||
offset: int = Field(default=0, ge=0, description="Pagination offset")
|
offset: int = Field(default=0, ge=0, description="Pagination offset")
|
||||||
status: str | None = Field(default=None, description="Filter by thread status")
|
status: str | None = Field(default=None, description="Filter by thread status")
|
||||||
|
|
||||||
@field_validator("metadata")
|
|
||||||
@classmethod
|
|
||||||
def _validate_metadata_filters(cls, v: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Reject filter entries the SQL backend cannot compile.
|
|
||||||
|
|
||||||
Enforces consistent behaviour across SQL and memory backends.
|
|
||||||
See ``deerflow.persistence.json_compat`` for the shared validators.
|
|
||||||
"""
|
|
||||||
if not v:
|
|
||||||
return v
|
|
||||||
from deerflow.persistence.json_compat import validate_metadata_filter_key, validate_metadata_filter_value
|
|
||||||
|
|
||||||
bad_entries: list[str] = []
|
|
||||||
for key, value in v.items():
|
|
||||||
if not validate_metadata_filter_key(key):
|
|
||||||
bad_entries.append(f"{key!r} (unsafe key)")
|
|
||||||
elif not validate_metadata_filter_value(value):
|
|
||||||
bad_entries.append(f"{key!r} (unsupported value type {type(value).__name__})")
|
|
||||||
if bad_entries:
|
|
||||||
raise ValueError(f"Invalid metadata filter entries: {', '.join(bad_entries)}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadStateResponse(BaseModel):
|
class ThreadStateResponse(BaseModel):
|
||||||
"""Response model for thread state."""
|
"""Response model for thread state."""
|
||||||
@@ -167,11 +142,11 @@ class ThreadHistoryRequest(BaseModel):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _delete_thread_data(thread_id: str, paths: Paths | None = None, *, user_id: str | None = None) -> ThreadDeleteResponse:
|
def _delete_thread_data(thread_id: str, paths: Paths | None = None) -> ThreadDeleteResponse:
|
||||||
"""Delete local persisted filesystem data for a thread."""
|
"""Delete local persisted filesystem data for a thread."""
|
||||||
path_manager = paths or get_paths()
|
path_manager = paths or get_paths()
|
||||||
try:
|
try:
|
||||||
path_manager.delete_thread_dir(thread_id, user_id=user_id)
|
path_manager.delete_thread_dir(thread_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -219,10 +194,10 @@ async def delete_thread_data(thread_id: str, request: Request) -> ThreadDeleteRe
|
|||||||
and removes the thread_meta row from the configured ThreadMetaStore
|
and removes the thread_meta row from the configured ThreadMetaStore
|
||||||
(sqlite or memory).
|
(sqlite or memory).
|
||||||
"""
|
"""
|
||||||
from app.gateway.deps import get_thread_store
|
from app.gateway.deps import get_thread_meta_repo
|
||||||
|
|
||||||
# Clean local filesystem
|
# Clean local filesystem
|
||||||
response = _delete_thread_data(thread_id, user_id=get_effective_user_id())
|
response = _delete_thread_data(thread_id)
|
||||||
|
|
||||||
# Remove checkpoints (best-effort)
|
# Remove checkpoints (best-effort)
|
||||||
checkpointer = getattr(request.app.state, "checkpointer", None)
|
checkpointer = getattr(request.app.state, "checkpointer", None)
|
||||||
@@ -236,8 +211,8 @@ async def delete_thread_data(thread_id: str, request: Request) -> ThreadDeleteRe
|
|||||||
# Remove thread_meta row (best-effort) — required for sqlite backend
|
# Remove thread_meta row (best-effort) — required for sqlite backend
|
||||||
# so the deleted thread no longer appears in /threads/search.
|
# so the deleted thread no longer appears in /threads/search.
|
||||||
try:
|
try:
|
||||||
thread_store = get_thread_store(request)
|
thread_meta_repo = get_thread_meta_repo(request)
|
||||||
await thread_store.delete(thread_id)
|
await thread_meta_repo.delete(thread_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Could not delete thread_meta for %s (not critical)", sanitize_log_param(thread_id))
|
logger.debug("Could not delete thread_meta for %s (not critical)", sanitize_log_param(thread_id))
|
||||||
|
|
||||||
@@ -252,40 +227,31 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
|
|||||||
and an empty checkpoint (so state endpoints work immediately).
|
and an empty checkpoint (so state endpoints work immediately).
|
||||||
Idempotent: returns the existing record when ``thread_id`` already exists.
|
Idempotent: returns the existing record when ``thread_id`` already exists.
|
||||||
"""
|
"""
|
||||||
from app.gateway.deps import get_thread_store
|
from app.gateway.deps import get_thread_meta_repo
|
||||||
|
|
||||||
checkpointer = get_checkpointer(request)
|
checkpointer = get_checkpointer(request)
|
||||||
thread_store = get_thread_store(request)
|
thread_meta_repo = get_thread_meta_repo(request)
|
||||||
thread_id = body.thread_id or str(uuid.uuid4())
|
thread_id = body.thread_id or str(uuid.uuid4())
|
||||||
now = now_iso()
|
now = time.time()
|
||||||
thread_owner_user_id = get_trusted_internal_owner_user_id(request)
|
|
||||||
thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {}
|
|
||||||
# ``body.metadata`` is already stripped of server-reserved keys by
|
# ``body.metadata`` is already stripped of server-reserved keys by
|
||||||
# ``ThreadCreateRequest._strip_reserved`` — see the model definition.
|
# ``ThreadCreateRequest._strip_reserved`` — see the model definition.
|
||||||
|
|
||||||
# Idempotency: return existing record when already present
|
# Idempotency: return existing record when already present
|
||||||
existing_record = await thread_store.get(thread_id, **thread_owner_kwargs)
|
existing_record = await thread_meta_repo.get(thread_id)
|
||||||
if existing_record is None and thread_owner_user_id:
|
|
||||||
unscoped_record = await thread_store.get(thread_id, user_id=None)
|
|
||||||
if unscoped_record is not None:
|
|
||||||
if unscoped_record.get("user_id") != thread_owner_user_id:
|
|
||||||
await thread_store.update_owner(thread_id, thread_owner_user_id, user_id=None)
|
|
||||||
existing_record = await thread_store.get(thread_id, **thread_owner_kwargs)
|
|
||||||
if existing_record is not None:
|
if existing_record is not None:
|
||||||
return ThreadResponse(
|
return ThreadResponse(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
status=existing_record.get("status", "idle"),
|
status=existing_record.get("status", "idle"),
|
||||||
created_at=coerce_iso(existing_record.get("created_at", "")),
|
created_at=str(existing_record.get("created_at", "")),
|
||||||
updated_at=coerce_iso(existing_record.get("updated_at", "")),
|
updated_at=str(existing_record.get("updated_at", "")),
|
||||||
metadata=existing_record.get("metadata", {}),
|
metadata=existing_record.get("metadata", {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write thread_meta so the thread appears in /threads/search immediately
|
# Write thread_meta so the thread appears in /threads/search immediately
|
||||||
try:
|
try:
|
||||||
await thread_store.create(
|
await thread_meta_repo.create(
|
||||||
thread_id,
|
thread_id,
|
||||||
assistant_id=getattr(body, "assistant_id", None),
|
assistant_id=getattr(body, "assistant_id", None),
|
||||||
**thread_owner_kwargs,
|
|
||||||
metadata=body.metadata,
|
metadata=body.metadata,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -295,6 +261,8 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
|
|||||||
# Write an empty checkpoint so state endpoints work immediately
|
# Write an empty checkpoint so state endpoints work immediately
|
||||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||||
try:
|
try:
|
||||||
|
from langgraph.checkpoint.base import empty_checkpoint
|
||||||
|
|
||||||
ckpt_metadata = {
|
ckpt_metadata = {
|
||||||
"step": -1,
|
"step": -1,
|
||||||
"source": "input",
|
"source": "input",
|
||||||
@@ -312,8 +280,8 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
|
|||||||
return ThreadResponse(
|
return ThreadResponse(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
status="idle",
|
status="idle",
|
||||||
created_at=now,
|
created_at=str(now),
|
||||||
updated_at=now,
|
updated_at=str(now),
|
||||||
metadata=body.metadata,
|
metadata=body.metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -325,28 +293,21 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th
|
|||||||
Delegates to the configured ThreadMetaStore implementation
|
Delegates to the configured ThreadMetaStore implementation
|
||||||
(SQL-backed for sqlite/postgres, Store-backed for memory mode).
|
(SQL-backed for sqlite/postgres, Store-backed for memory mode).
|
||||||
"""
|
"""
|
||||||
from app.gateway.deps import get_thread_store
|
from app.gateway.deps import get_thread_meta_repo
|
||||||
from deerflow.persistence.thread_meta import InvalidMetadataFilterError
|
|
||||||
|
|
||||||
repo = get_thread_store(request)
|
repo = get_thread_meta_repo(request)
|
||||||
try:
|
rows = await repo.search(
|
||||||
rows = await repo.search(
|
metadata=body.metadata or None,
|
||||||
metadata=body.metadata or None,
|
status=body.status,
|
||||||
status=body.status,
|
limit=body.limit,
|
||||||
limit=body.limit,
|
offset=body.offset,
|
||||||
offset=body.offset,
|
)
|
||||||
)
|
|
||||||
except InvalidMetadataFilterError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
return [
|
return [
|
||||||
ThreadResponse(
|
ThreadResponse(
|
||||||
thread_id=r["thread_id"],
|
thread_id=r["thread_id"],
|
||||||
status=r.get("status", "idle"),
|
status=r.get("status", "idle"),
|
||||||
# ``coerce_iso`` heals legacy unix-second values that
|
created_at=r.get("created_at", ""),
|
||||||
# ``MemoryThreadMetaStore`` historically wrote with ``time.time()``;
|
updated_at=r.get("updated_at", ""),
|
||||||
# SQL-backed rows already arrive as ISO strings and pass through.
|
|
||||||
created_at=coerce_iso(r.get("created_at", "")),
|
|
||||||
updated_at=coerce_iso(r.get("updated_at", "")),
|
|
||||||
metadata=r.get("metadata", {}),
|
metadata=r.get("metadata", {}),
|
||||||
values={"title": r["display_name"]} if r.get("display_name") else {},
|
values={"title": r["display_name"]} if r.get("display_name") else {},
|
||||||
interrupts={},
|
interrupts={},
|
||||||
@@ -359,27 +320,27 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th
|
|||||||
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
||||||
async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Request) -> ThreadResponse:
|
async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Request) -> ThreadResponse:
|
||||||
"""Merge metadata into a thread record."""
|
"""Merge metadata into a thread record."""
|
||||||
from app.gateway.deps import get_thread_store
|
from app.gateway.deps import get_thread_meta_repo
|
||||||
|
|
||||||
thread_store = get_thread_store(request)
|
thread_meta_repo = get_thread_meta_repo(request)
|
||||||
record = await thread_store.get(thread_id)
|
record = await thread_meta_repo.get(thread_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
||||||
|
|
||||||
# ``body.metadata`` already stripped by ``ThreadPatchRequest._strip_reserved``.
|
# ``body.metadata`` already stripped by ``ThreadPatchRequest._strip_reserved``.
|
||||||
try:
|
try:
|
||||||
await thread_store.update_metadata(thread_id, body.metadata)
|
await thread_meta_repo.update_metadata(thread_id, body.metadata)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to patch thread %s", sanitize_log_param(thread_id))
|
logger.exception("Failed to patch thread %s", sanitize_log_param(thread_id))
|
||||||
raise HTTPException(status_code=500, detail="Failed to update thread")
|
raise HTTPException(status_code=500, detail="Failed to update thread")
|
||||||
|
|
||||||
# Re-read to get the merged metadata + refreshed updated_at
|
# Re-read to get the merged metadata + refreshed updated_at
|
||||||
record = await thread_store.get(thread_id) or record
|
record = await thread_meta_repo.get(thread_id) or record
|
||||||
return ThreadResponse(
|
return ThreadResponse(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
status=record.get("status", "idle"),
|
status=record.get("status", "idle"),
|
||||||
created_at=coerce_iso(record.get("created_at", "")),
|
created_at=str(record.get("created_at", "")),
|
||||||
updated_at=coerce_iso(record.get("updated_at", "")),
|
updated_at=str(record.get("updated_at", "")),
|
||||||
metadata=record.get("metadata", {}),
|
metadata=record.get("metadata", {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -393,12 +354,12 @@ async def get_thread(thread_id: str, request: Request) -> ThreadResponse:
|
|||||||
execution status from the checkpointer. Falls back to the checkpointer
|
execution status from the checkpointer. Falls back to the checkpointer
|
||||||
alone for threads that pre-date ThreadMetaStore adoption (backward compat).
|
alone for threads that pre-date ThreadMetaStore adoption (backward compat).
|
||||||
"""
|
"""
|
||||||
from app.gateway.deps import get_thread_store
|
from app.gateway.deps import get_thread_meta_repo
|
||||||
|
|
||||||
thread_store = get_thread_store(request)
|
thread_meta_repo = get_thread_meta_repo(request)
|
||||||
checkpointer = get_checkpointer(request)
|
checkpointer = get_checkpointer(request)
|
||||||
|
|
||||||
record: dict | None = await thread_store.get(thread_id)
|
record: dict | None = await thread_meta_repo.get(thread_id)
|
||||||
|
|
||||||
# Derive accurate status from the checkpointer
|
# Derive accurate status from the checkpointer
|
||||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||||
@@ -419,8 +380,8 @@ async def get_thread(thread_id: str, request: Request) -> ThreadResponse:
|
|||||||
record = {
|
record = {
|
||||||
"thread_id": thread_id,
|
"thread_id": thread_id,
|
||||||
"status": "idle",
|
"status": "idle",
|
||||||
"created_at": coerce_iso(ckpt_meta.get("created_at", "")),
|
"created_at": ckpt_meta.get("created_at", ""),
|
||||||
"updated_at": coerce_iso(ckpt_meta.get("updated_at", ckpt_meta.get("created_at", ""))),
|
"updated_at": ckpt_meta.get("updated_at", ckpt_meta.get("created_at", "")),
|
||||||
"metadata": {k: v for k, v in ckpt_meta.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents")},
|
"metadata": {k: v for k, v in ckpt_meta.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents")},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,14 +395,13 @@ async def get_thread(thread_id: str, request: Request) -> ThreadResponse:
|
|||||||
return ThreadResponse(
|
return ThreadResponse(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
status=status,
|
status=status,
|
||||||
created_at=coerce_iso(record.get("created_at", "")),
|
created_at=str(record.get("created_at", "")),
|
||||||
updated_at=coerce_iso(record.get("updated_at", "")),
|
updated_at=str(record.get("updated_at", "")),
|
||||||
metadata=record.get("metadata", {}),
|
metadata=record.get("metadata", {}),
|
||||||
values=serialize_channel_values_for_api(channel_values),
|
values=serialize_channel_values(channel_values),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@router.get("/{thread_id}/state", response_model=ThreadStateResponse)
|
@router.get("/{thread_id}/state", response_model=ThreadStateResponse)
|
||||||
@require_permission("threads", "read", owner_check=True)
|
@require_permission("threads", "read", owner_check=True)
|
||||||
async def get_thread_state(thread_id: str, request: Request) -> ThreadStateResponse:
|
async def get_thread_state(thread_id: str, request: Request) -> ThreadStateResponse:
|
||||||
@@ -480,16 +440,14 @@ async def get_thread_state(thread_id: str, request: Request) -> ThreadStateRespo
|
|||||||
next_tasks = [t.name for t in tasks_raw if hasattr(t, "name")]
|
next_tasks = [t.name for t in tasks_raw if hasattr(t, "name")]
|
||||||
tasks = [{"id": getattr(t, "id", ""), "name": getattr(t, "name", "")} for t in tasks_raw]
|
tasks = [{"id": getattr(t, "id", ""), "name": getattr(t, "name", "")} for t in tasks_raw]
|
||||||
|
|
||||||
values = serialize_channel_values_for_api(channel_values)
|
|
||||||
|
|
||||||
return ThreadStateResponse(
|
return ThreadStateResponse(
|
||||||
values=values,
|
values=serialize_channel_values(channel_values),
|
||||||
next=next_tasks,
|
next=next_tasks,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
checkpoint={"id": checkpoint_id, "ts": coerce_iso(metadata.get("created_at", ""))},
|
checkpoint={"id": checkpoint_id, "ts": str(metadata.get("created_at", ""))},
|
||||||
checkpoint_id=checkpoint_id,
|
checkpoint_id=checkpoint_id,
|
||||||
parent_checkpoint_id=parent_checkpoint_id,
|
parent_checkpoint_id=parent_checkpoint_id,
|
||||||
created_at=coerce_iso(metadata.get("created_at", "")),
|
created_at=str(metadata.get("created_at", "")),
|
||||||
tasks=tasks,
|
tasks=tasks,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -504,10 +462,10 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re
|
|||||||
ThreadMetaStore abstraction so that ``/threads/search`` reflects the
|
ThreadMetaStore abstraction so that ``/threads/search`` reflects the
|
||||||
change immediately in both sqlite and memory backends.
|
change immediately in both sqlite and memory backends.
|
||||||
"""
|
"""
|
||||||
from app.gateway.deps import get_thread_store
|
from app.gateway.deps import get_thread_meta_repo
|
||||||
|
|
||||||
checkpointer = get_checkpointer(request)
|
checkpointer = get_checkpointer(request)
|
||||||
thread_store = get_thread_store(request)
|
thread_meta_repo = get_thread_meta_repo(request)
|
||||||
|
|
||||||
# checkpoint_ns must be present in the config for aput — default to ""
|
# checkpoint_ns must be present in the config for aput — default to ""
|
||||||
# (the root graph namespace). checkpoint_id is optional; omitting it
|
# (the root graph namespace). checkpoint_id is optional; omitting it
|
||||||
@@ -539,28 +497,16 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re
|
|||||||
channel_values.update(body.values)
|
channel_values.update(body.values)
|
||||||
|
|
||||||
checkpoint["channel_values"] = channel_values
|
checkpoint["channel_values"] = channel_values
|
||||||
metadata["updated_at"] = now_iso()
|
metadata["updated_at"] = time.time()
|
||||||
|
|
||||||
if body.as_node:
|
if body.as_node:
|
||||||
metadata["source"] = "update"
|
metadata["source"] = "update"
|
||||||
metadata["step"] = metadata.get("step", 0) + 1
|
metadata["step"] = metadata.get("step", 0) + 1
|
||||||
metadata["writes"] = {body.as_node: body.values}
|
metadata["writes"] = {body.as_node: body.values}
|
||||||
|
|
||||||
# Assign a new checkpoint ID so aput performs an INSERT rather than an
|
|
||||||
# in-place REPLACE of the existing row. Use uuid6 (time-ordered) rather
|
|
||||||
# than uuid4 (random) so the new ID is always lexicographically greater
|
|
||||||
# than the previous one — LangGraph's checkpointers determine the "latest"
|
|
||||||
# checkpoint by max(checkpoint_ids) string order, matching the uuid6 epoch.
|
|
||||||
checkpoint["id"] = str(uuid6())
|
|
||||||
|
|
||||||
# aput requires checkpoint_ns in the config — use the same config used for the
|
# aput requires checkpoint_ns in the config — use the same config used for the
|
||||||
# read (which always includes checkpoint_ns=""). The fresh checkpoint ID is
|
# read (which always includes checkpoint_ns=""). Do NOT include checkpoint_id
|
||||||
# assigned above via checkpoint["id"]; keep checkpoint_id out of the config so
|
# so that aput generates a fresh checkpoint ID for the new snapshot.
|
||||||
# the write is keyed by the new checkpoint payload rather than the prior read.
|
|
||||||
# All supported savers (InMemorySaver, AsyncSqliteSaver, AsyncPostgresSaver)
|
|
||||||
# persist and echo back checkpoint["id"] verbatim — none mint their own — so
|
|
||||||
# the new_config below carries the uuid6 we assigned here. (Regression-locked
|
|
||||||
# by test_update_thread_state_inserts_new_checkpoint_each_call.)
|
|
||||||
write_config: dict[str, Any] = {
|
write_config: dict[str, Any] = {
|
||||||
"configurable": {
|
"configurable": {
|
||||||
"thread_id": thread_id,
|
"thread_id": thread_id,
|
||||||
@@ -579,20 +525,20 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re
|
|||||||
|
|
||||||
# Sync title changes through the ThreadMetaStore abstraction so /threads/search
|
# Sync title changes through the ThreadMetaStore abstraction so /threads/search
|
||||||
# reflects them immediately in both sqlite and memory backends.
|
# reflects them immediately in both sqlite and memory backends.
|
||||||
if thread_store and body.values and "title" in body.values:
|
if body.values and "title" in body.values:
|
||||||
new_title = body.values["title"]
|
new_title = body.values["title"]
|
||||||
if new_title: # Skip empty strings and None
|
if new_title: # Skip empty strings and None
|
||||||
try:
|
try:
|
||||||
await thread_store.update_display_name(thread_id, new_title)
|
await thread_meta_repo.update_display_name(thread_id, new_title)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to sync title to thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
|
logger.debug("Failed to sync title to thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
|
||||||
|
|
||||||
return ThreadStateResponse(
|
return ThreadStateResponse(
|
||||||
values=serialize_channel_values_for_api(channel_values),
|
values=serialize_channel_values(channel_values),
|
||||||
next=[],
|
next=[],
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
checkpoint_id=new_checkpoint_id,
|
checkpoint_id=new_checkpoint_id,
|
||||||
created_at=coerce_iso(metadata.get("created_at", "")),
|
created_at=str(metadata.get("created_at", "")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -636,11 +582,11 @@ async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request
|
|||||||
if thread_data := channel_values.get("thread_data"):
|
if thread_data := channel_values.get("thread_data"):
|
||||||
values["thread_data"] = thread_data
|
values["thread_data"] = thread_data
|
||||||
|
|
||||||
# Attach messages only to the latest checkpoint entry.
|
# Attach messages from checkpointer only for the latest checkpoint
|
||||||
if is_latest_checkpoint:
|
if is_latest_checkpoint:
|
||||||
messages = channel_values.get("messages")
|
messages = channel_values.get("messages")
|
||||||
if messages:
|
if messages:
|
||||||
values["messages"] = serialize_channel_values_for_api({"messages": messages}).get("messages", [])
|
values["messages"] = serialize_channel_values({"messages": messages}).get("messages", [])
|
||||||
is_latest_checkpoint = False
|
is_latest_checkpoint = False
|
||||||
|
|
||||||
# Derive next tasks
|
# Derive next tasks
|
||||||
@@ -659,7 +605,7 @@ async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request
|
|||||||
parent_checkpoint_id=parent_id,
|
parent_checkpoint_id=parent_id,
|
||||||
metadata=user_meta,
|
metadata=user_meta,
|
||||||
values=values,
|
values=values,
|
||||||
created_at=coerce_iso(metadata.get("created_at", "")),
|
created_at=str(metadata.get("created_at", "")),
|
||||||
next=next_tasks,
|
next=next_tasks,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,26 +4,20 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
from app.gateway.deps import get_config
|
|
||||||
from deerflow.config.app_config import AppConfig
|
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import 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 SandboxProvider, get_sandbox_provider
|
|
||||||
from deerflow.uploads.manager import (
|
from deerflow.uploads.manager import (
|
||||||
PathTraversalError,
|
PathTraversalError,
|
||||||
UnsafeUploadPathError,
|
|
||||||
claim_unique_filename,
|
|
||||||
delete_file_safe,
|
delete_file_safe,
|
||||||
enrich_file_listing,
|
enrich_file_listing,
|
||||||
ensure_uploads_dir,
|
ensure_uploads_dir,
|
||||||
get_uploads_dir,
|
get_uploads_dir,
|
||||||
list_files_in_dir,
|
list_files_in_dir,
|
||||||
normalize_filename,
|
normalize_filename,
|
||||||
open_upload_file_no_symlink,
|
|
||||||
upload_artifact_url,
|
upload_artifact_url,
|
||||||
upload_virtual_path,
|
upload_virtual_path,
|
||||||
)
|
)
|
||||||
@@ -33,51 +27,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/threads/{thread_id}/uploads", tags=["uploads"])
|
router = APIRouter(prefix="/api/threads/{thread_id}/uploads", tags=["uploads"])
|
||||||
|
|
||||||
UPLOAD_CHUNK_SIZE = 8192
|
|
||||||
DEFAULT_MAX_FILES = 10
|
|
||||||
DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024
|
|
||||||
DEFAULT_MAX_TOTAL_SIZE = 100 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class UploadedFileInfo(BaseModel):
|
|
||||||
"""Uploaded file metadata exposed by upload and list APIs."""
|
|
||||||
|
|
||||||
filename: str
|
|
||||||
size: int
|
|
||||||
path: str
|
|
||||||
virtual_path: str
|
|
||||||
artifact_url: str
|
|
||||||
extension: str | None = None
|
|
||||||
modified: float | None = None
|
|
||||||
original_filename: str | None = None
|
|
||||||
markdown_file: str | None = None
|
|
||||||
markdown_path: str | None = None
|
|
||||||
markdown_virtual_path: str | None = None
|
|
||||||
markdown_artifact_url: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class UploadResponse(BaseModel):
|
class UploadResponse(BaseModel):
|
||||||
"""Response model for file upload."""
|
"""Response model for file upload."""
|
||||||
|
|
||||||
success: bool
|
success: bool
|
||||||
files: list[UploadedFileInfo]
|
files: list[dict[str, str]]
|
||||||
message: str
|
message: str
|
||||||
skipped_files: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class UploadListResponse(BaseModel):
|
|
||||||
"""Response model for uploaded file listing."""
|
|
||||||
|
|
||||||
files: list[UploadedFileInfo]
|
|
||||||
count: int
|
|
||||||
|
|
||||||
|
|
||||||
class UploadLimits(BaseModel):
|
|
||||||
"""Application-level upload limits exposed to clients."""
|
|
||||||
|
|
||||||
max_files: int
|
|
||||||
max_file_size: int
|
|
||||||
max_total_size: int
|
|
||||||
|
|
||||||
|
|
||||||
def _make_file_sandbox_writable(file_path: os.PathLike[str] | str) -> None:
|
def _make_file_sandbox_writable(file_path: os.PathLike[str] | str) -> None:
|
||||||
@@ -93,212 +49,73 @@ def _make_file_sandbox_writable(file_path: os.PathLike[str] | str) -> None:
|
|||||||
logger.warning("Skipping sandbox chmod for symlinked upload path: %s", file_path)
|
logger.warning("Skipping sandbox chmod for symlinked upload path: %s", file_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
writable_mode = stat.S_IMODE(file_stat.st_mode) | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH | stat.S_IRGRP | stat.S_IROTH
|
writable_mode = stat.S_IMODE(file_stat.st_mode) | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH
|
||||||
chmod_kwargs = {"follow_symlinks": False} if os.chmod in os.supports_follow_symlinks else {}
|
chmod_kwargs = {"follow_symlinks": False} if os.chmod in os.supports_follow_symlinks else {}
|
||||||
os.chmod(file_path, writable_mode, **chmod_kwargs)
|
os.chmod(file_path, writable_mode, **chmod_kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _make_file_sandbox_readable(file_path: os.PathLike[str] | str) -> None:
|
|
||||||
"""Ensure uploaded files are readable by the sandbox process.
|
|
||||||
|
|
||||||
For Docker sandboxes (AIO), the gateway writes files as root with 0o600
|
|
||||||
permissions, then bind-mounts the host directory into the container. The
|
|
||||||
sandbox process inside the container runs as a non-root user and cannot
|
|
||||||
read those files without group/other read bits. This function adds
|
|
||||||
``S_IRGRP | S_IROTH`` so the sandbox can read the uploaded content.
|
|
||||||
"""
|
|
||||||
file_stat = os.lstat(file_path)
|
|
||||||
if stat.S_ISLNK(file_stat.st_mode):
|
|
||||||
logger.warning("Skipping sandbox chmod for symlinked upload path: %s", file_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
readable_mode = stat.S_IMODE(file_stat.st_mode) | stat.S_IRGRP | stat.S_IROTH
|
|
||||||
chmod_kwargs = {"follow_symlinks": False} if os.chmod in os.supports_follow_symlinks else {}
|
|
||||||
os.chmod(file_path, readable_mode, **chmod_kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def _uses_thread_data_mounts(sandbox_provider: SandboxProvider) -> bool:
|
|
||||||
return bool(getattr(sandbox_provider, "uses_thread_data_mounts", False))
|
|
||||||
|
|
||||||
|
|
||||||
def _get_uploads_config_value(app_config: AppConfig, key: str, default: object) -> object:
|
|
||||||
"""Read a value from the uploads config, supporting dict and attribute access."""
|
|
||||||
uploads_cfg = getattr(app_config, "uploads", None)
|
|
||||||
if isinstance(uploads_cfg, dict):
|
|
||||||
return uploads_cfg.get(key, default)
|
|
||||||
return getattr(uploads_cfg, key, default)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_upload_limit(app_config: AppConfig, key: str, default: int, *, legacy_key: str | None = None) -> int:
|
|
||||||
try:
|
|
||||||
value = _get_uploads_config_value(app_config, key, None)
|
|
||||||
if value is None and legacy_key is not None:
|
|
||||||
value = _get_uploads_config_value(app_config, legacy_key, None)
|
|
||||||
if value is None:
|
|
||||||
value = default
|
|
||||||
limit = int(value)
|
|
||||||
if limit <= 0:
|
|
||||||
raise ValueError
|
|
||||||
return limit
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Invalid uploads.%s value; falling back to %d", key, default)
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _get_upload_limits(app_config: AppConfig) -> UploadLimits:
|
|
||||||
return UploadLimits(
|
|
||||||
max_files=_get_upload_limit(app_config, "max_files", DEFAULT_MAX_FILES, legacy_key="max_file_count"),
|
|
||||||
max_file_size=_get_upload_limit(app_config, "max_file_size", DEFAULT_MAX_FILE_SIZE, legacy_key="max_single_file_size"),
|
|
||||||
max_total_size=_get_upload_limit(app_config, "max_total_size", DEFAULT_MAX_TOTAL_SIZE),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_uploaded_paths(paths: list[os.PathLike[str] | str]) -> None:
|
|
||||||
for path in reversed(paths):
|
|
||||||
try:
|
|
||||||
os.unlink(path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to clean up upload path after rejected request: %s", path, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def _write_upload_file_with_limits(
|
|
||||||
file: UploadFile,
|
|
||||||
*,
|
|
||||||
uploads_dir: os.PathLike[str] | str,
|
|
||||||
display_filename: str,
|
|
||||||
max_single_file_size: int,
|
|
||||||
max_total_size: int,
|
|
||||||
total_size: int,
|
|
||||||
) -> tuple[os.PathLike[str] | str, int, int]:
|
|
||||||
file_size = 0
|
|
||||||
file_path, fh = open_upload_file_no_symlink(uploads_dir, display_filename)
|
|
||||||
try:
|
|
||||||
while chunk := await file.read(UPLOAD_CHUNK_SIZE):
|
|
||||||
file_size += len(chunk)
|
|
||||||
total_size += len(chunk)
|
|
||||||
if file_size > max_single_file_size:
|
|
||||||
raise HTTPException(status_code=413, detail=f"File too large: {display_filename}")
|
|
||||||
if total_size > max_total_size:
|
|
||||||
raise HTTPException(status_code=413, detail="Total upload size too large")
|
|
||||||
fh.write(chunk)
|
|
||||||
except Exception:
|
|
||||||
fh.close()
|
|
||||||
try:
|
|
||||||
os.unlink(file_path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
fh.close()
|
|
||||||
return file_path, file_size, total_size
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_convert_documents_enabled(app_config: AppConfig) -> bool:
|
|
||||||
"""Return whether automatic host-side document conversion is enabled.
|
|
||||||
|
|
||||||
The secure default is disabled unless an operator explicitly opts in via
|
|
||||||
uploads.auto_convert_documents in config.yaml.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
raw = _get_uploads_config_value(app_config, "auto_convert_documents", False)
|
|
||||||
if isinstance(raw, str):
|
|
||||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
return bool(raw)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=UploadResponse)
|
@router.post("", response_model=UploadResponse)
|
||||||
@require_permission("threads", "write", owner_check=True, require_existing=False)
|
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
||||||
async def upload_files(
|
async def upload_files(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
files: list[UploadFile] = File(...),
|
files: list[UploadFile] = File(...),
|
||||||
config: AppConfig = Depends(get_config),
|
|
||||||
) -> UploadResponse:
|
) -> UploadResponse:
|
||||||
"""Upload multiple files to a thread's uploads directory."""
|
"""Upload multiple files to a thread's uploads directory."""
|
||||||
if not files:
|
if not files:
|
||||||
raise HTTPException(status_code=400, detail="No files provided")
|
raise HTTPException(status_code=400, detail="No files provided")
|
||||||
|
|
||||||
limits = _get_upload_limits(config)
|
|
||||||
if len(files) > limits.max_files:
|
|
||||||
raise HTTPException(status_code=413, detail=f"Too many files: maximum is {limits.max_files}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
uploads_dir = ensure_uploads_dir(thread_id)
|
uploads_dir = ensure_uploads_dir(thread_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=get_effective_user_id())
|
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id)
|
||||||
uploaded_files = []
|
uploaded_files = []
|
||||||
written_paths = []
|
|
||||||
sandbox_sync_targets = []
|
|
||||||
skipped_files = []
|
|
||||||
total_size = 0
|
|
||||||
# Track filenames within this request so duplicate form parts do not
|
|
||||||
# silently truncate each other. Existing uploads keep the historical
|
|
||||||
# overwrite behavior for a single replacement upload.
|
|
||||||
seen_filenames: set[str] = set()
|
|
||||||
|
|
||||||
sandbox_provider = get_sandbox_provider()
|
sandbox_provider = get_sandbox_provider()
|
||||||
sync_to_sandbox = not _uses_thread_data_mounts(sandbox_provider)
|
sandbox_id = sandbox_provider.acquire(thread_id)
|
||||||
sandbox = None
|
sandbox = sandbox_provider.get(sandbox_id)
|
||||||
if sync_to_sandbox:
|
|
||||||
sandbox_id = sandbox_provider.acquire(thread_id)
|
|
||||||
sandbox = sandbox_provider.get(sandbox_id)
|
|
||||||
if sandbox is None:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to acquire sandbox")
|
|
||||||
auto_convert_documents = _auto_convert_documents_enabled(config)
|
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
original_filename = normalize_filename(file.filename)
|
safe_filename = normalize_filename(file.filename)
|
||||||
safe_filename = claim_unique_filename(original_filename, seen_filenames)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Skipping file with unsafe filename: {file.filename!r}")
|
logger.warning(f"Skipping file with unsafe filename: {file.filename!r}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_path, file_size, total_size = await _write_upload_file_with_limits(
|
content = await file.read()
|
||||||
file,
|
file_path = uploads_dir / safe_filename
|
||||||
uploads_dir=uploads_dir,
|
file_path.write_bytes(content)
|
||||||
display_filename=safe_filename,
|
|
||||||
max_single_file_size=limits.max_file_size,
|
|
||||||
max_total_size=limits.max_total_size,
|
|
||||||
total_size=total_size,
|
|
||||||
)
|
|
||||||
written_paths.append(file_path)
|
|
||||||
|
|
||||||
virtual_path = upload_virtual_path(safe_filename)
|
virtual_path = upload_virtual_path(safe_filename)
|
||||||
|
|
||||||
if sync_to_sandbox:
|
if sandbox_id != "local":
|
||||||
sandbox_sync_targets.append((file_path, virtual_path))
|
_make_file_sandbox_writable(file_path)
|
||||||
|
sandbox.update_file(virtual_path, content)
|
||||||
|
|
||||||
file_info = {
|
file_info = {
|
||||||
"filename": safe_filename,
|
"filename": safe_filename,
|
||||||
"size": file_size,
|
"size": str(len(content)),
|
||||||
"path": str(sandbox_uploads / safe_filename),
|
"path": str(sandbox_uploads / safe_filename),
|
||||||
"virtual_path": virtual_path,
|
"virtual_path": virtual_path,
|
||||||
"artifact_url": upload_artifact_url(thread_id, safe_filename),
|
"artifact_url": upload_artifact_url(thread_id, safe_filename),
|
||||||
}
|
}
|
||||||
if safe_filename != original_filename:
|
|
||||||
file_info["original_filename"] = original_filename
|
|
||||||
|
|
||||||
logger.info(f"Saved file: {safe_filename} ({file_size} bytes) to {file_info['path']}")
|
logger.info(f"Saved file: {safe_filename} ({len(content)} bytes) to {file_info['path']}")
|
||||||
|
|
||||||
file_ext = file_path.suffix.lower()
|
file_ext = file_path.suffix.lower()
|
||||||
if auto_convert_documents and file_ext in CONVERTIBLE_EXTENSIONS:
|
if file_ext in CONVERTIBLE_EXTENSIONS:
|
||||||
md_path = await convert_file_to_markdown(file_path)
|
md_path = await convert_file_to_markdown(file_path)
|
||||||
if md_path:
|
if md_path:
|
||||||
written_paths.append(md_path)
|
|
||||||
md_virtual_path = upload_virtual_path(md_path.name)
|
md_virtual_path = upload_virtual_path(md_path.name)
|
||||||
|
|
||||||
if sync_to_sandbox:
|
if sandbox_id != "local":
|
||||||
sandbox_sync_targets.append((md_path, md_virtual_path))
|
_make_file_sandbox_writable(md_path)
|
||||||
|
sandbox.update_file(md_virtual_path, md_path.read_bytes())
|
||||||
|
|
||||||
file_info["markdown_file"] = md_path.name
|
file_info["markdown_file"] = md_path.name
|
||||||
file_info["markdown_path"] = str(sandbox_uploads / md_path.name)
|
file_info["markdown_path"] = str(sandbox_uploads / md_path.name)
|
||||||
@@ -307,59 +124,20 @@ async def upload_files(
|
|||||||
|
|
||||||
uploaded_files.append(file_info)
|
uploaded_files.append(file_info)
|
||||||
|
|
||||||
except HTTPException as e:
|
|
||||||
_cleanup_uploaded_paths(written_paths)
|
|
||||||
raise e
|
|
||||||
except UnsafeUploadPathError as e:
|
|
||||||
logger.warning("Skipping upload with unsafe destination %s: %s", file.filename, e)
|
|
||||||
skipped_files.append(safe_filename)
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to upload {file.filename}: {e}")
|
logger.error(f"Failed to upload {file.filename}: {e}")
|
||||||
_cleanup_uploaded_paths(written_paths)
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to upload {file.filename}: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to upload {file.filename}: {str(e)}")
|
||||||
|
|
||||||
# Uploaded files are created with 0o600 permissions (owner read/write only).
|
|
||||||
# In Docker sandbox deployments the gateway writes as root but the sandbox
|
|
||||||
# process runs as a non-root user (typically UID 1000). Without group/other
|
|
||||||
# read bits the sandbox cannot access the files — whether the uploads
|
|
||||||
# directory is bind-mounted into the container or synced via
|
|
||||||
# sandbox.update_file. Always add group/other read bits so every sandbox
|
|
||||||
# configuration can read the uploaded content.
|
|
||||||
for file_path in written_paths:
|
|
||||||
_make_file_sandbox_readable(file_path)
|
|
||||||
|
|
||||||
if sync_to_sandbox:
|
|
||||||
for file_path, virtual_path in sandbox_sync_targets:
|
|
||||||
_make_file_sandbox_writable(file_path)
|
|
||||||
sandbox.update_file(virtual_path, file_path.read_bytes())
|
|
||||||
|
|
||||||
message = f"Successfully uploaded {len(uploaded_files)} file(s)"
|
|
||||||
if skipped_files:
|
|
||||||
message += f"; skipped {len(skipped_files)} unsafe file(s)"
|
|
||||||
|
|
||||||
return UploadResponse(
|
return UploadResponse(
|
||||||
success=not skipped_files,
|
success=True,
|
||||||
files=uploaded_files,
|
files=uploaded_files,
|
||||||
message=message,
|
message=f"Successfully uploaded {len(uploaded_files)} file(s)",
|
||||||
skipped_files=skipped_files,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/limits", response_model=UploadLimits)
|
@router.get("/list", response_model=dict)
|
||||||
@require_permission("threads", "read", owner_check=True)
|
@require_permission("threads", "read", owner_check=True)
|
||||||
async def get_upload_limits(
|
async def list_uploaded_files(thread_id: str, request: Request) -> dict:
|
||||||
thread_id: str,
|
|
||||||
request: Request,
|
|
||||||
config: AppConfig = Depends(get_config),
|
|
||||||
) -> UploadLimits:
|
|
||||||
"""Return upload limits used by the gateway for this thread."""
|
|
||||||
return _get_upload_limits(config)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", response_model=UploadListResponse)
|
|
||||||
@require_permission("threads", "read", owner_check=True)
|
|
||||||
async def list_uploaded_files(thread_id: str, request: Request) -> UploadListResponse:
|
|
||||||
"""List all files in a thread's uploads directory."""
|
"""List all files in a thread's uploads directory."""
|
||||||
try:
|
try:
|
||||||
uploads_dir = get_uploads_dir(thread_id)
|
uploads_dir = get_uploads_dir(thread_id)
|
||||||
@@ -369,11 +147,11 @@ async def list_uploaded_files(thread_id: str, request: Request) -> UploadListRes
|
|||||||
enrich_file_listing(result, thread_id)
|
enrich_file_listing(result, thread_id)
|
||||||
|
|
||||||
# Gateway additionally includes the sandbox-relative path.
|
# Gateway additionally includes the sandbox-relative path.
|
||||||
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id, user_id=get_effective_user_id())
|
sandbox_uploads = get_paths().sandbox_uploads_dir(thread_id)
|
||||||
for f in result["files"]:
|
for f in result["files"]:
|
||||||
f["path"] = str(sandbox_uploads / f["filename"])
|
f["path"] = str(sandbox_uploads / f["filename"])
|
||||||
|
|
||||||
return UploadListResponse(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{filename}")
|
@router.delete("/{filename}")
|
||||||
|
|||||||
+116
-294
@@ -8,21 +8,17 @@ frames, and consuming stream bridge events. Router modules
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from langchain_core.messages import BaseMessage
|
from langchain_core.messages import HumanMessage
|
||||||
from langchain_core.messages.utils import convert_to_messages
|
|
||||||
|
|
||||||
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
|
from app.gateway.deps import get_run_context, get_run_manager, get_run_store, get_stream_bridge
|
||||||
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE, get_trusted_internal_owner_user_id
|
|
||||||
from app.gateway.utils import sanitize_log_param
|
from app.gateway.utils import sanitize_log_param
|
||||||
from deerflow.config.app_config import get_app_config
|
|
||||||
from deerflow.runtime import (
|
from deerflow.runtime import (
|
||||||
END_SENTINEL,
|
END_SENTINEL,
|
||||||
HEARTBEAT_SENTINEL,
|
HEARTBEAT_SENTINEL,
|
||||||
@@ -35,8 +31,6 @@ from deerflow.runtime import (
|
|||||||
UnsupportedStrategyError,
|
UnsupportedStrategyError,
|
||||||
run_agent,
|
run_agent,
|
||||||
)
|
)
|
||||||
from deerflow.runtime.runs.naming import resolve_root_run_name
|
|
||||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -80,35 +74,21 @@ def normalize_stream_modes(raw: list[str] | str | None) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""Convert LangGraph Platform input format to LangChain state dict.
|
"""Convert LangGraph Platform input format to LangChain state dict."""
|
||||||
|
|
||||||
Delegates dict→message coercion to ``langchain_core.messages.utils.convert_to_messages``
|
|
||||||
so that ``additional_kwargs`` (e.g. uploaded-file metadata — gh #3132), ``id``,
|
|
||||||
``name``, and non-human roles (ai/system/tool) survive unchanged. An earlier
|
|
||||||
hand-rolled version only forwarded ``content`` and collapsed every role to
|
|
||||||
``HumanMessage``, which silently stripped frontend-supplied attachments.
|
|
||||||
|
|
||||||
Malformed message dicts (missing ``role``/``type``/``content``, unsupported
|
|
||||||
role, etc.) raise ``HTTPException(400)`` with the offending index, instead
|
|
||||||
of bubbling up as a 500. The gateway is a system boundary, so per-entry
|
|
||||||
validation errors are the right shape for clients to retry against.
|
|
||||||
"""
|
|
||||||
if raw_input is None:
|
if raw_input is None:
|
||||||
return {}
|
return {}
|
||||||
messages = raw_input.get("messages")
|
messages = raw_input.get("messages")
|
||||||
if messages and isinstance(messages, list):
|
if messages and isinstance(messages, list):
|
||||||
converted: list[Any] = []
|
converted = []
|
||||||
for index, msg in enumerate(messages):
|
for msg in messages:
|
||||||
if isinstance(msg, BaseMessage):
|
if isinstance(msg, dict):
|
||||||
converted.append(msg)
|
role = msg.get("role", msg.get("type", "user"))
|
||||||
elif isinstance(msg, dict):
|
content = msg.get("content", "")
|
||||||
try:
|
if role in ("user", "human"):
|
||||||
converted.extend(convert_to_messages([msg]))
|
converted.append(HumanMessage(content=content))
|
||||||
except (ValueError, TypeError, NotImplementedError) as exc:
|
else:
|
||||||
raise HTTPException(
|
# TODO: handle other message types (system, ai, tool)
|
||||||
status_code=400,
|
converted.append(HumanMessage(content=content))
|
||||||
detail=f"Invalid message at input.messages[{index}]: {exc}",
|
|
||||||
) from exc
|
|
||||||
else:
|
else:
|
||||||
converted.append(msg)
|
converted.append(msg)
|
||||||
return {**raw_input, "messages": converted}
|
return {**raw_input, "messages": converted}
|
||||||
@@ -118,82 +98,13 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
_DEFAULT_ASSISTANT_ID = "lead_agent"
|
_DEFAULT_ASSISTANT_ID = "lead_agent"
|
||||||
|
|
||||||
|
|
||||||
# Whitelist of run-context keys that the langgraph-compat layer forwards from
|
|
||||||
# ``body.context`` into the run config. ``config["context"]`` exists in
|
|
||||||
# LangGraph >=0.6, but these values must be written to both ``configurable``
|
|
||||||
# (for legacy ``_get_runtime_config`` consumers) and ``context`` because
|
|
||||||
# LangGraph >=1.1.9 no longer makes ``ToolRuntime.context`` fall back to
|
|
||||||
# ``configurable`` for consumers like ``setup_agent``.
|
|
||||||
_CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
|
||||||
{
|
|
||||||
"model_name",
|
|
||||||
"mode",
|
|
||||||
"thinking_enabled",
|
|
||||||
"reasoning_effort",
|
|
||||||
"is_plan_mode",
|
|
||||||
"subagent_enabled",
|
|
||||||
"max_concurrent_subagents",
|
|
||||||
"agent_name",
|
|
||||||
"is_bootstrap",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, Any] | None) -> None:
|
|
||||||
"""Merge whitelisted keys from ``body.context`` into both ``config['configurable']``
|
|
||||||
and ``config['context']`` so they are visible to legacy configurable readers and
|
|
||||||
to LangGraph ``ToolRuntime.context`` consumers (e.g. the ``setup_agent`` tool —
|
|
||||||
see issue #2677).
|
|
||||||
|
|
||||||
``user_id`` is intentionally propagated into ``config['context']`` in addition to
|
|
||||||
the whitelisted keys, so non-web callers (e.g. IM channels) that supply identity in
|
|
||||||
``body.context`` keep it on ``ToolRuntime.context``. It is merged with
|
|
||||||
``setdefault`` so a server-authenticated id stamped by
|
|
||||||
:func:`inject_authenticated_user_context` always wins over the client-supplied one.
|
|
||||||
"""
|
|
||||||
if not context:
|
|
||||||
return
|
|
||||||
configurable = config.setdefault("configurable", {})
|
|
||||||
runtime_context = config.setdefault("context", {})
|
|
||||||
for key in _CONTEXT_CONFIGURABLE_KEYS:
|
|
||||||
if key in context:
|
|
||||||
if isinstance(configurable, dict):
|
|
||||||
configurable.setdefault(key, context[key])
|
|
||||||
if isinstance(runtime_context, dict):
|
|
||||||
runtime_context.setdefault(key, context[key])
|
|
||||||
if "user_id" in context and isinstance(runtime_context, dict):
|
|
||||||
runtime_context.setdefault("user_id", context["user_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def inject_authenticated_user_context(config: dict[str, Any], request: Request) -> None:
|
|
||||||
"""Stamp the authenticated user into the run context for background tools.
|
|
||||||
|
|
||||||
Tool execution may happen after the request handler has returned, so tools
|
|
||||||
that persist user-scoped files should not rely only on ambient ContextVars.
|
|
||||||
The value comes from server-side auth state, never from client context.
|
|
||||||
"""
|
|
||||||
|
|
||||||
user = getattr(request.state, "user", None)
|
|
||||||
user_id = getattr(user, "id", None)
|
|
||||||
if user_id is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if getattr(user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
|
|
||||||
return
|
|
||||||
|
|
||||||
runtime_context = config.setdefault("context", {})
|
|
||||||
if isinstance(runtime_context, dict):
|
|
||||||
runtime_context["user_id"] = str(user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_agent_factory(assistant_id: str | None):
|
def resolve_agent_factory(assistant_id: str | None):
|
||||||
"""Resolve the agent factory callable from config.
|
"""Resolve the agent factory callable from config.
|
||||||
|
|
||||||
Custom agents are implemented as ``lead_agent`` + an ``agent_name``
|
Custom agents are implemented as ``lead_agent`` + an ``agent_name``
|
||||||
injected into ``configurable`` or ``context`` — see
|
injected into ``configurable`` — see :func:`build_run_config`. All
|
||||||
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
|
``assistant_id`` values therefore map to the same factory; the routing
|
||||||
same factory; the routing happens inside ``make_lead_agent`` when it reads
|
happens inside ``make_lead_agent`` when it reads ``cfg["agent_name"]``.
|
||||||
``cfg["agent_name"]``.
|
|
||||||
"""
|
"""
|
||||||
from deerflow.agents.lead_agent.agent import make_lead_agent
|
from deerflow.agents.lead_agent.agent import make_lead_agent
|
||||||
|
|
||||||
@@ -210,12 +121,10 @@ def build_run_config(
|
|||||||
"""Build a RunnableConfig dict for the agent.
|
"""Build a RunnableConfig dict for the agent.
|
||||||
|
|
||||||
When *assistant_id* refers to a custom agent (anything other than
|
When *assistant_id* refers to a custom agent (anything other than
|
||||||
``"lead_agent"`` / ``None``), the name is forwarded as ``agent_name`` in
|
``"lead_agent"`` / ``None``), the name is forwarded as
|
||||||
whichever runtime options container is active: ``context`` for
|
``configurable["agent_name"]``. ``make_lead_agent`` reads this key to
|
||||||
LangGraph >= 0.6.0 requests, otherwise ``configurable``.
|
load the matching ``agents/<name>/SOUL.md`` and per-agent config —
|
||||||
``make_lead_agent`` reads this key to load the matching
|
without it the agent silently runs as the default lead agent.
|
||||||
``agents/<name>/SOUL.md`` and per-agent config — without it the agent
|
|
||||||
silently runs as the default lead agent.
|
|
||||||
|
|
||||||
This mirrors the channel manager's ``_resolve_run_params`` logic so that
|
This mirrors the channel manager's ``_resolve_run_params`` logic so that
|
||||||
the LangGraph Platform-compatible HTTP API and the IM channel path behave
|
the LangGraph Platform-compatible HTTP API and the IM channel path behave
|
||||||
@@ -234,14 +143,7 @@ def build_run_config(
|
|||||||
thread_id,
|
thread_id,
|
||||||
list(request_config.get("configurable", {}).keys()),
|
list(request_config.get("configurable", {}).keys()),
|
||||||
)
|
)
|
||||||
context_value = request_config["context"]
|
config["context"] = request_config["context"]
|
||||||
if context_value is None:
|
|
||||||
context = {}
|
|
||||||
elif isinstance(context_value, Mapping):
|
|
||||||
context = dict(context_value)
|
|
||||||
else:
|
|
||||||
raise ValueError("request config 'context' must be a mapping or null.")
|
|
||||||
config["context"] = context
|
|
||||||
else:
|
else:
|
||||||
configurable = {"thread_id": thread_id}
|
configurable = {"thread_id": thread_id}
|
||||||
configurable.update(request_config.get("configurable", {}))
|
configurable.update(request_config.get("configurable", {}))
|
||||||
@@ -253,20 +155,13 @@ def build_run_config(
|
|||||||
config["configurable"] = {"thread_id": thread_id}
|
config["configurable"] = {"thread_id": thread_id}
|
||||||
|
|
||||||
# Inject custom agent name when the caller specified a non-default assistant.
|
# Inject custom agent name when the caller specified a non-default assistant.
|
||||||
# Honour an explicit agent_name in the active runtime options container.
|
# Honour an explicit configurable["agent_name"] in the request if already set.
|
||||||
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
|
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID and "configurable" in config:
|
||||||
normalized = assistant_id.strip().lower().replace("_", "-")
|
if "agent_name" not in config["configurable"]:
|
||||||
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
|
normalized = assistant_id.strip().lower().replace("_", "-")
|
||||||
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.")
|
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
|
||||||
if "configurable" in config:
|
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.")
|
||||||
target = config["configurable"]
|
config["configurable"]["agent_name"] = normalized
|
||||||
elif "context" in config:
|
|
||||||
target = config["context"]
|
|
||||||
else:
|
|
||||||
target = config.setdefault("configurable", {})
|
|
||||||
if target is not None and "agent_name" not in target:
|
|
||||||
target["agent_name"] = normalized
|
|
||||||
config.setdefault("run_name", resolve_root_run_name(config, normalized))
|
|
||||||
if metadata:
|
if metadata:
|
||||||
config.setdefault("metadata", {}).update(metadata)
|
config.setdefault("metadata", {}).update(metadata)
|
||||||
return config
|
return config
|
||||||
@@ -300,125 +195,100 @@ async def start_run(
|
|||||||
|
|
||||||
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
|
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
|
||||||
|
|
||||||
body_context = getattr(body, "context", None) or {}
|
# Resolve follow_up_to_run_id: explicit from request, or auto-detect from latest successful run
|
||||||
model_name = body_context.get("model_name")
|
follow_up_to_run_id = getattr(body, "follow_up_to_run_id", None)
|
||||||
|
if follow_up_to_run_id is None:
|
||||||
# Coerce non-string model_name values to str before truncation.
|
run_store = get_run_store(request)
|
||||||
if model_name is not None and not isinstance(model_name, str):
|
|
||||||
model_name = str(model_name)
|
|
||||||
|
|
||||||
# Validate model against the allowlist when a model_name is provided.
|
|
||||||
if model_name:
|
|
||||||
app_config = get_app_config()
|
|
||||||
resolved = app_config.get_model_config(model_name)
|
|
||||||
if resolved is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Model {model_name!r} is not in the configured model allowlist",
|
|
||||||
)
|
|
||||||
|
|
||||||
owner_user_id = get_trusted_internal_owner_user_id(request)
|
|
||||||
# Stateless run endpoints carry thread_id in the request *body*, so the
|
|
||||||
# @require_permission(owner_check=True) decorator -- which resolves ownership
|
|
||||||
# from the path param -- cannot protect them. Enforce thread ownership here,
|
|
||||||
# before any run is created, so one user cannot start runs on (or read /wait
|
|
||||||
# checkpoint state from) another user's thread. Missing rows (auto-created
|
|
||||||
# temp threads) and NULL-owner rows (shared / pre-auth data) stay accessible
|
|
||||||
# via check_access; only a thread already owned by another user is rejected
|
|
||||||
# with 404, matching thread_runs.py's anti-enumeration behaviour. Internal
|
|
||||||
# channel runs act on behalf of the connection owner carried in
|
|
||||||
# X-DeerFlow-Owner-User-Id, so they are scoped to that owner instead of
|
|
||||||
# bypassing the check -- a leaked internal token must not grant cross-user
|
|
||||||
# thread access.
|
|
||||||
user = getattr(request.state, "user", None)
|
|
||||||
if user is not None:
|
|
||||||
allowed = await run_ctx.thread_store.check_access(thread_id, str(user.id))
|
|
||||||
if not allowed and owner_user_id and getattr(user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
|
|
||||||
# Channel workers may also act for the connection owner named in
|
|
||||||
# the trusted header (e.g. claiming a legacy default-owned channel
|
|
||||||
# thread for its real owner).
|
|
||||||
allowed = await run_ctx.thread_store.check_access(thread_id, owner_user_id)
|
|
||||||
if not allowed:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
|
||||||
|
|
||||||
owner_context_token = set_current_user(SimpleNamespace(id=owner_user_id)) if owner_user_id else None
|
|
||||||
try:
|
|
||||||
try:
|
try:
|
||||||
record = await run_mgr.create_or_reject(
|
recent_runs = await run_store.list_by_thread(thread_id, limit=1)
|
||||||
thread_id,
|
if recent_runs and recent_runs[0].get("status") == "success":
|
||||||
body.assistant_id,
|
follow_up_to_run_id = recent_runs[0]["run_id"]
|
||||||
on_disconnect=disconnect,
|
|
||||||
metadata=body.metadata or {},
|
|
||||||
kwargs={"input": body.input, "config": body.config},
|
|
||||||
multitask_strategy=body.multitask_strategy,
|
|
||||||
model_name=model_name,
|
|
||||||
user_id=owner_user_id,
|
|
||||||
)
|
|
||||||
except ConflictError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
except UnsupportedStrategyError as exc:
|
|
||||||
raise HTTPException(status_code=501, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
# Upsert thread metadata so the thread appears in /threads/search,
|
|
||||||
# even for threads that were never explicitly created via POST /threads
|
|
||||||
# (e.g. stateless runs).
|
|
||||||
try:
|
|
||||||
existing = await run_ctx.thread_store.get(thread_id)
|
|
||||||
if existing is None and owner_user_id:
|
|
||||||
unscoped_existing = await run_ctx.thread_store.get(thread_id, user_id=None)
|
|
||||||
if unscoped_existing is not None:
|
|
||||||
if unscoped_existing.get("user_id") != owner_user_id:
|
|
||||||
await run_ctx.thread_store.update_owner(thread_id, owner_user_id, user_id=None)
|
|
||||||
existing = await run_ctx.thread_store.get(thread_id)
|
|
||||||
if existing is None:
|
|
||||||
await run_ctx.thread_store.create(
|
|
||||||
thread_id,
|
|
||||||
assistant_id=body.assistant_id,
|
|
||||||
metadata=body.metadata,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await run_ctx.thread_store.update_status(thread_id, "running")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
|
pass # Don't block run creation
|
||||||
|
|
||||||
agent_factory = resolve_agent_factory(body.assistant_id)
|
# Enrich base context with per-run field
|
||||||
graph_input = normalize_input(body.input)
|
if follow_up_to_run_id:
|
||||||
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
|
run_ctx = dataclasses.replace(run_ctx, follow_up_to_run_id=follow_up_to_run_id)
|
||||||
|
|
||||||
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
|
try:
|
||||||
# The ``context`` field is a custom extension for the langgraph-compat layer
|
record = await run_mgr.create_or_reject(
|
||||||
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
thread_id,
|
||||||
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
|
body.assistant_id,
|
||||||
merge_run_context_overrides(config, getattr(body, "context", None))
|
on_disconnect=disconnect,
|
||||||
inject_authenticated_user_context(config, request)
|
metadata=body.metadata or {},
|
||||||
|
kwargs={"input": body.input, "config": body.config},
|
||||||
stream_modes = normalize_stream_modes(body.stream_mode)
|
multitask_strategy=body.multitask_strategy,
|
||||||
|
follow_up_to_run_id=follow_up_to_run_id,
|
||||||
task = asyncio.create_task(
|
|
||||||
run_agent(
|
|
||||||
bridge,
|
|
||||||
run_mgr,
|
|
||||||
record,
|
|
||||||
ctx=run_ctx,
|
|
||||||
agent_factory=agent_factory,
|
|
||||||
graph_input=graph_input,
|
|
||||||
config=config,
|
|
||||||
stream_modes=stream_modes,
|
|
||||||
stream_subgraphs=body.stream_subgraphs,
|
|
||||||
interrupt_before=body.interrupt_before,
|
|
||||||
interrupt_after=body.interrupt_after,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
record.task = task
|
except ConflictError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except UnsupportedStrategyError as exc:
|
||||||
|
raise HTTPException(status_code=501, detail=str(exc)) from exc
|
||||||
|
|
||||||
# Title sync is handled by worker.py's finally block which reads the
|
# Upsert thread metadata so the thread appears in /threads/search,
|
||||||
# title from the checkpoint and calls thread_store.update_display_name
|
# even for threads that were never explicitly created via POST /threads
|
||||||
# after the run completes.
|
# (e.g. stateless runs).
|
||||||
|
try:
|
||||||
|
existing = await run_ctx.thread_meta_repo.get(thread_id)
|
||||||
|
if existing is None:
|
||||||
|
await run_ctx.thread_meta_repo.create(
|
||||||
|
thread_id,
|
||||||
|
assistant_id=body.assistant_id,
|
||||||
|
metadata=body.metadata,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await run_ctx.thread_meta_repo.update_status(thread_id, "running")
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
|
||||||
|
|
||||||
return record
|
agent_factory = resolve_agent_factory(body.assistant_id)
|
||||||
finally:
|
graph_input = normalize_input(body.input)
|
||||||
if owner_context_token is not None:
|
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
|
||||||
reset_current_user(owner_context_token)
|
|
||||||
|
# Merge DeerFlow-specific context overrides into configurable.
|
||||||
|
# The ``context`` field is a custom extension for the langgraph-compat layer
|
||||||
|
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
||||||
|
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
|
||||||
|
context = getattr(body, "context", None)
|
||||||
|
if context:
|
||||||
|
_CONTEXT_CONFIGURABLE_KEYS = {
|
||||||
|
"model_name",
|
||||||
|
"mode",
|
||||||
|
"thinking_enabled",
|
||||||
|
"reasoning_effort",
|
||||||
|
"is_plan_mode",
|
||||||
|
"subagent_enabled",
|
||||||
|
"max_concurrent_subagents",
|
||||||
|
}
|
||||||
|
configurable = config.setdefault("configurable", {})
|
||||||
|
for key in _CONTEXT_CONFIGURABLE_KEYS:
|
||||||
|
if key in context:
|
||||||
|
configurable.setdefault(key, context[key])
|
||||||
|
|
||||||
|
stream_modes = normalize_stream_modes(body.stream_mode)
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
run_agent(
|
||||||
|
bridge,
|
||||||
|
run_mgr,
|
||||||
|
record,
|
||||||
|
ctx=run_ctx,
|
||||||
|
agent_factory=agent_factory,
|
||||||
|
graph_input=graph_input,
|
||||||
|
config=config,
|
||||||
|
stream_modes=stream_modes,
|
||||||
|
stream_subgraphs=body.stream_subgraphs,
|
||||||
|
interrupt_before=body.interrupt_before,
|
||||||
|
interrupt_after=body.interrupt_after,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record.task = task
|
||||||
|
|
||||||
|
# Title sync is handled by worker.py's finally block which reads the
|
||||||
|
# title from the checkpoint and calls thread_meta_repo.update_display_name
|
||||||
|
# after the run completes.
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
async def sse_consumer(
|
async def sse_consumer(
|
||||||
@@ -453,51 +323,3 @@ async def sse_consumer(
|
|||||||
if record.status in (RunStatus.pending, RunStatus.running):
|
if record.status in (RunStatus.pending, RunStatus.running):
|
||||||
if record.on_disconnect == DisconnectMode.cancel:
|
if record.on_disconnect == DisconnectMode.cancel:
|
||||||
await run_mgr.cancel(record.run_id)
|
await run_mgr.cancel(record.run_id)
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_run_completion(
|
|
||||||
bridge: StreamBridge,
|
|
||||||
record: RunRecord,
|
|
||||||
request: Request,
|
|
||||||
run_mgr: RunManager,
|
|
||||||
) -> bool:
|
|
||||||
"""Block until the run publishes ``END_SENTINEL``, honouring on_disconnect.
|
|
||||||
|
|
||||||
The non-streaming ``/wait`` endpoints used to ``await record.task``
|
|
||||||
directly with no disconnect handling. When the client (or an
|
|
||||||
intermediate HTTP proxy) timed out during a long tool call such as
|
|
||||||
``pip install``, the handler would swallow ``CancelledError`` and
|
|
||||||
serialize whatever checkpoint happened to exist — masking a half-finished
|
|
||||||
run as a normal completion (issue #3265).
|
|
||||||
|
|
||||||
This helper consumes the same bridge that ``sse_consumer`` does so the
|
|
||||||
wait path shares its disconnect semantics: each wake-up polls
|
|
||||||
``request.is_disconnected()``; on a real disconnect it cancels the
|
|
||||||
background run when ``record.on_disconnect`` is ``cancel``. The bridge's
|
|
||||||
heartbeat sentinels guarantee at least one wake-up per
|
|
||||||
``heartbeat_interval`` even when the agent emits no events for a while.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``True`` when ``END_SENTINEL`` was observed (run reached a terminal
|
|
||||||
state), ``False`` when the loop exited because the client
|
|
||||||
disconnected. Callers must skip checkpoint serialization on
|
|
||||||
``False`` so a partial checkpoint is not returned as a normal
|
|
||||||
response.
|
|
||||||
"""
|
|
||||||
completed = False
|
|
||||||
try:
|
|
||||||
async for entry in bridge.subscribe(record.run_id):
|
|
||||||
# END_SENTINEL means the run reached a terminal state; honour it
|
|
||||||
# even if the client just disconnected so the caller still serializes
|
|
||||||
# the real final checkpoint.
|
|
||||||
if entry is END_SENTINEL:
|
|
||||||
completed = True
|
|
||||||
return True
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
# Heartbeats and regular events: keep waiting for END_SENTINEL.
|
|
||||||
return completed
|
|
||||||
finally:
|
|
||||||
if not completed and record.status in (RunStatus.pending, RunStatus.running):
|
|
||||||
if record.on_disconnect == DisconnectMode.cancel:
|
|
||||||
await run_mgr.cancel(record.run_id)
|
|
||||||
|
|||||||
+13
-90
@@ -19,72 +19,24 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
|
||||||
try:
|
from deerflow.agents import make_lead_agent
|
||||||
from prompt_toolkit import PromptSession
|
|
||||||
from prompt_toolkit.history import InMemoryHistory
|
|
||||||
|
|
||||||
_HAS_PROMPT_TOOLKIT = True
|
|
||||||
except ImportError:
|
|
||||||
_HAS_PROMPT_TOOLKIT = False
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
_LOG_FMT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
logging.basicConfig(
|
||||||
_LOG_DATEFMT = "%Y-%m-%d %H:%M:%S"
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
def _setup_logging(log_level: int = logging.INFO) -> None:
|
)
|
||||||
"""Route logs to ``debug.log`` using *log_level* for the initial root/file setup.
|
|
||||||
|
|
||||||
This configures the root logger and the ``debug.log`` file handler so logs do
|
|
||||||
not print on the interactive console. It is idempotent: any pre-existing
|
|
||||||
handlers on the root logger (e.g. installed by ``logging.basicConfig`` in
|
|
||||||
transitively imported modules) are removed so the debug session output only
|
|
||||||
lands in ``debug.log``.
|
|
||||||
|
|
||||||
Note: later config-driven logging adjustments may change named logger
|
|
||||||
verbosity without raising the root logger or file-handler thresholds set
|
|
||||||
here, so the eventual contents of ``debug.log`` may not be filtered solely by
|
|
||||||
this function's ``log_level`` argument.
|
|
||||||
"""
|
|
||||||
root = logging.root
|
|
||||||
for h in list(root.handlers):
|
|
||||||
root.removeHandler(h)
|
|
||||||
h.close()
|
|
||||||
root.setLevel(log_level)
|
|
||||||
|
|
||||||
file_handler = logging.FileHandler("debug.log", mode="a", encoding="utf-8")
|
|
||||||
file_handler.setLevel(log_level)
|
|
||||||
file_handler.setFormatter(logging.Formatter(_LOG_FMT, datefmt=_LOG_DATEFMT))
|
|
||||||
root.addHandler(file_handler)
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
# Install file logging first so warnings emitted while loading config do not
|
|
||||||
# leak onto the interactive terminal via Python's lastResort handler.
|
|
||||||
_setup_logging()
|
|
||||||
|
|
||||||
from deerflow.config import get_app_config
|
|
||||||
from deerflow.config.app_config import apply_logging_level
|
|
||||||
|
|
||||||
app_config = get_app_config()
|
|
||||||
apply_logging_level(app_config.log_level)
|
|
||||||
|
|
||||||
# Delay the rest of the deerflow imports until *after* logging is installed
|
|
||||||
# so that any import-time side effects (e.g. deerflow.agents starts a
|
|
||||||
# background skill-loader thread on import) emit logs to debug.log instead
|
|
||||||
# of leaking onto the interactive terminal via Python's lastResort handler.
|
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from langgraph.runtime import Runtime
|
|
||||||
|
|
||||||
from deerflow.agents import make_lead_agent
|
|
||||||
from deerflow.config.paths import get_paths
|
|
||||||
from deerflow.mcp import initialize_mcp_tools
|
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
|
||||||
|
|
||||||
# Initialize MCP tools at startup
|
# Initialize MCP tools at startup
|
||||||
try:
|
try:
|
||||||
|
from deerflow.mcp import initialize_mcp_tools
|
||||||
|
|
||||||
await initialize_mcp_tools()
|
await initialize_mcp_tools()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Failed to initialize MCP tools: {e}")
|
print(f"Warning: Failed to initialize MCP tools: {e}")
|
||||||
@@ -100,29 +52,16 @@ async def main():
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime = Runtime(context={"thread_id": config["configurable"]["thread_id"]})
|
|
||||||
config["configurable"]["__pregel_runtime"] = runtime
|
|
||||||
|
|
||||||
agent = make_lead_agent(config)
|
agent = make_lead_agent(config)
|
||||||
|
|
||||||
session = PromptSession(history=InMemoryHistory()) if _HAS_PROMPT_TOOLKIT else None
|
|
||||||
|
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print("Lead Agent Debug Mode")
|
print("Lead Agent Debug Mode")
|
||||||
print("Type 'quit' or 'exit' to stop")
|
print("Type 'quit' or 'exit' to stop")
|
||||||
print(f"Logs: debug.log (log_level={app_config.log_level})")
|
|
||||||
if not _HAS_PROMPT_TOOLKIT:
|
|
||||||
print("Tip: `uv sync --group dev` to enable arrow-key & history support")
|
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
seen_artifacts: set[str] = set()
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if session:
|
user_input = input("\nYou: ").strip()
|
||||||
user_input = (await session.prompt_async("\nYou: ")).strip()
|
|
||||||
else:
|
|
||||||
user_input = input("\nYou: ").strip()
|
|
||||||
if not user_input:
|
if not user_input:
|
||||||
continue
|
continue
|
||||||
if user_input.lower() in ("quit", "exit"):
|
if user_input.lower() in ("quit", "exit"):
|
||||||
@@ -131,31 +70,15 @@ async def main():
|
|||||||
|
|
||||||
# Invoke the agent
|
# Invoke the agent
|
||||||
state = {"messages": [HumanMessage(content=user_input)]}
|
state = {"messages": [HumanMessage(content=user_input)]}
|
||||||
result = await agent.ainvoke(state, config=config)
|
result = await agent.ainvoke(state, config=config, context={"thread_id": "debug-thread-001"})
|
||||||
|
|
||||||
# Print the response
|
# Print the response
|
||||||
if result.get("messages"):
|
if result.get("messages"):
|
||||||
last_message = result["messages"][-1]
|
last_message = result["messages"][-1]
|
||||||
print(f"\nAgent: {last_message.content}")
|
print(f"\nAgent: {last_message.content}")
|
||||||
|
|
||||||
# Show files presented to the user this turn (new artifacts only)
|
except KeyboardInterrupt:
|
||||||
artifacts = result.get("artifacts") or []
|
print("\nInterrupted. Goodbye!")
|
||||||
new_artifacts = [p for p in artifacts if p not in seen_artifacts]
|
|
||||||
if new_artifacts:
|
|
||||||
thread_id = config["configurable"]["thread_id"]
|
|
||||||
user_id = get_effective_user_id()
|
|
||||||
paths = get_paths()
|
|
||||||
print("\n[Presented files]")
|
|
||||||
for virtual in new_artifacts:
|
|
||||||
try:
|
|
||||||
physical = paths.resolve_virtual_path(thread_id, virtual, user_id=user_id)
|
|
||||||
print(f" - {virtual}\n → {physical}")
|
|
||||||
except ValueError as exc:
|
|
||||||
print(f" - {virtual} (failed to resolve physical path: {exc})")
|
|
||||||
seen_artifacts.update(new_artifacts)
|
|
||||||
|
|
||||||
except (KeyboardInterrupt, EOFError):
|
|
||||||
print("\nGoodbye!")
|
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\nError: {e}")
|
print(f"\nError: {e}")
|
||||||
|
|||||||
+46
-74
@@ -6,16 +6,16 @@ This document provides a complete reference for the DeerFlow backend APIs.
|
|||||||
|
|
||||||
DeerFlow backend exposes two sets of APIs:
|
DeerFlow backend exposes two sets of APIs:
|
||||||
|
|
||||||
1. **LangGraph-compatible API** - Agent interactions, threads, and streaming (`/api/langgraph/*`)
|
1. **LangGraph API** - Agent interactions, threads, and streaming (`/api/langgraph/*`)
|
||||||
2. **Gateway API** - Models, MCP, skills, uploads, and artifacts (`/api/*`)
|
2. **Gateway API** - Models, MCP, skills, uploads, and artifacts (`/api/*`)
|
||||||
|
|
||||||
All APIs are accessed through the Nginx reverse proxy at port 2026.
|
All APIs are accessed through the Nginx reverse proxy at port 2026.
|
||||||
|
|
||||||
## LangGraph-compatible API
|
## LangGraph API
|
||||||
|
|
||||||
Base URL: `/api/langgraph`
|
Base URL: `/api/langgraph`
|
||||||
|
|
||||||
The public LangGraph-compatible API follows LangGraph SDK conventions. In the unified nginx deployment, Gateway owns `/api/langgraph/*` and translates those paths to its native `/api/*` run, thread, and streaming routers.
|
The LangGraph API is provided by the LangGraph server and follows the LangGraph SDK conventions.
|
||||||
|
|
||||||
### Threads
|
### Threads
|
||||||
|
|
||||||
@@ -104,11 +104,17 @@ Content-Type: application/json
|
|||||||
**Recursion Limit:**
|
**Recursion Limit:**
|
||||||
|
|
||||||
`config.recursion_limit` caps the number of graph steps LangGraph will execute
|
`config.recursion_limit` caps the number of graph steps LangGraph will execute
|
||||||
in a single run. The unified Gateway path defaults to `100` in
|
in a single run. The `/api/langgraph/*` endpoints go straight to the LangGraph
|
||||||
`build_run_config` (see `backend/app/gateway/services.py`), which is a safer
|
server and therefore inherit LangGraph's native default of **25**, which is
|
||||||
starting point for plan-mode or subagent-heavy runs. Clients can still set
|
too low for plan-mode or subagent-heavy runs — the agent typically errors out
|
||||||
`recursion_limit` explicitly in the request body; increase it if you run deeply
|
with `GraphRecursionError` after the first round of subagent results comes
|
||||||
nested subagent graphs.
|
back, before the lead agent can synthesize the final answer.
|
||||||
|
|
||||||
|
DeerFlow's own Gateway and IM-channel paths mitigate this by defaulting to
|
||||||
|
`100` in `build_run_config` (see `backend/app/gateway/services.py`), but
|
||||||
|
clients calling the LangGraph API directly must set `recursion_limit`
|
||||||
|
explicitly in the request body. `100` matches the Gateway default and is a
|
||||||
|
safe starting point; increase it if you run deeply nested subagent graphs.
|
||||||
|
|
||||||
**Configurable Options:**
|
**Configurable Options:**
|
||||||
- `model_name` (string): Override the default model
|
- `model_name` (string): Override the default model
|
||||||
@@ -228,13 +234,10 @@ Get current MCP server configurations.
|
|||||||
GET /api/mcp/config
|
GET /api/mcp/config
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires an authenticated admin session. Sensitive env/header/OAuth secret
|
|
||||||
values are masked in the response.
|
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcp_servers": {
|
"mcpServers": {
|
||||||
"github": {
|
"github": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
@@ -244,6 +247,13 @@ values are masked in the response.
|
|||||||
"GITHUB_TOKEN": "***"
|
"GITHUB_TOKEN": "***"
|
||||||
},
|
},
|
||||||
"description": "GitHub operations"
|
"description": "GitHub operations"
|
||||||
|
},
|
||||||
|
"filesystem": {
|
||||||
|
"enabled": false,
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||||
|
"description": "File system access"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,15 +268,10 @@ PUT /api/mcp/config
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires an authenticated admin session. API-managed `stdio` MCP servers may
|
|
||||||
only use allowed executable names for `command` (default: `npx`, `uvx`). Set
|
|
||||||
`DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST` to a comma-separated list when a
|
|
||||||
deployment needs additional trusted launchers.
|
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcp_servers": {
|
"mcpServers": {
|
||||||
"github": {
|
"github": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
@@ -284,18 +289,8 @@ deployment needs additional trusted launchers.
|
|||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcp_servers": {
|
"success": true,
|
||||||
"github": {
|
"message": "MCP configuration updated"
|
||||||
"enabled": true,
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
||||||
"env": {
|
|
||||||
"GITHUB_TOKEN": "***"
|
|
||||||
},
|
|
||||||
"description": "GitHub operations"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -546,28 +541,14 @@ All APIs return errors in a consistent format:
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
DeerFlow enforces authentication for all non-public HTTP routes. Public routes are limited to health/docs metadata and these public auth endpoints:
|
Currently, DeerFlow does not implement authentication. All APIs are accessible without credentials.
|
||||||
|
|
||||||
- `POST /api/v1/auth/initialize` creates the first admin account when no admin exists.
|
Note: This is about DeerFlow API authentication. MCP outbound connections can still use OAuth for configured HTTP/SSE MCP servers.
|
||||||
- `POST /api/v1/auth/login/local` logs in with email/password and sets an HttpOnly `access_token` cookie.
|
|
||||||
- `POST /api/v1/auth/register` creates a regular `user` account and sets the session cookie.
|
|
||||||
- `POST /api/v1/auth/logout` clears the session cookie.
|
|
||||||
- `GET /api/v1/auth/setup-status` reports whether the first admin still needs to be created.
|
|
||||||
|
|
||||||
The authenticated auth endpoints are:
|
For production deployments, it is recommended to:
|
||||||
|
1. Use Nginx for basic auth or OAuth integration
|
||||||
- `GET /api/v1/auth/me` returns the current user.
|
2. Deploy behind a VPN or private network
|
||||||
- `POST /api/v1/auth/change-password` changes password, optionally changes email during setup, increments `token_version`, and reissues the cookie.
|
3. Implement custom authentication middleware
|
||||||
|
|
||||||
Protected state-changing requests also require the CSRF double-submit token: send the `csrf_token` cookie value as the `X-CSRF-Token` header. Login/register/initialize/logout are bootstrap auth endpoints: they are exempt from the double-submit token but still reject hostile browser `Origin` headers.
|
|
||||||
|
|
||||||
User isolation is enforced from the authenticated user context:
|
|
||||||
|
|
||||||
- Thread metadata is scoped by `threads_meta.user_id`; search/read/write/delete APIs only expose the current user's threads.
|
|
||||||
- Thread files live under `{base_dir}/users/{user_id}/threads/{thread_id}/user-data/` and are exposed inside the sandbox as `/mnt/user-data/`.
|
|
||||||
- Memory and custom agents are stored under `{base_dir}/users/{user_id}/...`.
|
|
||||||
|
|
||||||
Note: MCP outbound connections can still use OAuth for configured HTTP/SSE MCP servers; that is separate from DeerFlow API authentication.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -586,13 +567,12 @@ location /api/ {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Streaming Support
|
## WebSocket Support
|
||||||
|
|
||||||
Gateway's LangGraph-compatible API streams run events with Server-Sent Events (SSE):
|
The LangGraph server supports WebSocket connections for real-time streaming. Connect to:
|
||||||
|
|
||||||
```http
|
```
|
||||||
POST /api/langgraph/threads/{thread_id}/runs/stream
|
ws://localhost:2026/api/langgraph/threads/{thread_id}/runs/stream
|
||||||
Accept: text/event-stream
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -628,21 +608,13 @@ const response = await fetch('/api/models');
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log(data.models);
|
console.log(data.models);
|
||||||
|
|
||||||
// Create a run and stream SSE events
|
// Using EventSource for streaming
|
||||||
const streamResponse = await fetch(`/api/langgraph/threads/${threadId}/runs/stream`, {
|
const eventSource = new EventSource(
|
||||||
method: "POST",
|
`/api/langgraph/threads/${threadId}/runs/stream`
|
||||||
headers: {
|
);
|
||||||
"Content-Type": "application/json",
|
eventSource.onmessage = (event) => {
|
||||||
Accept: "text/event-stream",
|
console.log(JSON.parse(event.data));
|
||||||
},
|
};
|
||||||
body: JSON.stringify({
|
|
||||||
input: { messages: [{ role: "user", content: "Hello" }] },
|
|
||||||
stream_mode: ["values", "messages-tuple", "custom"],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const reader = streamResponse.body?.getReader();
|
|
||||||
// Decode and parse SSE frames from reader in your client code.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### cURL Examples
|
### cURL Examples
|
||||||
@@ -677,7 +649,7 @@ curl -X POST http://localhost:2026/api/langgraph/threads/abc123/runs \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
> The unified Gateway path defaults `config.recursion_limit` to 100 for
|
> The `/api/langgraph/*` endpoints bypass DeerFlow's Gateway and inherit
|
||||||
> plan-mode and subagent-heavy runs. Clients may still set
|
> LangGraph's native `recursion_limit` default of 25, which is too low for
|
||||||
> `config.recursion_limit` explicitly — see the [Create Run](#create-run)
|
> plan-mode or subagent runs. Set `config.recursion_limit` explicitly — see
|
||||||
> section for details.
|
> the [Create Run](#create-run) section for details.
|
||||||
|
|||||||
@@ -14,28 +14,30 @@ This document provides a comprehensive overview of the DeerFlow backend architec
|
|||||||
│ Nginx (Port 2026) │
|
│ Nginx (Port 2026) │
|
||||||
│ Unified Reverse Proxy Entry Point │
|
│ Unified Reverse Proxy Entry Point │
|
||||||
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ /api/langgraph/* → Gateway LangGraph-compatible runtime (8001) │ │
|
│ │ /api/langgraph/* → LangGraph Server (2024) │ │
|
||||||
│ │ /api/* → Gateway REST APIs (8001) │ │
|
│ │ /api/* → Gateway API (8001) │ │
|
||||||
│ │ /* → Frontend (3000) │ │
|
│ │ /* → Frontend (3000) │ │
|
||||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||||
└─────────────────────────────────┬────────────────────────────────────────┘
|
└─────────────────────────────────┬────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌───────────────────────┴───────────────────────┐
|
┌───────────────────────┼───────────────────────┐
|
||||||
│ │
|
│ │ │
|
||||||
▼ ▼
|
▼ ▼ ▼
|
||||||
┌─────────────────────────────────────────────┐ ┌─────────────────────┐
|
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
|
||||||
│ Gateway API │ │ Frontend │
|
│ LangGraph Server │ │ Gateway API │ │ Frontend │
|
||||||
│ (Port 8001) │ │ (Port 3000) │
|
│ (Port 2024) │ │ (Port 8001) │ │ (Port 3000) │
|
||||||
│ │ │ │
|
│ │ │ │ │ │
|
||||||
│ - LangGraph-compatible runs/threads API │ │ - Next.js App │
|
│ - Agent Runtime │ │ - Models API │ │ - Next.js App │
|
||||||
│ - Embedded Agent Runtime │ │ - React UI │
|
│ - Thread Mgmt │ │ - MCP Config │ │ - React UI │
|
||||||
│ - SSE Streaming │ │ - Chat Interface │
|
│ - SSE Streaming │ │ - Skills Mgmt │ │ - Chat Interface │
|
||||||
│ - Checkpointing │ │ │
|
│ - Checkpointing │ │ - File Uploads │ │ │
|
||||||
│ - Models, MCP, Skills, Uploads, Artifacts │ │ │
|
│ │ │ - Thread Cleanup │ │ │
|
||||||
│ - Thread Cleanup │ │ │
|
│ │ │ - Artifacts │ │ │
|
||||||
└─────────────────────────────────────────────┘ └─────────────────────┘
|
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
|
||||||
│
|
│ │
|
||||||
▼
|
│ ┌─────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
┌──────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
│ Shared Configuration │
|
│ Shared Configuration │
|
||||||
│ ┌─────────────────────────┐ ┌────────────────────────────────────────┐ │
|
│ ┌─────────────────────────┐ ┌────────────────────────────────────────┐ │
|
||||||
@@ -50,9 +52,9 @@ This document provides a comprehensive overview of the DeerFlow backend architec
|
|||||||
|
|
||||||
## Component Details
|
## Component Details
|
||||||
|
|
||||||
### Gateway Embedded Agent Runtime
|
### LangGraph Server
|
||||||
|
|
||||||
The agent runtime is embedded in the FastAPI Gateway and built on LangGraph for robust multi-agent workflow orchestration. Nginx rewrites `/api/langgraph/*` to Gateway's native `/api/*` routes, so the public API remains compatible with LangGraph SDK clients without running a separate LangGraph server.
|
The LangGraph server is the core agent runtime, built on LangGraph for robust multi-agent workflow orchestration.
|
||||||
|
|
||||||
**Entry Point**: `packages/harness/deerflow/agents/lead_agent/agent.py:make_lead_agent`
|
**Entry Point**: `packages/harness/deerflow/agents/lead_agent/agent.py:make_lead_agent`
|
||||||
|
|
||||||
@@ -63,8 +65,7 @@ The agent runtime is embedded in the FastAPI Gateway and built on LangGraph for
|
|||||||
- Tool execution orchestration
|
- Tool execution orchestration
|
||||||
- SSE streaming for real-time responses
|
- SSE streaming for real-time responses
|
||||||
|
|
||||||
**Graph registry**: `langgraph.json` remains available for tooling, Studio, or direct LangGraph Server compatibility.
|
**Configuration**: `langgraph.json`
|
||||||
It is not the default service entrypoint; scripts and Docker deployments run the Gateway embedded runtime.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -77,13 +78,12 @@ It is not the default service entrypoint; scripts and Docker deployments run the
|
|||||||
|
|
||||||
### Gateway API
|
### Gateway API
|
||||||
|
|
||||||
FastAPI application providing REST endpoints plus the public LangGraph-compatible `/api/langgraph/*` runtime routes.
|
FastAPI application providing REST endpoints for non-agent operations.
|
||||||
|
|
||||||
**Entry Point**: `app/gateway/app.py`
|
**Entry Point**: `app/gateway/app.py`
|
||||||
|
|
||||||
**Routers**:
|
**Routers**:
|
||||||
- `models.py` - `/api/models` - Model listing and details
|
- `models.py` - `/api/models` - Model listing and details
|
||||||
- `thread_runs.py` / `runs.py` - `/api/threads/{id}/runs`, `/api/runs/*` - LangGraph-compatible runs and streaming
|
|
||||||
- `mcp.py` - `/api/mcp` - MCP server configuration
|
- `mcp.py` - `/api/mcp` - MCP server configuration
|
||||||
- `skills.py` - `/api/skills` - Skills management
|
- `skills.py` - `/api/skills` - Skills management
|
||||||
- `uploads.py` - `/api/threads/{id}/uploads` - File upload
|
- `uploads.py` - `/api/threads/{id}/uploads` - File upload
|
||||||
@@ -91,7 +91,7 @@ FastAPI application providing REST endpoints plus the public LangGraph-compatibl
|
|||||||
- `artifacts.py` - `/api/threads/{id}/artifacts` - Artifact serving
|
- `artifacts.py` - `/api/threads/{id}/artifacts` - Artifact serving
|
||||||
- `suggestions.py` - `/api/threads/{id}/suggestions` - Follow-up suggestion generation
|
- `suggestions.py` - `/api/threads/{id}/suggestions` - Follow-up suggestion generation
|
||||||
|
|
||||||
The web conversation delete flow first deletes Gateway-managed thread state through the LangGraph-compatible route, then the Gateway `threads.py` router removes DeerFlow-managed filesystem data via `Paths.delete_thread_dir()`.
|
The web conversation delete flow is now split across both backend surfaces: LangGraph handles `DELETE /api/langgraph/threads/{thread_id}` for thread state, then the Gateway `threads.py` router removes DeerFlow-managed filesystem data via `Paths.delete_thread_dir()`.
|
||||||
|
|
||||||
### Agent Architecture
|
### Agent Architecture
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ class ThreadState(AgentState):
|
|||||||
│ Built-in Tools │ │ Configured Tools │ │ MCP Tools │
|
│ Built-in Tools │ │ Configured Tools │ │ MCP Tools │
|
||||||
│ (packages/harness/deerflow/tools/) │ │ (config.yaml) │ │ (extensions.json) │
|
│ (packages/harness/deerflow/tools/) │ │ (config.yaml) │ │ (extensions.json) │
|
||||||
├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤
|
├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤
|
||||||
│ - present_files │ │ - web_search │ │ - github │
|
│ - present_file │ │ - web_search │ │ - github │
|
||||||
│ - ask_clarification │ │ - web_fetch │ │ - filesystem │
|
│ - ask_clarification │ │ - web_fetch │ │ - filesystem │
|
||||||
│ - view_image │ │ - bash │ │ - postgres │
|
│ - view_image │ │ - bash │ │ - postgres │
|
||||||
│ │ │ - read_file │ │ - brave-search │
|
│ │ │ - read_file │ │ - brave-search │
|
||||||
@@ -353,10 +353,10 @@ SKILL.md Format:
|
|||||||
POST /api/langgraph/threads/{thread_id}/runs
|
POST /api/langgraph/threads/{thread_id}/runs
|
||||||
{"input": {"messages": [{"role": "user", "content": "Hello"}]}}
|
{"input": {"messages": [{"role": "user", "content": "Hello"}]}}
|
||||||
|
|
||||||
2. Nginx → Gateway API (8001)
|
2. Nginx → LangGraph Server (2024)
|
||||||
`/api/langgraph/*` is rewritten to Gateway's LangGraph-compatible `/api/*` routes
|
Proxied to LangGraph server
|
||||||
|
|
||||||
3. Gateway embedded runtime
|
3. LangGraph Server
|
||||||
a. Load/create thread state
|
a. Load/create thread state
|
||||||
b. Execute middleware chain:
|
b. Execute middleware chain:
|
||||||
- ThreadDataMiddleware: Set up paths
|
- ThreadDataMiddleware: Set up paths
|
||||||
@@ -412,7 +412,7 @@ SKILL.md Format:
|
|||||||
### Thread Cleanup Flow
|
### Thread Cleanup Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Client deletes conversation via the LangGraph-compatible Gateway route
|
1. Client deletes conversation via LangGraph
|
||||||
DELETE /api/langgraph/threads/{thread_id}
|
DELETE /api/langgraph/threads/{thread_id}
|
||||||
|
|
||||||
2. Web UI follows up with Gateway cleanup
|
2. Web UI follows up with Gateway cleanup
|
||||||
|
|||||||
@@ -1,331 +0,0 @@
|
|||||||
# 用户认证与隔离设计
|
|
||||||
|
|
||||||
本文档描述 DeerFlow 当前内置认证模块的设计,而不是历史 RFC。它覆盖浏览器登录、API 认证、CSRF、用户隔离、首次初始化、密码重置、内部调用和升级迁移。
|
|
||||||
|
|
||||||
## 设计目标
|
|
||||||
|
|
||||||
认证模块的核心目标是把 DeerFlow 从“本地单用户工具”提升为“可多用户部署的 agent runtime”,并让用户身份贯穿 HTTP API、LangGraph-compatible runtime、文件系统、memory、自定义 agent 和反馈数据。
|
|
||||||
|
|
||||||
设计约束:
|
|
||||||
|
|
||||||
- 默认强制认证:除健康检查、文档和 auth bootstrap 端点外,HTTP 路由都必须有有效 session。
|
|
||||||
- 服务端持有所有权:客户端 metadata 不能声明 `user_id` 或 `owner_id`。
|
|
||||||
- 隔离默认开启:repository(仓储)、文件路径、memory、agent 配置默认按当前用户解析。
|
|
||||||
- 旧数据可升级:无认证版本留下的 thread 可以在 admin 存在后迁移到 admin。
|
|
||||||
- 密码不进日志:首次初始化由操作者设置密码;`reset_admin` 只写 0600 凭据文件。
|
|
||||||
|
|
||||||
非目标:
|
|
||||||
|
|
||||||
- 当前 OAuth 端点只是占位,尚未实现第三方登录。
|
|
||||||
- 当前用户角色只有 `admin` 和 `user`,尚未实现细粒度 RBAC。
|
|
||||||
- 当前登录限速是进程内字典,多 worker 下不是全局精确限速。
|
|
||||||
|
|
||||||
## 核心模型
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TB
|
|
||||||
classDef actor fill:#D8CFC4,stroke:#6E6259,color:#2F2A26;
|
|
||||||
classDef api fill:#C9D7D2,stroke:#5D706A,color:#21302C;
|
|
||||||
classDef state fill:#D7D3E8,stroke:#6B6680,color:#29263A;
|
|
||||||
classDef data fill:#E5D2C4,stroke:#806A5B,color:#30251E;
|
|
||||||
|
|
||||||
Browser["Browser — access_token cookie and csrf_token cookie"]:::actor
|
|
||||||
AuthMiddleware["AuthMiddleware — strict session gate"]:::api
|
|
||||||
CSRFMiddleware["CSRFMiddleware — double-submit token and Origin check"]:::api
|
|
||||||
AuthRoutes["Auth routes — initialize login register logout me change-password"]:::api
|
|
||||||
UserContext["Current user ContextVar — request-scoped identity"]:::state
|
|
||||||
Repositories["Repositories — AUTO resolves user_id from context"]:::state
|
|
||||||
Files["Filesystem — users/{user_id}/threads/{thread_id}/user-data"]:::data
|
|
||||||
Memory["Memory and agents — users/{user_id}/memory.json and agents"]:::data
|
|
||||||
|
|
||||||
Browser --> AuthMiddleware
|
|
||||||
Browser --> CSRFMiddleware
|
|
||||||
AuthMiddleware --> AuthRoutes
|
|
||||||
AuthMiddleware --> UserContext
|
|
||||||
UserContext --> Repositories
|
|
||||||
UserContext --> Files
|
|
||||||
UserContext --> Memory
|
|
||||||
```
|
|
||||||
|
|
||||||
### 用户表
|
|
||||||
|
|
||||||
用户记录定义在 `app.gateway.auth.models.User`,持久化到 `users` 表。关键字段:
|
|
||||||
|
|
||||||
| 字段 | 语义 |
|
|
||||||
|---|---|
|
|
||||||
| `id` | 用户主键,JWT `sub` 使用该值 |
|
|
||||||
| `email` | 唯一登录名 |
|
|
||||||
| `password_hash` | bcrypt hash,OAuth 用户可为空 |
|
|
||||||
| `system_role` | `admin` 或 `user` |
|
|
||||||
| `needs_setup` | reset 后要求用户完成邮箱 / 密码设置 |
|
|
||||||
| `token_version` | 改密码或 reset 时递增,用于废弃旧 JWT |
|
|
||||||
|
|
||||||
### 运行时身份
|
|
||||||
|
|
||||||
认证成功后,`AuthMiddleware` 把用户同时写入:
|
|
||||||
|
|
||||||
- `request.state.user`
|
|
||||||
- `request.state.auth`
|
|
||||||
- `deerflow.runtime.user_context` 的 `ContextVar`
|
|
||||||
|
|
||||||
`ContextVar` 是这里的核心边界。上层 Gateway 负责写入身份,下层 persistence / file path 只读取结构化的当前用户,不反向依赖 `app.gateway.auth` 具体类型。
|
|
||||||
|
|
||||||
可以把 repository 调用的用户参数理解成一个三态 ADT:
|
|
||||||
|
|
||||||
```scala
|
|
||||||
enum UserScope:
|
|
||||||
case AutoFromContext
|
|
||||||
case Explicit(userId: String)
|
|
||||||
case BypassForMigration
|
|
||||||
```
|
|
||||||
|
|
||||||
对应 Python 实现是 `AUTO | str | None`:
|
|
||||||
|
|
||||||
- `AUTO`:从 `ContextVar` 解析当前用户;没有上下文则抛错。
|
|
||||||
- `str`:显式指定用户,主要用于测试或管理脚本。
|
|
||||||
- `None`:跳过用户过滤,只允许迁移脚本或 admin CLI 使用。
|
|
||||||
|
|
||||||
## 登录与初始化流程
|
|
||||||
|
|
||||||
### 首次初始化
|
|
||||||
|
|
||||||
首次启动时,如果没有 admin,服务不会自动创建账号,只记录日志提示访问 `/setup`。
|
|
||||||
|
|
||||||
流程:
|
|
||||||
|
|
||||||
1. 用户访问 `/setup`。
|
|
||||||
2. 前端调用 `GET /api/v1/auth/setup-status`。
|
|
||||||
3. 如果返回 `{"needs_setup": true}`,前端展示创建 admin 表单。
|
|
||||||
4. 表单提交 `POST /api/v1/auth/initialize`。
|
|
||||||
5. 服务端确认当前没有 admin,创建 `system_role="admin"`、`needs_setup=false` 的用户。
|
|
||||||
6. 服务端设置 `access_token` HttpOnly cookie,用户进入 workspace。
|
|
||||||
|
|
||||||
`/api/v1/auth/initialize` 只在没有 admin 时可用。并发初始化由数据库唯一约束兜底,失败方返回 409。
|
|
||||||
|
|
||||||
### 普通登录
|
|
||||||
|
|
||||||
`POST /api/v1/auth/login/local` 使用 `OAuth2PasswordRequestForm`:
|
|
||||||
|
|
||||||
- `username` 是邮箱。
|
|
||||||
- `password` 是密码。
|
|
||||||
- 成功后签发 JWT,放入 `access_token` HttpOnly cookie。
|
|
||||||
- 响应体只返回 `expires_in` 和 `needs_setup`,不返回 token。
|
|
||||||
|
|
||||||
登录失败会按客户端 IP 计数。IP 解析只在 TCP peer 属于 `AUTH_TRUSTED_PROXIES` 时信任 `X-Real-IP`,不使用 `X-Forwarded-For`。
|
|
||||||
|
|
||||||
### 注册
|
|
||||||
|
|
||||||
`POST /api/v1/auth/register` 创建普通 `user`,并自动登录。
|
|
||||||
|
|
||||||
当前实现允许在没有 admin 时注册普通用户,但 `setup-status` 仍会返回 `needs_setup=true`,因为 admin 仍不存在。这是当前产品策略边界:如果后续要求“必须先初始化 admin 才能注册普通用户”,需要在 `/register` 增加 admin-exists gate。
|
|
||||||
|
|
||||||
### 改密码与 reset setup
|
|
||||||
|
|
||||||
`POST /api/v1/auth/change-password` 需要当前密码和新密码:
|
|
||||||
|
|
||||||
- 校验当前密码。
|
|
||||||
- 更新 bcrypt hash。
|
|
||||||
- `token_version += 1`,使旧 JWT 立即失效。
|
|
||||||
- 重新签发 cookie。
|
|
||||||
- 如果 `needs_setup=true` 且传了 `new_email`,则更新邮箱并清除 `needs_setup`。
|
|
||||||
|
|
||||||
`python -m app.gateway.auth.reset_admin` 会:
|
|
||||||
|
|
||||||
- 找到 admin 或指定邮箱用户。
|
|
||||||
- 生成随机密码。
|
|
||||||
- 更新密码 hash。
|
|
||||||
- `token_version += 1`。
|
|
||||||
- 设置 `needs_setup=true`。
|
|
||||||
- 写入 `.deer-flow/admin_initial_credentials.txt`,权限 `0600`。
|
|
||||||
|
|
||||||
命令行只输出凭据文件路径,不输出明文密码。
|
|
||||||
|
|
||||||
## HTTP 认证边界
|
|
||||||
|
|
||||||
`AuthMiddleware` 是 fail-closed(默认拒绝)的全局认证门。
|
|
||||||
|
|
||||||
公开路径:
|
|
||||||
|
|
||||||
- `/health`
|
|
||||||
- `/docs`
|
|
||||||
- `/redoc`
|
|
||||||
- `/openapi.json`
|
|
||||||
- `/api/v1/auth/login/local`
|
|
||||||
- `/api/v1/auth/register`
|
|
||||||
- `/api/v1/auth/logout`
|
|
||||||
- `/api/v1/auth/setup-status`
|
|
||||||
- `/api/v1/auth/initialize`
|
|
||||||
|
|
||||||
其余路径都要求有效 `access_token` cookie。存在 cookie 但 JWT 无效、过期、用户不存在或 `token_version` 不匹配时,直接返回 401,而不是让请求穿透到业务路由。
|
|
||||||
|
|
||||||
路由级别的 owner check 由 `require_permission(..., owner_check=True)` 完成:
|
|
||||||
|
|
||||||
- 读类请求允许旧的未追踪 legacy thread 兼容读取。
|
|
||||||
- 写 / 删除类请求使用 `require_existing=True`,要求 thread row 存在且属于当前用户,避免删除后缺 row 导致其他用户误通过。
|
|
||||||
|
|
||||||
## CSRF 设计
|
|
||||||
|
|
||||||
DeerFlow 使用 Double Submit Cookie:
|
|
||||||
|
|
||||||
- 服务端设置 `csrf_token` cookie。
|
|
||||||
- 前端 state-changing 请求发送同值 `X-CSRF-Token` header。
|
|
||||||
- 服务端用 `secrets.compare_digest` 比较 cookie/header。
|
|
||||||
|
|
||||||
需要 CSRF 的方法:
|
|
||||||
|
|
||||||
- `POST`
|
|
||||||
- `PUT`
|
|
||||||
- `DELETE`
|
|
||||||
- `PATCH`
|
|
||||||
|
|
||||||
auth bootstrap 端点(login/register/initialize/logout)不要求 double-submit token,因为首次调用时浏览器还没有 token;但这些端点会校验 browser `Origin`,拒绝 hostile Origin,避免 login CSRF / session fixation。
|
|
||||||
|
|
||||||
## 用户隔离
|
|
||||||
|
|
||||||
### Thread metadata
|
|
||||||
|
|
||||||
Thread metadata 存在 `threads_meta`,关键隔离字段是 `user_id`。
|
|
||||||
|
|
||||||
创建 thread 时:
|
|
||||||
|
|
||||||
- 客户端传入的 `metadata.user_id` 和 `metadata.owner_id` 会被剥离。
|
|
||||||
- `ThreadMetaRepository.create(..., user_id=AUTO)` 从 `ContextVar` 解析真实用户。
|
|
||||||
- `/api/threads/search` 默认只返回当前用户的 thread。
|
|
||||||
|
|
||||||
读取 / 修改 / 删除时:
|
|
||||||
|
|
||||||
- `get()` 默认按当前用户过滤。
|
|
||||||
- `check_access()` 用于路由 owner check。
|
|
||||||
- 对其他用户的 thread 返回 404,避免泄露资源存在性。
|
|
||||||
|
|
||||||
### 文件系统
|
|
||||||
|
|
||||||
当前线程文件布局:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{base_dir}/users/{user_id}/threads/{thread_id}/user-data/
|
|
||||||
├── workspace/
|
|
||||||
├── uploads/
|
|
||||||
└── outputs/
|
|
||||||
```
|
|
||||||
|
|
||||||
agent 在 sandbox 内看到统一虚拟路径:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/mnt/user-data/workspace
|
|
||||||
/mnt/user-data/uploads
|
|
||||||
/mnt/user-data/outputs
|
|
||||||
```
|
|
||||||
|
|
||||||
`ThreadDataMiddleware` 使用 `get_effective_user_id()` 解析当前用户并生成线程路径。没有认证上下文时会落到 `default` 用户桶,主要用于内部调用、嵌入式 client 或无 HTTP 的本地执行路径。
|
|
||||||
|
|
||||||
### Memory
|
|
||||||
|
|
||||||
默认 memory 存储:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{base_dir}/users/{user_id}/memory.json
|
|
||||||
{base_dir}/users/{user_id}/agents/{agent_name}/memory.json
|
|
||||||
```
|
|
||||||
|
|
||||||
有用户上下文时,空或相对 `memory.storage_path` 都使用上述 per-user 默认路径;只有绝对 `memory.storage_path` 会视为显式 opt-out(退出) per-user isolation,所有用户共享该路径。无用户上下文的 legacy 路径仍会把相对 `storage_path` 解析到 `Paths.base_dir` 下。
|
|
||||||
|
|
||||||
### 自定义 agent
|
|
||||||
|
|
||||||
用户自定义 agent 写入:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{base_dir}/users/{user_id}/agents/{agent_name}/
|
|
||||||
├── config.yaml
|
|
||||||
├── SOUL.md
|
|
||||||
└── memory.json
|
|
||||||
```
|
|
||||||
|
|
||||||
旧布局 `{base_dir}/agents/{agent_name}/` 只作为只读兼容回退。更新或删除旧共享 agent 会要求先运行迁移脚本。
|
|
||||||
|
|
||||||
## 内部调用与 IM 渠道
|
|
||||||
|
|
||||||
IM channel worker 不是浏览器用户,不持有浏览器 cookie。它们通过 Gateway 内部认证:
|
|
||||||
|
|
||||||
- 请求带 `X-DeerFlow-Internal-Token`。
|
|
||||||
- 同时带匹配的 CSRF cookie/header。
|
|
||||||
- 服务端识别为内部用户,`id="default"`、`system_role="internal"`。
|
|
||||||
|
|
||||||
这意味着 channel 产生的数据默认进入 `default` 用户桶。这个选择适合“平台级 bot 身份”,但不是“每个 IM 用户单独隔离”。如果后续要做到外部 IM 用户隔离,需要把外部 platform user 映射到 DeerFlow user,并让 channel manager 设置对应的 scoped identity。
|
|
||||||
|
|
||||||
## LangGraph-compatible 认证
|
|
||||||
|
|
||||||
Gateway 内嵌 runtime 路径由 `AuthMiddleware` 和 `CSRFMiddleware` 保护。
|
|
||||||
|
|
||||||
仓库仍保留 `app.gateway.langgraph_auth`,用于 LangGraph Server 直连模式:
|
|
||||||
|
|
||||||
- `@auth.authenticate` 校验 JWT cookie、CSRF、用户存在性和 `token_version`。
|
|
||||||
- `@auth.on` 在写入 metadata 时注入 `user_id`,并在读路径返回 `{"user_id": current_user}` 过滤条件。
|
|
||||||
|
|
||||||
这保证 Gateway 路由和 LangGraph-compatible 直连模式使用同一 JWT 语义。
|
|
||||||
|
|
||||||
## 升级与迁移
|
|
||||||
|
|
||||||
从无认证版本升级时,可能存在没有 `user_id` 的历史 thread。
|
|
||||||
|
|
||||||
当前策略:
|
|
||||||
|
|
||||||
1. 首次启动如果没有 admin,只提示访问 `/setup`,不迁移。
|
|
||||||
2. 操作者创建 admin。
|
|
||||||
3. 后续启动时,`_ensure_admin_user()` 找到 admin,并把 LangGraph store 中缺少 `metadata.user_id` 的 thread 迁移到 admin。
|
|
||||||
|
|
||||||
文件系统旧布局迁移由脚本处理:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
PYTHONPATH=. python scripts/migrate_user_isolation.py --dry-run
|
|
||||||
PYTHONPATH=. python scripts/migrate_user_isolation.py --user-id <target-user-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
迁移脚本覆盖 legacy `memory.json`、`threads/` 和 `agents/` 到 per-user layout。
|
|
||||||
|
|
||||||
## 安全不变量
|
|
||||||
|
|
||||||
必须长期保持的不变量:
|
|
||||||
|
|
||||||
- JWT 只在 HttpOnly cookie 中传输,不出现在响应 JSON。
|
|
||||||
- 任何非 public HTTP 路由都不能只靠“cookie 存在”放行,必须严格验证 JWT。
|
|
||||||
- `token_version` 不匹配必须拒绝,保证改密码 / reset 后旧 session 失效。
|
|
||||||
- 客户端 metadata 中的 `user_id` / `owner_id` 必须剥离。
|
|
||||||
- repository 默认 `AUTO` 必须从当前用户上下文解析,不能静默退化成全局查询。
|
|
||||||
- 只有迁移脚本和 admin CLI 可以显式传 `user_id=None` 绕过隔离。
|
|
||||||
- 本地文件路径必须通过 `Paths` 和 sandbox path validation 解析,不能拼接未校验的用户输入。
|
|
||||||
- 捕获认证、迁移、后台任务异常必须记录日志;不能空 catch。
|
|
||||||
|
|
||||||
## 已知边界
|
|
||||||
|
|
||||||
| 边界 | 当前行为 | 后续方向 |
|
|
||||||
|---|---|---|
|
|
||||||
| 无 admin 时注册普通用户 | 允许注册普通 `user` | 如产品要求先初始化 admin,给 `/register` 加 gate |
|
|
||||||
| 登录限速 | 进程内 dict,单 worker 精确,多 worker 近似 | Redis / DB-backed rate limiter |
|
|
||||||
| OAuth | 端点占位,未实现 | 接入 provider 并统一 `token_version` / role 语义 |
|
|
||||||
| IM 用户隔离 | channel 使用 `default` 内部用户 | 建立外部用户到 DeerFlow user 的映射 |
|
|
||||||
| 绝对 memory path | 显式共享 memory | UI / docs 明确提示 opt-out 风险 |
|
|
||||||
|
|
||||||
## 相关文件
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|---|---|
|
|
||||||
| `app/gateway/auth_middleware.py` | 全局认证门、JWT 严格验证、写入 user context |
|
|
||||||
| `app/gateway/csrf_middleware.py` | CSRF double-submit 和 auth Origin 校验 |
|
|
||||||
| `app/gateway/routers/auth.py` | initialize/login/register/logout/me/change-password |
|
|
||||||
| `app/gateway/auth/jwt.py` | JWT 创建与解析 |
|
|
||||||
| `app/gateway/auth/reset_admin.py` | 密码 reset CLI |
|
|
||||||
| `app/gateway/auth/credential_file.py` | 0600 凭据文件写入 |
|
|
||||||
| `app/gateway/authz.py` | 路由权限与 owner check |
|
|
||||||
| `deerflow/runtime/user_context.py` | 当前用户 ContextVar 与 `AUTO` sentinel |
|
|
||||||
| `deerflow/persistence/thread_meta/` | thread metadata owner filter |
|
|
||||||
| `deerflow/config/paths.py` | per-user filesystem layout |
|
|
||||||
| `deerflow/agents/middlewares/thread_data_middleware.py` | run 时解析用户线程目录 |
|
|
||||||
| `deerflow/agents/memory/storage.py` | per-user memory storage |
|
|
||||||
| `deerflow/config/agents_config.py` | per-user custom agents |
|
|
||||||
| `app/channels/manager.py` | IM channel 内部认证调用 |
|
|
||||||
| `scripts/migrate_user_isolation.py` | legacy 数据迁移到 per-user layout |
|
|
||||||
| `.deer-flow/data/deerflow.db` | 统一 SQLite 数据库,包含 users / threads_meta / runs / feedback 等表 |
|
|
||||||
| `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory |
|
|
||||||
| `.deer-flow/admin_initial_credentials.txt` | `reset_admin` 生成的新凭据文件(0600,读完应删除) |
|
|
||||||
@@ -24,12 +24,12 @@ All other test plan sections were executed against either:
|
|||||||
|
|
||||||
| Case | Title | What it covers | Why not run |
|
| Case | Title | What it covers | Why not run |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| TC-DOCKER-01 | `deerflow.db` volume persistence | Verify the `DEER_FLOW_HOME` bind mount survives container restart | needs `docker compose up` |
|
| TC-DOCKER-01 | `users.db` volume persistence | Verify the `DEER_FLOW_HOME` bind mount survives container restart | needs `docker compose up` |
|
||||||
| TC-DOCKER-02 | Session persistence across container restart | `AUTH_JWT_SECRET` env var keeps cookies valid after `docker compose down && up` | needs `docker compose down/up` |
|
| TC-DOCKER-02 | Session persistence across container restart | `AUTH_JWT_SECRET` env var keeps cookies valid after `docker compose down && up` | needs `docker compose down/up` |
|
||||||
| TC-DOCKER-03 | Per-worker rate limiter divergence | Confirms in-process `_login_attempts` dict doesn't share state across `gunicorn` workers (4 by default in the compose file); known limitation, documented | needs multi-worker container |
|
| TC-DOCKER-03 | Per-worker rate limiter divergence | Confirms in-process `_login_attempts` dict doesn't share state across `gunicorn` workers (4 by default in the compose file); known limitation, documented | needs multi-worker container |
|
||||||
| TC-DOCKER-04 | IM channels use internal Gateway auth | Verify Feishu/Slack/Telegram dispatchers attach the process-local internal auth header plus CSRF cookie/header when calling Gateway-compatible LangGraph APIs | needs `docker logs` |
|
| TC-DOCKER-04 | IM channels skip AuthMiddleware | Verify Feishu/Slack/Telegram dispatchers run in-container against `http://langgraph:2024` without going through nginx | needs `docker logs` |
|
||||||
| TC-DOCKER-05 | Reset credentials surfacing | `reset_admin` writes a 0600 credential file in `DEER_FLOW_HOME` instead of logging plaintext. The file-based behavior is validated by non-Docker reset tests, so the only Docker-specific gap is verifying the volume mount carries the file out to the host | needs container + host volume |
|
| TC-DOCKER-05 | Admin credentials surfacing | **Updated post-simplify** — was "log scrape", now "0600 credential file in `DEER_FLOW_HOME`". The file-based behavior is already validated by TC-1.1 + TC-UPG-13 on sg_dev (non-Docker), so the only Docker-specific gap is verifying the volume mount carries the file out to the host | needs container + host volume |
|
||||||
| TC-DOCKER-06 | Docker deploy uses Gateway embedded runtime | `./scripts/deploy.sh` produces a Gateway + frontend + nginx topology (no `langgraph` container); same auth flow as local `make dev` | needs `docker compose up` |
|
| TC-DOCKER-06 | Gateway-mode Docker deploy | `./scripts/deploy.sh --gateway` produces a 3-container topology (no `langgraph` container); same auth flow as standard mode | needs `docker compose --profile gateway` |
|
||||||
|
|
||||||
## Coverage already provided by non-Docker tests
|
## Coverage already provided by non-Docker tests
|
||||||
|
|
||||||
@@ -41,9 +41,9 @@ the test cases that ran on sg_dev or local:
|
|||||||
| TC-DOCKER-01 (volume persistence) | TC-REENT-01 on sg_dev (admin row survives gateway restart) — same SQLite file, just no container layer between |
|
| TC-DOCKER-01 (volume persistence) | TC-REENT-01 on sg_dev (admin row survives gateway restart) — same SQLite file, just no container layer between |
|
||||||
| TC-DOCKER-02 (session persistence) | TC-API-02/03/06 (cookie roundtrip), plus TC-REENT-04 (multi-cookie) — JWT verification is process-state-free, container restart is equivalent to `pkill uvicorn && uv run uvicorn` |
|
| TC-DOCKER-02 (session persistence) | TC-API-02/03/06 (cookie roundtrip), plus TC-REENT-04 (multi-cookie) — JWT verification is process-state-free, container restart is equivalent to `pkill uvicorn && uv run uvicorn` |
|
||||||
| TC-DOCKER-03 (per-worker rate limit) | TC-GW-04 + TC-REENT-09 (single-worker rate limit + 5min expiry). The cross-worker divergence is an architectural property of the in-memory dict; no auth code path differs |
|
| TC-DOCKER-03 (per-worker rate limit) | TC-GW-04 + TC-REENT-09 (single-worker rate limit + 5min expiry). The cross-worker divergence is an architectural property of the in-memory dict; no auth code path differs |
|
||||||
| TC-DOCKER-04 (IM channels use internal auth) | Code-level: `app/channels/manager.py` creates the `langgraph_sdk` client with `create_internal_auth_headers()` plus CSRF cookie/header, so channel workers do not rely on browser cookies |
|
| TC-DOCKER-04 (IM channels skip auth) | Code-level only: `app/channels/manager.py` uses `langgraph_sdk` directly with no cookie handling. The langgraph_auth handler is bypassed by going through SDK, not HTTP |
|
||||||
| TC-DOCKER-05 (credential surfacing) | `reset_admin` writes `.deer-flow/admin_initial_credentials.txt` with mode 0600 and logs only the path — the only Docker-unique step is whether the bind mount projects this path onto the host, which is a `docker compose` config check, not a runtime behavior change |
|
| TC-DOCKER-05 (credential surfacing) | TC-1.1 on sg_dev (file at `~/deer-flow/backend/.deer-flow/admin_initial_credentials.txt`, mode 0600, password 22 chars) — the only Docker-unique step is whether the bind mount projects this path onto the host, which is a `docker compose` config check, not a runtime behavior change |
|
||||||
| TC-DOCKER-06 (Gateway embedded runtime container) | Section 七 7.2 covered by TC-GW-01..05 + Section 二 (Gateway auth flow on sg_dev) — same Gateway code, container is just a packaging change |
|
| TC-DOCKER-06 (gateway-mode container) | Section 七 7.2 covered by TC-GW-01..05 + Section 二 (gateway-mode auth flow on sg_dev) — same Gateway code, container is just a packaging change |
|
||||||
|
|
||||||
## Reproduction steps when Docker becomes available
|
## Reproduction steps when Docker becomes available
|
||||||
|
|
||||||
@@ -72,6 +72,6 @@ Then run TC-DOCKER-01..06 from the test plan as written.
|
|||||||
about *container packaging* details (bind mounts, multi-worker, log
|
about *container packaging* details (bind mounts, multi-worker, log
|
||||||
collection), not about whether the auth code paths work.
|
collection), not about whether the auth code paths work.
|
||||||
- **TC-DOCKER-05 was updated in place** in `AUTH_TEST_PLAN.md` to reflect
|
- **TC-DOCKER-05 was updated in place** in `AUTH_TEST_PLAN.md` to reflect
|
||||||
the current reset flow (`reset_admin` → 0600 credentials file, no log leak).
|
the post-simplify reality (credentials file → 0600 file, no log leak).
|
||||||
The old "grep 'Password:' in docker logs" expectation would have failed
|
The old "grep 'Password:' in docker logs" expectation would have failed
|
||||||
silently and given a false sense of coverage.
|
silently and given a false sense of coverage.
|
||||||
|
|||||||
+156
-179
@@ -4,12 +4,10 @@
|
|||||||
|
|
||||||
| 模式 | 启动命令 | Auth 层 | 端口 |
|
| 模式 | 启动命令 | Auth 层 | 端口 |
|
||||||
|------|---------|---------|------|
|
|------|---------|---------|------|
|
||||||
| 标准模式 | `make dev` | Gateway AuthMiddleware(全量) | 2026 (nginx) |
|
| 标准模式 | `make dev` | Gateway AuthMiddleware + LangGraph auth | 2026 (nginx) |
|
||||||
|
| Gateway 模式 | `make dev-pro` | Gateway AuthMiddleware(全量) | 2026 (nginx) |
|
||||||
| 直连 Gateway | `cd backend && make gateway` | Gateway AuthMiddleware | 8001 |
|
| 直连 Gateway | `cd backend && make gateway` | Gateway AuthMiddleware | 8001 |
|
||||||
| 直连 LangGraph 兼容性 | 手动运行 LangGraph 工具链时使用 | LangGraph auth | 2024 |
|
| 直连 LangGraph | `cd backend && make dev` | LangGraph auth | 2024 |
|
||||||
|
|
||||||
`make dev`、Docker dev 和生产部署默认都运行 Gateway embedded runtime。
|
|
||||||
`app.gateway.langgraph_auth` 仅用于保留的直连 LangGraph 工具链 / Studio 兼容性测试,不是标准服务启动路径。
|
|
||||||
|
|
||||||
每种模式下都需执行以下测试。
|
每种模式下都需执行以下测试。
|
||||||
|
|
||||||
@@ -21,18 +19,19 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 清除已有数据
|
# 清除已有数据
|
||||||
rm -f backend/.deer-flow/data/deerflow.db
|
rm -f backend/.deer-flow/users.db
|
||||||
|
|
||||||
# 启动标准模式(Gateway embedded runtime)
|
# 选择模式启动
|
||||||
make dev
|
make dev # 标准模式
|
||||||
|
# 或
|
||||||
|
make dev-pro # Gateway 模式
|
||||||
```
|
```
|
||||||
|
|
||||||
**验证点:**
|
**验证点:**
|
||||||
- [ ] 控制台不输出 admin 邮箱或明文密码
|
- [ ] 控制台输出 admin 邮箱和随机密码
|
||||||
- [ ] 控制台提示 `First boot detected — no admin account exists.`
|
- [ ] 密码格式为 `secrets.token_urlsafe(16)` 的 22 字符字符串
|
||||||
- [ ] 控制台提示访问 `/setup` 完成 admin 创建
|
- [ ] 邮箱为 `admin@deerflow.dev`
|
||||||
- [ ] `GET /api/v1/auth/setup-status` 返回 `{"needs_setup": true}`
|
- [ ] 提示 `Change it after login: Settings -> Account`
|
||||||
- [ ] 前端访问 `/login` 会跳转 `/setup`
|
|
||||||
|
|
||||||
### 1.2 非首次启动
|
### 1.2 非首次启动
|
||||||
|
|
||||||
@@ -43,8 +42,7 @@ make dev
|
|||||||
|
|
||||||
**验证点:**
|
**验证点:**
|
||||||
- [ ] 控制台不输出密码
|
- [ ] 控制台不输出密码
|
||||||
- [ ] `GET /api/v1/auth/setup-status` 返回 `{"needs_setup": false}`
|
- [ ] 如果 admin 仍 `needs_setup=True`,控制台有 warning 提示
|
||||||
- [ ] 已登录用户如果 `needs_setup=True`,访问 workspace 会被引导到 `/setup` 完成改邮箱 / 改密码流程
|
|
||||||
|
|
||||||
### 1.3 环境变量配置
|
### 1.3 环境变量配置
|
||||||
|
|
||||||
@@ -57,7 +55,7 @@ make dev
|
|||||||
|
|
||||||
## 二、接口流程测试
|
## 二、接口流程测试
|
||||||
|
|
||||||
> 以下用 `BASE=http://localhost:2026` 为例。标准模式经 nginx 暴露此地址。
|
> 以下用 `BASE=http://localhost:2026` 为例。标准模式和 Gateway 模式都用此地址。
|
||||||
> 直连测试替换为对应端口。
|
> 直连测试替换为对应端口。
|
||||||
>
|
>
|
||||||
> **CSRF token 提取**:多处用到从 cookie jar 提取 CSRF token,统一使用:
|
> **CSRF token 提取**:多处用到从 cookie jar 提取 CSRF token,统一使用:
|
||||||
@@ -78,22 +76,19 @@ make dev
|
|||||||
curl -s $BASE/api/v1/auth/setup-status | jq .
|
curl -s $BASE/api/v1/auth/setup-status | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:** 返回 `{"needs_setup": false}`(admin 在启动时已自动创建,`count_users() > 0`)。仅在启动完成前的极短窗口内可能返回 `true`。
|
||||||
- 干净数据库且尚未初始化 admin:返回 `{"needs_setup": true}`
|
|
||||||
- 已存在 admin:返回 `{"needs_setup": false}`
|
|
||||||
|
|
||||||
#### TC-API-02: 首次初始化 Admin
|
#### TC-API-02: Admin 首次登录
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST $BASE/api/v1/auth/initialize \
|
curl -s -X POST $BASE/api/v1/auth/login/local \
|
||||||
-H "Content-Type: application/json" \
|
-d "username=admin@deerflow.dev&password=<控制台密码>" \
|
||||||
-d '{"email":"admin@example.com","password":"AdminPass1!"}' \
|
|
||||||
-c cookies.txt | jq .
|
-c cookies.txt | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- 状态码 201
|
- 状态码 200
|
||||||
- Body: `{"id": "...", "email": "admin@example.com", "system_role": "admin", "needs_setup": false}`
|
- Body: `{"expires_in": 604800, "needs_setup": true}`
|
||||||
- `cookies.txt` 包含 `access_token`(HttpOnly)和 `csrf_token`(非 HttpOnly)
|
- `cookies.txt` 包含 `access_token`(HttpOnly)和 `csrf_token`(非 HttpOnly)
|
||||||
|
|
||||||
#### TC-API-03: 获取当前用户
|
#### TC-API-03: 获取当前用户
|
||||||
@@ -102,9 +97,9 @@ curl -s -X POST $BASE/api/v1/auth/initialize \
|
|||||||
curl -s $BASE/api/v1/auth/me -b cookies.txt | jq .
|
curl -s $BASE/api/v1/auth/me -b cookies.txt | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** `{"id": "...", "email": "admin@example.com", "system_role": "admin", "needs_setup": false}`
|
**预期:** `{"id": "...", "email": "admin@deerflow.dev", "system_role": "admin", "needs_setup": true}`
|
||||||
|
|
||||||
#### TC-API-04: 改密码流程
|
#### TC-API-04: Setup 流程(改邮箱 + 改密码)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
|
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
|
||||||
@@ -112,36 +107,13 @@ curl -s -X POST $BASE/api/v1/auth/change-password \
|
|||||||
-b cookies.txt \
|
-b cookies.txt \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-CSRF-Token: $CSRF" \
|
-H "X-CSRF-Token: $CSRF" \
|
||||||
-d '{"current_password":"AdminPass1!","new_password":"NewPass123!"}' | jq .
|
-d '{"current_password":"<控制台密码>","new_password":"NewPass123!","new_email":"admin@example.com"}' | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- 状态码 200
|
- 状态码 200
|
||||||
- `{"message": "Password changed successfully"}`
|
- `{"message": "Password changed successfully"}`
|
||||||
- 再调 `/auth/me` 仍为 `admin@example.com`,`needs_setup` 仍为 `false`
|
- 再调 `/auth/me` 邮箱变为 `admin@example.com`,`needs_setup` 变为 `false`
|
||||||
|
|
||||||
#### TC-API-04a: reset_admin 后的 Setup 流程(改邮箱 + 改密码)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python -m app.gateway.auth.reset_admin --email admin@example.com
|
|
||||||
# 从 .deer-flow/admin_initial_credentials.txt 读取 reset 后密码
|
|
||||||
|
|
||||||
curl -s -X POST $BASE/api/v1/auth/login/local \
|
|
||||||
-d "username=admin@example.com&password=<凭据文件密码>" \
|
|
||||||
-c cookies.txt | jq .
|
|
||||||
|
|
||||||
CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')
|
|
||||||
curl -s -X POST $BASE/api/v1/auth/change-password \
|
|
||||||
-b cookies.txt \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-CSRF-Token: $CSRF" \
|
|
||||||
-d '{"current_password":"<凭据文件密码>","new_password":"AdminPass2!","new_email":"admin2@example.com"}' | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
**预期:**
|
|
||||||
- 登录返回 `{"expires_in": 604800, "needs_setup": true}`
|
|
||||||
- `change-password` 后 `/auth/me` 邮箱变为 `admin2@example.com`,`needs_setup` 变为 `false`
|
|
||||||
|
|
||||||
#### TC-API-05: 普通用户注册
|
#### TC-API-05: 普通用户注册
|
||||||
|
|
||||||
@@ -211,18 +183,20 @@ curl -s -X POST $BASE/api/threads/search \
|
|||||||
|
|
||||||
**预期:** 返回 0 或仅包含 user2 自己的 thread
|
**预期:** 返回 0 或仅包含 user2 自己的 thread
|
||||||
|
|
||||||
### 2.3 LangGraph-compatible Gateway 路由隔离
|
### 2.3 标准模式 LangGraph Server 隔离
|
||||||
|
|
||||||
#### TC-API-10: LangGraph-compatible 端点需要 cookie
|
> 仅在标准模式下测试。Gateway 模式不跑 LangGraph Server。
|
||||||
|
|
||||||
|
#### TC-API-10: LangGraph 端点需要 cookie
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 不带 cookie 访问 LangGraph-compatible 接口
|
# 不带 cookie 访问 LangGraph 接口
|
||||||
curl -s -w "%{http_code}" $BASE/api/langgraph/threads
|
curl -s -w "%{http_code}" $BASE/api/langgraph/threads
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 401
|
**预期:** 401
|
||||||
|
|
||||||
#### TC-API-11: LangGraph-compatible 路由带 cookie 可访问
|
#### TC-API-11: LangGraph 带 cookie 可访问
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s $BASE/api/langgraph/threads -b user1.txt | jq length
|
curl -s $BASE/api/langgraph/threads -b user1.txt | jq length
|
||||||
@@ -230,10 +204,10 @@ curl -s $BASE/api/langgraph/threads -b user1.txt | jq length
|
|||||||
|
|
||||||
**预期:** 200,返回 user1 的 thread 列表
|
**预期:** 200,返回 user1 的 thread 列表
|
||||||
|
|
||||||
#### TC-API-12: LangGraph-compatible 路由隔离 — 用户只看到自己的
|
#### TC-API-12: LangGraph 隔离 — 用户只看到自己的
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# user2 查 threads
|
# user2 查 LangGraph threads
|
||||||
curl -s $BASE/api/langgraph/threads -b user2.txt | jq length
|
curl -s $BASE/api/langgraph/threads -b user2.txt | jq length
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -519,7 +493,7 @@ curl -s -X POST $BASE/api/v1/auth/register \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 检查数据库
|
# 检查数据库
|
||||||
sqlite3 backend/.deer-flow/data/deerflow.db "SELECT email, password_hash FROM users LIMIT 3;"
|
sqlite3 backend/.deer-flow/users.db "SELECT email, password_hash FROM users LIMIT 3;"
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** `password_hash` 以 `$2b$` 开头(bcrypt 格式)
|
**预期:** `password_hash` 以 `$2b$` 开头(bcrypt 格式)
|
||||||
@@ -532,25 +506,24 @@ sqlite3 backend/.deer-flow/data/deerflow.db "SELECT email, password_hash FROM us
|
|||||||
|
|
||||||
### 4.1 首次登录流程
|
### 4.1 首次登录流程
|
||||||
|
|
||||||
#### TC-UI-01: 无 admin 时访问 workspace 跳转 setup
|
#### TC-UI-01: 访问首页跳转登录
|
||||||
|
|
||||||
1. 打开 `http://localhost:2026/workspace`
|
1. 打开 `http://localhost:2026/workspace`
|
||||||
2. **预期:** 自动跳转到 `/setup`
|
2. **预期:** 自动跳转到 `/login`
|
||||||
|
|
||||||
#### TC-UI-02: Setup 页面创建 admin
|
#### TC-UI-02: Login 页面
|
||||||
|
|
||||||
1. 输入 admin 邮箱、密码、确认密码
|
1. 输入 admin 邮箱和控制台密码
|
||||||
2. 点击 Create Admin Account
|
2. 点击 Login
|
||||||
|
3. **预期:** 跳转到 `/setup`(因为 `needs_setup=true`)
|
||||||
|
|
||||||
|
#### TC-UI-03: Setup 页面
|
||||||
|
|
||||||
|
1. 输入新邮箱、控制台密码(current)、新密码、确认密码
|
||||||
|
2. 点击 Complete Setup
|
||||||
3. **预期:** 跳转到 `/workspace`
|
3. **预期:** 跳转到 `/workspace`
|
||||||
4. 刷新页面不跳回 `/setup`
|
4. 刷新页面不跳回 `/setup`
|
||||||
|
|
||||||
#### TC-UI-03: 已初始化后 Login 页面
|
|
||||||
|
|
||||||
1. 退出登录后访问 `/login`
|
|
||||||
2. 输入 admin 邮箱和密码
|
|
||||||
3. 点击 Login
|
|
||||||
4. **预期:** 跳转到 `/workspace`
|
|
||||||
|
|
||||||
#### TC-UI-04: Setup 密码不匹配
|
#### TC-UI-04: Setup 密码不匹配
|
||||||
|
|
||||||
1. 新密码和确认密码不一致
|
1. 新密码和确认密码不一致
|
||||||
@@ -629,7 +602,7 @@ sqlite3 backend/.deer-flow/data/deerflow.db "SELECT email, password_hash FROM us
|
|||||||
#### TC-UI-15: reset_admin 后重新登录
|
#### TC-UI-15: reset_admin 后重新登录
|
||||||
|
|
||||||
1. 执行 `cd backend && python -m app.gateway.auth.reset_admin`
|
1. 执行 `cd backend && python -m app.gateway.auth.reset_admin`
|
||||||
2. 从 `.deer-flow/admin_initial_credentials.txt` 读取新密码并登录
|
2. 使用新密码登录
|
||||||
3. **预期:** 跳转到 `/setup` 页面(`needs_setup` 被重置为 true)
|
3. **预期:** 跳转到 `/setup` 页面(`needs_setup` 被重置为 true)
|
||||||
4. 旧 session 已失效
|
4. 旧 session 已失效
|
||||||
|
|
||||||
@@ -672,28 +645,18 @@ make install
|
|||||||
make dev
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
#### TC-UPG-01: 首次启动等待 admin 初始化
|
#### TC-UPG-01: 首次启动创建 admin
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 控制台不输出 admin 邮箱或随机密码
|
- [ ] 控制台输出 admin 邮箱(`admin@deerflow.dev`)和随机密码
|
||||||
- [ ] 访问 `/setup` 可创建第一个 admin
|
|
||||||
- [ ] 无报错,正常启动
|
- [ ] 无报错,正常启动
|
||||||
|
|
||||||
#### TC-UPG-02: 旧 Thread 迁移到 admin
|
#### TC-UPG-02: 旧 Thread 迁移到 admin
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建第一个 admin
|
|
||||||
curl -s -X POST http://localhost:2026/api/v1/auth/initialize \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email":"admin@example.com","password":"AdminPass1!"}' \
|
|
||||||
-c cookies.txt
|
|
||||||
|
|
||||||
# 重启一次:启动迁移只在已有 admin 的启动路径执行
|
|
||||||
make stop && make dev
|
|
||||||
|
|
||||||
# 登录 admin
|
# 登录 admin
|
||||||
curl -s -X POST http://localhost:2026/api/v1/auth/login/local \
|
curl -s -X POST http://localhost:2026/api/v1/auth/login/local \
|
||||||
-d "username=admin@example.com&password=AdminPass1!" \
|
-d "username=admin@deerflow.dev&password=<控制台密码>" \
|
||||||
-c cookies.txt
|
-c cookies.txt
|
||||||
|
|
||||||
# 查看 thread 列表
|
# 查看 thread 列表
|
||||||
@@ -707,8 +670,8 @@ curl -s -X POST http://localhost:2026/api/threads/search \
|
|||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 返回的 thread 数量 ≥ 旧版创建的数量
|
- [ ] 返回的 thread 数量 ≥ 旧版创建的数量
|
||||||
- [ ] 控制台日志有 `Migrated N orphan LangGraph thread(s) to admin`
|
- [ ] 控制台日志有 `Migrated N orphaned thread(s) to admin`
|
||||||
- [ ] 旧 thread 只对 admin 可见
|
- [ ] 每个 thread 的 `metadata.owner_id` 都已被设为 admin 的 ID
|
||||||
|
|
||||||
#### TC-UPG-03: 旧 Thread 内容完整
|
#### TC-UPG-03: 旧 Thread 内容完整
|
||||||
|
|
||||||
@@ -720,7 +683,7 @@ curl -s http://localhost:2026/api/threads/<old-thread-id> \
|
|||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] `metadata.title` 保留原值(如 `old-thread-1`)
|
- [ ] `metadata.title` 保留原值(如 `old-thread-1`)
|
||||||
- [ ] 响应不回显服务端保留的 `user_id` / `owner_id`
|
- [ ] `metadata.owner_id` 已填充
|
||||||
|
|
||||||
#### TC-UPG-04: 新用户看不到旧 Thread
|
#### TC-UPG-04: 新用户看不到旧 Thread
|
||||||
|
|
||||||
@@ -743,19 +706,18 @@ curl -s -X POST http://localhost:2026/api/threads/search \
|
|||||||
|
|
||||||
### 5.3 数据库 Schema 兼容
|
### 5.3 数据库 Schema 兼容
|
||||||
|
|
||||||
#### TC-UPG-05: 无 deerflow.db 时创建 schema 但不创建默认用户
|
#### TC-UPG-05: 无 users.db 时自动创建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls -la backend/.deer-flow/data/deerflow.db
|
ls -la backend/.deer-flow/users.db
|
||||||
sqlite3 backend/.deer-flow/data/deerflow.db "SELECT COUNT(*) FROM users;"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 文件存在,`sqlite3` 可查到 `users` 表含 `needs_setup`、`token_version` 列;未调用 `/initialize` 前用户数为 0
|
**预期:** 文件存在,`sqlite3` 可查到 `users` 表含 `needs_setup`、`token_version` 列
|
||||||
|
|
||||||
#### TC-UPG-06: deerflow.db WAL 模式
|
#### TC-UPG-06: users.db WAL 模式
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sqlite3 backend/.deer-flow/data/deerflow.db "PRAGMA journal_mode;"
|
sqlite3 backend/.deer-flow/users.db "PRAGMA journal_mode;"
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 返回 `wal`
|
**预期:** 返回 `wal`
|
||||||
@@ -806,9 +768,9 @@ make dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 服务正常启动(忽略 `deerflow.db`,无 auth 相关代码不报错)
|
- [ ] 服务正常启动(忽略 `users.db`,无 auth 相关代码不报错)
|
||||||
- [ ] 旧对话数据仍然可访问
|
- [ ] 旧对话数据仍然可访问
|
||||||
- [ ] `deerflow.db` 文件残留但不影响运行
|
- [ ] `users.db` 文件残留但不影响运行
|
||||||
|
|
||||||
#### TC-UPG-12: 再次升级到 auth 分支
|
#### TC-UPG-12: 再次升级到 auth 分支
|
||||||
|
|
||||||
@@ -819,47 +781,51 @@ make dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 识别已有 `deerflow.db`,不重新创建 admin
|
- [ ] 识别已有 `users.db`,不重新创建 admin
|
||||||
- [ ] 旧的 admin 账号仍可登录(如果回退期间未删 `deerflow.db`)
|
- [ ] 旧的 admin 账号仍可登录(如果回退期间未删 `users.db`)
|
||||||
|
|
||||||
### 5.7 Admin 初始化与 reset_admin
|
### 5.7 休眠 Admin(初始密码未使用/未更改)
|
||||||
|
|
||||||
> 首次启动不生成默认 admin,也不在日志输出密码。忘记密码时走 `reset_admin`,新密码写入 0600 凭据文件。
|
> 首次启动生成 admin + 随机密码,但运维未登录、未改密码。
|
||||||
|
> 密码只在首次启动的控制台闪过一次,后续启动不再显示。
|
||||||
|
|
||||||
#### TC-UPG-13: 未初始化 admin 时重启不创建默认账号
|
#### TC-UPG-13: 重启后自动重置密码并打印
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -f backend/.deer-flow/data/deerflow.db
|
# 首次启动,记录密码
|
||||||
|
rm -f backend/.deer-flow/users.db
|
||||||
make dev
|
make dev
|
||||||
|
# 控制台输出密码 P0,不登录
|
||||||
make stop
|
make stop
|
||||||
|
|
||||||
|
# 隔了几天,再次启动
|
||||||
make dev
|
make dev
|
||||||
curl -s $BASE/api/v1/auth/setup-status | jq .
|
# 控制台输出新密码 P1
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 控制台不输出密码
|
- [ ] 控制台输出 `Admin account setup incomplete — password reset`
|
||||||
- [ ] `setup-status` 仍为 `{"needs_setup": true}`
|
- [ ] 输出新密码 P1(P0 已失效)
|
||||||
- [ ] 访问 `/setup` 仍可创建第一个 admin
|
- [ ] 用 P1 可以登录,P0 不可以
|
||||||
|
- [ ] 登录后 `needs_setup=true`,跳转 `/setup`
|
||||||
|
- [ ] `token_version` 递增(旧 session 如有也失效)
|
||||||
|
|
||||||
#### TC-UPG-14: 密码丢失 — reset_admin 写入凭据文件
|
#### TC-UPG-14: 密码丢失 — 无需 CLI,重启即可
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m app.gateway.auth.reset_admin --email admin@example.com
|
# 忘记了控制台密码 → 直接重启服务
|
||||||
ls -la backend/.deer-flow/admin_initial_credentials.txt
|
make stop && make dev
|
||||||
cat backend/.deer-flow/admin_initial_credentials.txt
|
# 控制台自动输出新密码
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 命令行只输出凭据文件路径,不输出明文密码
|
- [ ] 无需 `reset_admin`,重启服务即可拿到新密码
|
||||||
- [ ] 凭据文件权限为 `0600`
|
- [ ] `reset_admin` CLI 仍然可用作手动备选方案
|
||||||
- [ ] 凭据文件包含 email + password 行
|
|
||||||
- [ ] 该用户下次登录返回 `needs_setup=true`
|
|
||||||
|
|
||||||
#### TC-UPG-15: 未初始化 admin 期间普通用户注册策略边界
|
#### TC-UPG-15: 休眠 admin 期间普通用户注册
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# admin 尚不存在,普通用户尝试注册
|
# admin 存在但从未登录,普通用户先注册
|
||||||
curl -s -X POST $BASE/api/v1/auth/register \
|
curl -s -X POST $BASE/api/v1/auth/register \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"email":"earlybird@example.com","password":"EarlyPass1!"}' \
|
-d '{"email":"earlybird@example.com","password":"EarlyPass1!"}' \
|
||||||
@@ -867,11 +833,11 @@ curl -s -X POST $BASE/api/v1/auth/register \
|
|||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 当前代码允许注册普通用户并自动登录(201,角色为 `user`)
|
- [ ] 注册成功(201),角色为 `user`
|
||||||
- [ ] 但 `setup-status` 仍为 `{"needs_setup": true}`,因为 admin 仍不存在
|
- [ ] 无法提权为 admin
|
||||||
- [ ] 这是一个产品策略边界:若要求“必须先有 admin”,需要在 `/register` 增加 admin-exists gate
|
- [ ] 普通用户的数据与 admin 隔离
|
||||||
|
|
||||||
#### TC-UPG-16: 普通用户数据与后续 admin 隔离
|
#### TC-UPG-16: 休眠 admin 不影响后续操作
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 普通用户正常创建 thread、发消息
|
# 普通用户正常创建 thread、发消息
|
||||||
@@ -883,13 +849,14 @@ curl -s -X POST $BASE/api/threads \
|
|||||||
-d '{"metadata":{}}' | jq .thread_id
|
-d '{"metadata":{}}' | jq .thread_id
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 普通用户正常创建 thread;后续 admin 创建后,搜索不到该普通用户 thread
|
**预期:** 正常创建,不受休眠 admin 影响
|
||||||
|
|
||||||
#### TC-UPG-17: reset_admin 后完成 Setup
|
#### TC-UPG-17: 休眠 admin 最终完成 Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 运维终于登录
|
||||||
curl -s -X POST $BASE/api/v1/auth/login/local \
|
curl -s -X POST $BASE/api/v1/auth/login/local \
|
||||||
-d "username=admin@example.com&password=<凭据文件密码>" \
|
-d "username=admin@deerflow.dev&password=<P0或P1>" \
|
||||||
-c admin.txt | jq .needs_setup
|
-c admin.txt | jq .needs_setup
|
||||||
# 预期: true
|
# 预期: true
|
||||||
|
|
||||||
@@ -899,7 +866,7 @@ curl -s -X POST $BASE/api/v1/auth/change-password \
|
|||||||
-b admin.txt \
|
-b admin.txt \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-CSRF-Token: $CSRF" \
|
-H "X-CSRF-Token: $CSRF" \
|
||||||
-d '{"current_password":"<凭据文件密码>","new_password":"AdminFinal1!","new_email":"admin@real.com"}' \
|
-d '{"current_password":"<密码>","new_password":"AdminFinal1!","new_email":"admin@real.com"}' \
|
||||||
-c admin.txt
|
-c admin.txt
|
||||||
|
|
||||||
# 验证
|
# 验证
|
||||||
@@ -909,7 +876,7 @@ curl -s $BASE/api/v1/auth/me -b admin.txt | jq '{email, needs_setup}'
|
|||||||
**预期:**
|
**预期:**
|
||||||
- [ ] `email` 变为 `admin@real.com`
|
- [ ] `email` 变为 `admin@real.com`
|
||||||
- [ ] `needs_setup` 变为 `false`
|
- [ ] `needs_setup` 变为 `false`
|
||||||
- [ ] 后续登录使用新密码
|
- [ ] 后续重启控制台不再有 warning
|
||||||
|
|
||||||
#### TC-UPG-18: 长期未用后 JWT 密钥轮换
|
#### TC-UPG-18: 长期未用后 JWT 密钥轮换
|
||||||
|
|
||||||
@@ -923,8 +890,8 @@ make stop && make dev
|
|||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] 服务正常启动
|
- [ ] 服务正常启动
|
||||||
- [ ] 账号密码仍可登录(密码存在 DB,与 JWT 密钥无关)
|
- [ ] 旧密码仍可登录(密码存在 DB,与 JWT 密钥无关)
|
||||||
- [ ] 旧的 JWT token 失效(密钥变了签名不匹配)
|
- [ ] 旧的 JWT token 失效(密钥变了签名不匹配)— 但因为从未登录过也没有旧 token
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -943,7 +910,7 @@ for i in 1 2 3; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# 检查 admin 数量
|
# 检查 admin 数量
|
||||||
sqlite3 backend/.deer-flow/data/deerflow.db \
|
sqlite3 backend/.deer-flow/users.db \
|
||||||
"SELECT COUNT(*) FROM users WHERE system_role='admin';"
|
"SELECT COUNT(*) FROM users WHERE system_role='admin';"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1088,7 +1055,7 @@ curl -s -X POST $BASE/api/v1/auth/register \
|
|||||||
wait
|
wait
|
||||||
|
|
||||||
# 检查用户数
|
# 检查用户数
|
||||||
sqlite3 backend/.deer-flow/data/deerflow.db \
|
sqlite3 backend/.deer-flow/users.db \
|
||||||
"SELECT COUNT(*) FROM users WHERE email='race@example.com';"
|
"SELECT COUNT(*) FROM users WHERE email='race@example.com';"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1198,16 +1165,13 @@ curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \
|
|||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
python -m app.gateway.auth.reset_admin
|
python -m app.gateway.auth.reset_admin
|
||||||
cp .deer-flow/admin_initial_credentials.txt /tmp/deerflow-reset-p1.txt
|
# 记录密码 P1
|
||||||
P1=$(awk -F': ' '/^password:/ {print $2}' /tmp/deerflow-reset-p1.txt)
|
|
||||||
|
|
||||||
python -m app.gateway.auth.reset_admin
|
python -m app.gateway.auth.reset_admin
|
||||||
cp .deer-flow/admin_initial_credentials.txt /tmp/deerflow-reset-p2.txt
|
# 记录密码 P2
|
||||||
P2=$(awk -F': ' '/^password:/ {print $2}' /tmp/deerflow-reset-p2.txt)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:**
|
**预期:**
|
||||||
- [ ] `.deer-flow/admin_initial_credentials.txt` 每次都会被重写,文件权限为 `0600`
|
|
||||||
- [ ] P1 ≠ P2(每次生成新随机密码)
|
- [ ] P1 ≠ P2(每次生成新随机密码)
|
||||||
- [ ] P1 不可用,只有 P2 有效
|
- [ ] P1 不可用,只有 P2 有效
|
||||||
- [ ] `token_version` 递增了 2
|
- [ ] `token_version` 递增了 2
|
||||||
@@ -1232,11 +1196,21 @@ P2=$(awk -F': ' '/^password:/ {print $2}' /tmp/deerflow-reset-p2.txt)
|
|||||||
## 七、模式差异测试
|
## 七、模式差异测试
|
||||||
|
|
||||||
> 以下用 `GW=http://localhost:8001` 表示直连 Gateway,`BASE=http://localhost:2026` 表示经 nginx。
|
> 以下用 `GW=http://localhost:8001` 表示直连 Gateway,`BASE=http://localhost:2026` 表示经 nginx。
|
||||||
> 标准启动命令:`make dev`(或 `./scripts/serve.sh --dev`)。
|
> Gateway 模式启动命令:`make dev-pro`(或 `./scripts/serve.sh --dev --gateway`)。
|
||||||
|
|
||||||
### 7.1 标准启动模式
|
### 7.1 标准模式独有
|
||||||
|
|
||||||
#### TC-MODE-01: Gateway AuthMiddleware 的 token_version 检查
|
> 启动命令:`make dev`(或 `./scripts/serve.sh --dev`)
|
||||||
|
|
||||||
|
#### TC-MODE-01: LangGraph Server 独立运行,需 cookie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 无 cookie 访问 LangGraph
|
||||||
|
curl -s -w "%{http_code}" -o /dev/null $BASE/api/langgraph/threads/search
|
||||||
|
# 预期: 403(LangGraph auth handler 拒绝)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TC-MODE-02: LangGraph auth 的 token_version 检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 登录拿 cookie
|
# 登录拿 cookie
|
||||||
@@ -1249,9 +1223,9 @@ curl -s -X POST $BASE/api/v1/auth/change-password \
|
|||||||
-b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \
|
-b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \
|
||||||
-d '{"current_password":"正确密码","new_password":"NewPass1!"}' -c new_cookies.txt
|
-d '{"current_password":"正确密码","new_password":"NewPass1!"}' -c new_cookies.txt
|
||||||
|
|
||||||
# 用旧 cookie 访问 LangGraph-compatible 路由
|
# 用旧 cookie 访问 LangGraph
|
||||||
curl -s -w "%{http_code}" $BASE/api/langgraph/threads/search -b cookies.txt
|
curl -s -w "%{http_code}" $BASE/api/langgraph/threads/search -b cookies.txt
|
||||||
# 预期: 401(token_version 不匹配)
|
# 预期: 403(token_version 不匹配)
|
||||||
|
|
||||||
# 用新 cookie 访问
|
# 用新 cookie 访问
|
||||||
CSRF2=$(grep csrf_token new_cookies.txt | awk '{print $NF}')
|
CSRF2=$(grep csrf_token new_cookies.txt | awk '{print $NF}')
|
||||||
@@ -1260,7 +1234,7 @@ curl -s -w "%{http_code}" -X POST $BASE/api/langgraph/threads/search \
|
|||||||
# 预期: 200
|
# 预期: 200
|
||||||
```
|
```
|
||||||
|
|
||||||
#### TC-MODE-02: Gateway owner filter 隔离
|
#### TC-MODE-03: LangGraph auth 的 owner filter 隔离
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# user1 创建 thread
|
# user1 创建 thread
|
||||||
@@ -1285,9 +1259,18 @@ print('OK: user2 sees', len(threads), 'threads, none belong to user1')
|
|||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### TC-MODE-03: 所有请求经 AuthMiddleware
|
### 7.2 Gateway 模式独有
|
||||||
|
|
||||||
|
> 启动命令:`make dev-pro`(或 `./scripts/serve.sh --dev --gateway`)
|
||||||
|
> 无 LangGraph Server 进程,agent runtime 嵌入 Gateway。
|
||||||
|
|
||||||
|
#### TC-MODE-04: 所有请求经 AuthMiddleware
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 确认 LangGraph Server 未运行
|
||||||
|
curl -s -w "%{http_code}" -o /dev/null http://localhost:2024/ok
|
||||||
|
# 预期: 000(连接被拒)
|
||||||
|
|
||||||
# Gateway API 受保护
|
# Gateway API 受保护
|
||||||
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
|
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
|
||||||
# 预期: 401
|
# 预期: 401
|
||||||
@@ -1298,7 +1281,7 @@ curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads/searc
|
|||||||
# 预期: 401
|
# 预期: 401
|
||||||
```
|
```
|
||||||
|
|
||||||
#### TC-MODE-04: 标准模式下完整 auth 流程
|
#### TC-MODE-05: Gateway 模式下完整 auth 流程
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 登录
|
# 登录
|
||||||
@@ -1313,7 +1296,7 @@ curl -s -X POST $BASE/api/langgraph/threads \
|
|||||||
-d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])"
|
-d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])"
|
||||||
# 预期: 返回 thread_id
|
# 预期: 返回 thread_id
|
||||||
|
|
||||||
# CSRF 保护(CSRFMiddleware 覆盖所有 Gateway 路由)
|
# CSRF 保护(Gateway 模式下 CSRFMiddleware 直接覆盖所有路由)
|
||||||
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads \
|
curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads \
|
||||||
-b cookies.txt -H "Content-Type: application/json" -d '{"metadata":{}}'
|
-b cookies.txt -H "Content-Type: application/json" -d '{"metadata":{}}'
|
||||||
# 预期: 403(CSRF token missing)
|
# 预期: 403(CSRF token missing)
|
||||||
@@ -1341,8 +1324,7 @@ done
|
|||||||
```bash
|
```bash
|
||||||
GW=http://localhost:8001
|
GW=http://localhost:8001
|
||||||
|
|
||||||
for path in /health /api/v1/auth/setup-status /api/v1/auth/login/local \
|
for path in /health /api/v1/auth/setup-status /api/v1/auth/login/local /api/v1/auth/register; do
|
||||||
/api/v1/auth/register /api/v1/auth/initialize /api/v1/auth/logout; do
|
|
||||||
echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)"
|
echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)"
|
||||||
done
|
done
|
||||||
# 预期: 200 或 405/422(方法不对但不是 401)
|
# 预期: 200 或 405/422(方法不对但不是 401)
|
||||||
@@ -1412,14 +1394,14 @@ done
|
|||||||
|
|
||||||
### 7.4 Docker 部署
|
### 7.4 Docker 部署
|
||||||
|
|
||||||
> 启动命令:`./scripts/deploy.sh`
|
> 启动命令:`./scripts/deploy.sh`(标准)或 `./scripts/deploy.sh --gateway`(Gateway 模式)
|
||||||
> Docker Compose 文件:`docker/docker-compose.yaml`
|
> Docker Compose 文件:`docker/docker-compose.yaml`
|
||||||
>
|
>
|
||||||
> 前置条件:
|
> 前置条件:
|
||||||
> - `.env` 中设置 `AUTH_JWT_SECRET`(否则每次容器重启 session 全部失效)
|
> - `.env` 中设置 `AUTH_JWT_SECRET`(否则每次容器重启 session 全部失效)
|
||||||
> - `DEER_FLOW_HOME` 挂载到宿主机目录(持久化 `deerflow.db`)
|
> - `DEER_FLOW_HOME` 挂载到宿主机目录(持久化 `users.db`)
|
||||||
|
|
||||||
#### TC-DOCKER-01: deerflow.db 通过 volume 持久化
|
#### TC-DOCKER-01: users.db 通过 volume 持久化
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动容器
|
# 启动容器
|
||||||
@@ -1434,13 +1416,13 @@ curl -s -X POST $BASE/api/v1/auth/register \
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"email":"docker-test@example.com","password":"DockerTest1!"}' -w "\nHTTP %{http_code}"
|
-d '{"email":"docker-test@example.com","password":"DockerTest1!"}' -w "\nHTTP %{http_code}"
|
||||||
|
|
||||||
# 检查宿主机上的 deerflow.db
|
# 检查宿主机上的 users.db
|
||||||
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/data/deerflow.db
|
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/users.db
|
||||||
sqlite3 ${DEER_FLOW_HOME:-backend/.deer-flow}/data/deerflow.db \
|
sqlite3 ${DEER_FLOW_HOME:-backend/.deer-flow}/users.db \
|
||||||
"SELECT email FROM users WHERE email='docker-test@example.com';"
|
"SELECT email FROM users WHERE email='docker-test@example.com';"
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** deerflow.db 在宿主机 `DEER_FLOW_HOME` 目录中,查询可见刚注册的用户。
|
**预期:** users.db 在宿主机 `DEER_FLOW_HOME` 目录中,查询可见刚注册的用户。
|
||||||
|
|
||||||
#### TC-DOCKER-02: 重启容器后 session 保持
|
#### TC-DOCKER-02: 重启容器后 session 保持
|
||||||
|
|
||||||
@@ -1484,24 +1466,22 @@ done
|
|||||||
|
|
||||||
**已知限制:** In-process rate limiter 不跨 worker 共享。生产环境如需精确限速,需要 Redis 等外部存储。
|
**已知限制:** In-process rate limiter 不跨 worker 共享。生产环境如需精确限速,需要 Redis 等外部存储。
|
||||||
|
|
||||||
#### TC-DOCKER-04: IM 渠道使用内部认证
|
#### TC-DOCKER-04: IM 渠道不经过 auth
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# IM 渠道(Feishu/Slack/Telegram)在 gateway 容器内部通过 LangGraph SDK 调 Gateway
|
# IM 渠道(Feishu/Slack/Telegram)在 gateway 容器内部通过 LangGraph SDK 通信
|
||||||
# 请求携带 process-local internal auth header,并带匹配的 CSRF cookie/header
|
# 不走 nginx,不经过 AuthMiddleware
|
||||||
|
|
||||||
# 验证方式:检查 gateway 日志中 channel manager 的请求不包含 auth 错误
|
# 验证方式:检查 gateway 日志中 channel manager 的请求不包含 auth 错误
|
||||||
docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10
|
docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 无 auth 相关错误。渠道不依赖浏览器 cookie;服务端通过内部认证头把请求归入 `default` 用户桶。
|
**预期:** 无 auth 相关错误。渠道通过 `langgraph-sdk` 直连 LangGraph Server(`http://langgraph:2024`),不走 auth 层。
|
||||||
|
|
||||||
#### TC-DOCKER-05: reset_admin 密码写入 0600 凭证文件(不再走日志)
|
#### TC-DOCKER-05: admin 密码写入 0600 凭证文件(不再走日志)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 首次启动不会自动生成 admin 密码。先重置已有 admin,凭据文件写在挂载到宿主机的 DEER_FLOW_HOME 下。
|
# 凭证文件写在挂载到宿主机的 DEER_FLOW_HOME 下
|
||||||
docker exec deer-flow-gateway python -m app.gateway.auth.reset_admin --email docker-test@example.com
|
|
||||||
|
|
||||||
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt
|
ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt
|
||||||
# 预期文件权限: -rw------- (0600)
|
# 预期文件权限: -rw------- (0600)
|
||||||
|
|
||||||
@@ -1521,26 +1501,25 @@ docker logs deer-flow-gateway 2>&1 | grep -iE "Password: .{15,}" && echo "FAIL:
|
|||||||
- 容器日志输出**路径**(不是密码本身),符合 CodeQL `py/clear-text-logging-sensitive-data` 规则
|
- 容器日志输出**路径**(不是密码本身),符合 CodeQL `py/clear-text-logging-sensitive-data` 规则
|
||||||
- `grep "Password:"` 在日志中**应当无匹配**(旧行为已废弃,simplify pass 移除了日志泄露路径)
|
- `grep "Password:"` 在日志中**应当无匹配**(旧行为已废弃,simplify pass 移除了日志泄露路径)
|
||||||
|
|
||||||
#### TC-DOCKER-06: Docker 部署
|
#### TC-DOCKER-06: Gateway 模式 Docker 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 标准 Docker 模式:runtime 嵌入 gateway 容器
|
# Gateway 模式:无 langgraph 容器
|
||||||
./scripts/deploy.sh
|
./scripts/deploy.sh --gateway
|
||||||
sleep 15
|
sleep 15
|
||||||
|
|
||||||
# 确认 gateway 容器存在
|
# 确认 langgraph 容器不存在
|
||||||
docker ps --filter name=deer-flow-gateway --format '{{.Names}}'
|
docker ps --filter name=deer-flow-langgraph --format '{{.Names}}' | wc -l
|
||||||
# 预期: deer-flow-gateway
|
# 预期: 0
|
||||||
|
|
||||||
# auth 流程正常:未登录受保护接口返回 401
|
# auth 流程正常
|
||||||
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
|
curl -s -w "%{http_code}" -o /dev/null $BASE/api/models
|
||||||
# 预期: 401
|
# 预期: 401
|
||||||
|
|
||||||
curl -s -X POST $BASE/api/v1/auth/initialize \
|
curl -s -X POST $BASE/api/v1/auth/login/local \
|
||||||
-H "Content-Type: application/json" \
|
-d "username=admin@deerflow.dev&password=<日志密码>" \
|
||||||
-d '{"email":"admin@example.com","password":"AdminPass1!"}' \
|
|
||||||
-c cookies.txt -w "\nHTTP %{http_code}"
|
-c cookies.txt -w "\nHTTP %{http_code}"
|
||||||
# 预期: 201
|
# 预期: 200
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.4 补充边界用例
|
### 7.4 补充边界用例
|
||||||
@@ -1608,15 +1587,13 @@ curl -s -D - -X POST $BASE/api/v1/auth/login/local \
|
|||||||
#### TC-EDGE-05: HTTP 无 max_age / HTTPS 有 max_age
|
#### TC-EDGE-05: HTTP 无 max_age / HTTPS 有 max_age
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GW=http://localhost:8001
|
|
||||||
|
|
||||||
# HTTP
|
# HTTP
|
||||||
curl -s -D - -X POST $GW/api/v1/auth/login/local \
|
curl -s -D - -X POST $BASE/api/v1/auth/login/local \
|
||||||
-d "username=admin@example.com&password=正确密码" 2>/dev/null \
|
-d "username=admin@example.com&password=正确密码" 2>/dev/null \
|
||||||
| grep "access_token=" | grep -oi "max-age=[0-9]*" || echo "NO max-age (HTTP session cookie)"
|
| grep "access_token=" | grep -oi "max-age=[0-9]*" || echo "NO max-age (HTTP session cookie)"
|
||||||
|
|
||||||
# HTTPS:直连 Gateway 才能用 X-Forwarded-Proto 模拟 HTTPS;nginx 会覆盖该 header
|
# HTTPS
|
||||||
curl -s -D - -X POST $GW/api/v1/auth/login/local \
|
curl -s -D - -X POST $BASE/api/v1/auth/login/local \
|
||||||
-H "X-Forwarded-Proto: https" \
|
-H "X-Forwarded-Proto: https" \
|
||||||
-d "username=admin@example.com&password=正确密码" 2>/dev/null \
|
-d "username=admin@example.com&password=正确密码" 2>/dev/null \
|
||||||
| grep "access_token=" | grep -oi "max-age=[0-9]*"
|
| grep "access_token=" | grep -oi "max-age=[0-9]*"
|
||||||
@@ -1735,10 +1712,10 @@ curl -s -X POST $BASE/api/threads \
|
|||||||
-b cookies.txt \
|
-b cookies.txt \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-CSRF-Token: $CSRF" \
|
-H "X-CSRF-Token: $CSRF" \
|
||||||
-d '{"metadata":{"owner_id":"victim-user-id","user_id":"victim-user-id"}}' | jq .metadata
|
-d '{"metadata":{"owner_id":"victim-user-id"}}' | jq .metadata.owner_id
|
||||||
```
|
```
|
||||||
|
|
||||||
**预期:** 返回的 `metadata` 不包含 `owner_id` 或 `user_id`。真实所有权写入 `threads_meta.user_id`,不从客户端 metadata 接收,也不通过 metadata 回显。
|
**预期:** 返回的 `metadata.owner_id` 应为当前登录用户的 ID,不是请求中注入的 `victim-user-id`。服务端应覆盖客户端提供的 `user_id`。
|
||||||
|
|
||||||
#### 7.5.6 HTTP Method 探测
|
#### 7.5.6 HTTP Method 探测
|
||||||
|
|
||||||
@@ -1819,6 +1796,6 @@ cd backend && PYTHONPATH=. uv run pytest \
|
|||||||
# 核心接口冒烟
|
# 核心接口冒烟
|
||||||
curl -s $BASE/health # 200
|
curl -s $BASE/health # 200
|
||||||
curl -s $BASE/api/models # 401 (无 cookie)
|
curl -s $BASE/api/models # 401 (无 cookie)
|
||||||
curl -s $BASE/api/v1/auth/setup-status # 200
|
curl -s -X POST $BASE/api/v1/auth/setup-status # 200
|
||||||
curl -s $BASE/api/v1/auth/me -b cookies.txt # 200 (有 cookie)
|
curl -s $BASE/api/v1/auth/me -b cookies.txt # 200 (有 cookie)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,16 +2,13 @@
|
|||||||
|
|
||||||
DeerFlow 内置了认证模块。本文档面向从无认证版本升级的用户。
|
DeerFlow 内置了认证模块。本文档面向从无认证版本升级的用户。
|
||||||
|
|
||||||
完整设计见 [AUTH_DESIGN.md](AUTH_DESIGN.md)。
|
|
||||||
|
|
||||||
## 核心概念
|
## 核心概念
|
||||||
|
|
||||||
认证模块采用**始终强制**策略:
|
认证模块采用**始终强制**策略:
|
||||||
|
|
||||||
- 首次启动时不会自动创建账号;首次访问 `/setup` 时由操作者创建第一个 admin 账号
|
- 首次启动时自动创建 admin 账号,随机密码打印到控制台日志
|
||||||
- 认证从一开始就是强制的,无竞争窗口
|
- 认证从一开始就是强制的,无竞争窗口
|
||||||
- 已有 admin 后,服务启动时会把历史对话(升级前创建且缺少 `user_id` 的 thread)迁移到 admin 名下
|
- 历史对话(升级前创建的 thread)自动迁移到 admin 名下
|
||||||
- 新数据按用户隔离:thread、workspace/uploads/outputs、memory、自定义 agent 都归属当前用户
|
|
||||||
|
|
||||||
## 升级步骤
|
## 升级步骤
|
||||||
|
|
||||||
@@ -28,41 +25,39 @@ cd backend && make install
|
|||||||
make dev
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
如果没有 admin 账号,控制台只会提示:
|
控制台会输出:
|
||||||
|
|
||||||
```
|
```
|
||||||
============================================================
|
============================================================
|
||||||
First boot detected — no admin account exists.
|
Admin account created on first boot
|
||||||
Visit /setup to complete admin account creation.
|
Email: admin@deerflow.dev
|
||||||
|
Password: aB3xK9mN_pQ7rT2w
|
||||||
|
Change it after login: Settings → Account
|
||||||
============================================================
|
============================================================
|
||||||
```
|
```
|
||||||
|
|
||||||
首次启动不会在日志里打印随机密码,也不会写入默认 admin。这样避免启动日志泄露凭据,也避免在操作者创建账号前出现可被猜测的默认身份。
|
如果未登录就重启了服务,不用担心——只要 setup 未完成,每次启动都会重置密码并重新打印到控制台。
|
||||||
|
|
||||||
### 3. 创建 admin
|
### 3. 登录
|
||||||
|
|
||||||
访问 `http://localhost:2026/setup`,填写邮箱和密码创建第一个 admin 账号。创建成功后会自动登录并进入 workspace。
|
访问 `http://localhost:2026/login`,使用控制台输出的邮箱和密码登录。
|
||||||
|
|
||||||
如果这是从无认证版本升级,创建 admin 后重启一次服务,让启动迁移把缺少 `user_id` 的历史 thread 归属到 admin。
|
### 4. 修改密码
|
||||||
|
|
||||||
### 4. 登录
|
登录后进入 Settings → Account → Change Password。
|
||||||
|
|
||||||
后续访问 `http://localhost:2026/login`,使用已创建的邮箱和密码登录。
|
|
||||||
|
|
||||||
### 5. 添加用户(可选)
|
### 5. 添加用户(可选)
|
||||||
|
|
||||||
其他用户通过 `/login` 页面注册,自动获得 **user** 角色。每个用户只能看到自己的对话、上传文件、输出文件、memory 和自定义 agent。
|
其他用户通过 `/login` 页面注册,自动获得 **user** 角色。每个用户只能看到自己的对话。
|
||||||
|
|
||||||
## 安全机制
|
## 安全机制
|
||||||
|
|
||||||
| 机制 | 说明 |
|
| 机制 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| JWT HttpOnly Cookie | Token 不暴露给 JavaScript,防止 XSS 窃取 |
|
| JWT HttpOnly Cookie | Token 不暴露给 JavaScript,防止 XSS 窃取 |
|
||||||
| CSRF Double Submit Cookie | 受保护的 POST/PUT/PATCH/DELETE 请求需携带 `X-CSRF-Token`;登录/注册/初始化/登出走 auth 端点 Origin 校验 |
|
| CSRF Double Submit Cookie | 所有 POST/PUT/DELETE 请求需携带 `X-CSRF-Token` |
|
||||||
| bcrypt 密码哈希 | 密码不以明文存储 |
|
| bcrypt 密码哈希 | 密码不以明文存储 |
|
||||||
| Thread owner filter | `threads_meta.user_id` 由服务端认证上下文写入,搜索、读取、更新、删除默认按当前用户过滤 |
|
| 多租户隔离 | 用户只能访问自己的 thread |
|
||||||
| 文件系统隔离 | 线程数据写入 `{base_dir}/users/{user_id}/threads/{thread_id}/user-data/`,sandbox 内统一映射为 `/mnt/user-data/` |
|
|
||||||
| Memory / agent 隔离 | 用户 memory 和自定义 agent 写入 `{base_dir}/users/{user_id}/...`;旧共享 agent 只作为只读兼容回退 |
|
|
||||||
| HTTPS 自适应 | 检测 `x-forwarded-proto`,自动设置 `Secure` cookie 标志 |
|
| HTTPS 自适应 | 检测 `x-forwarded-proto`,自动设置 `Secure` cookie 标志 |
|
||||||
|
|
||||||
## 常见操作
|
## 常见操作
|
||||||
@@ -79,27 +74,23 @@ python -m app.gateway.auth.reset_admin
|
|||||||
python -m app.gateway.auth.reset_admin --email user@example.com
|
python -m app.gateway.auth.reset_admin --email user@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
会把新的随机密码写入 `.deer-flow/admin_initial_credentials.txt`,文件权限为 `0600`。命令行只输出文件路径,不输出明文密码。
|
会输出新的随机密码。
|
||||||
|
|
||||||
### 完全重置
|
### 完全重置
|
||||||
|
|
||||||
删除统一 SQLite 数据库,重启后重新访问 `/setup` 创建新 admin:
|
删除用户数据库,重启后自动创建新 admin:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -f backend/.deer-flow/data/deerflow.db
|
rm -f backend/.deer-flow/users.db
|
||||||
# 重启服务后访问 http://localhost:2026/setup
|
# 重启服务,控制台输出新密码
|
||||||
```
|
```
|
||||||
|
|
||||||
## 数据存储
|
## 数据存储
|
||||||
|
|
||||||
| 文件 | 内容 |
|
| 文件 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `.deer-flow/data/deerflow.db` | 统一 SQLite 数据库(users、threads_meta、runs、feedback 等应用数据) |
|
| `.deer-flow/users.db` | SQLite 用户数据库(密码哈希、角色) |
|
||||||
| `.deer-flow/users/{user_id}/threads/{thread_id}/user-data/` | 用户线程的 workspace、uploads、outputs |
|
| `.env` 中的 `AUTH_JWT_SECRET` | JWT 签名密钥(未设置时自动生成临时密钥,重启后 session 失效) |
|
||||||
| `.deer-flow/users/{user_id}/memory.json` | 用户级 memory |
|
|
||||||
| `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory |
|
|
||||||
| `.deer-flow/admin_initial_credentials.txt` | `reset_admin` 生成的新凭据文件(0600,读完应删除) |
|
|
||||||
| `.env` 中的 `AUTH_JWT_SECRET` | JWT 签名密钥(未设置时自动生成并持久化到 `.deer-flow/.jwt_secret`,重启后 session 保持) |
|
|
||||||
|
|
||||||
### 生产环境建议
|
### 生产环境建议
|
||||||
|
|
||||||
@@ -120,21 +111,19 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|||||||
| `/api/v1/auth/me` | GET | 获取当前用户信息 |
|
| `/api/v1/auth/me` | GET | 获取当前用户信息 |
|
||||||
| `/api/v1/auth/change-password` | POST | 修改密码 |
|
| `/api/v1/auth/change-password` | POST | 修改密码 |
|
||||||
| `/api/v1/auth/setup-status` | GET | 检查 admin 是否存在 |
|
| `/api/v1/auth/setup-status` | GET | 检查 admin 是否存在 |
|
||||||
| `/api/v1/auth/initialize` | POST | 首次初始化第一个 admin(仅无 admin 时可调用) |
|
|
||||||
|
|
||||||
## 兼容性
|
## 兼容性
|
||||||
|
|
||||||
- **本地开发**(`make dev`):Gateway embedded runtime 完全兼容;无 admin 时访问 `/setup` 初始化
|
- **标准模式**(`make dev`):完全兼容,admin 自动创建
|
||||||
- **Gateway embedded runtime**:标准脚本、Docker dev 和生产部署均通过 Gateway 提供认证与 LangGraph-compatible API
|
- **Gateway 模式**(`make dev-pro`):完全兼容
|
||||||
- **Docker 部署**:完全兼容,`.deer-flow/data/deerflow.db` 需持久化卷挂载
|
- **Docker 部署**:完全兼容,`.deer-flow/users.db` 需持久化卷挂载
|
||||||
- **IM 渠道**(Feishu/Slack/Telegram):通过 Gateway 内部认证通信,使用 `default` 用户桶
|
- **IM 渠道**(Feishu/Slack/Telegram):通过 LangGraph SDK 通信,不经过认证层
|
||||||
- **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响
|
- **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响
|
||||||
|
|
||||||
## 故障排查
|
## 故障排查
|
||||||
|
|
||||||
| 症状 | 原因 | 解决 |
|
| 症状 | 原因 | 解决 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 启动后没看到密码 | 当前实现不在启动日志输出密码 | 首次安装访问 `/setup`;忘记密码用 `reset_admin` |
|
| 启动后没看到密码 | admin 已存在(非首次启动) | 用 `reset_admin` 重置,或删 `users.db` |
|
||||||
| `/login` 自动跳到 `/setup` | 系统还没有 admin | 在 `/setup` 创建第一个 admin |
|
|
||||||
| 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 |
|
| 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 |
|
||||||
| 重启后需要重新登录 | `.jwt_secret` 文件被删除且 `.env` 未设置 `AUTH_JWT_SECRET` | 在 `.env` 中设置固定密钥 |
|
| 重启后需要重新登录 | `AUTH_JWT_SECRET` 未持久化 | 在 `.env` 中设置固定密钥 |
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
# Blocking IO detection usage and maintenance
|
|
||||||
|
|
||||||
This document describes how to use and maintain DeerFlow backend blocking-IO
|
|
||||||
detection for async event-loop safety.
|
|
||||||
|
|
||||||
The goal is narrow: find and prevent synchronous IO from blocking backend
|
|
||||||
async event-loop paths. Static and runtime detection are complementary, but
|
|
||||||
they have different jobs.
|
|
||||||
|
|
||||||
## Static detector
|
|
||||||
|
|
||||||
The static detector is the discovery tool. It scans backend source code and
|
|
||||||
reports candidate blocking-IO call sites that may need human review.
|
|
||||||
|
|
||||||
Run it from the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make detect-blocking-io
|
|
||||||
```
|
|
||||||
|
|
||||||
Or from `backend/`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make detect-blocking-io
|
|
||||||
```
|
|
||||||
|
|
||||||
The report is written to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.deer-flow/blocking-io-findings.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Use this output for review and triage. A static finding is a candidate, not
|
|
||||||
proof that production blocks the event loop at runtime. The current static
|
|
||||||
rules are intentionally broad; prefer triaging existing output before adding
|
|
||||||
new static rules.
|
|
||||||
|
|
||||||
Add a static rule only when review finds a recurring high-risk blocking
|
|
||||||
pattern that is invisible to the current detector.
|
|
||||||
|
|
||||||
## Runtime detector
|
|
||||||
|
|
||||||
The runtime detector is the CI regression guard. It uses Blockbuster to fail a
|
|
||||||
focused test when code under `app.*` or `deerflow.*` performs blocking IO on
|
|
||||||
the asyncio event-loop thread.
|
|
||||||
|
|
||||||
Run it from `backend/`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test-blocking-io
|
|
||||||
```
|
|
||||||
|
|
||||||
The runtime gate starts from confirmed production bugs and protects those
|
|
||||||
paths from regressing. It does not prove that the entire backend is free of
|
|
||||||
blocking IO; it only covers the production paths exercised by
|
|
||||||
`backend/tests/blocking_io/`.
|
|
||||||
|
|
||||||
## Maintenance workflow
|
|
||||||
|
|
||||||
Use the static detector to find candidates, then use review to decide which
|
|
||||||
async production paths are worth protecting in CI.
|
|
||||||
|
|
||||||
The normal workflow is:
|
|
||||||
|
|
||||||
1. Run the static detector to find backend blocking-IO candidates.
|
|
||||||
2. Use human review to pick high-risk production async paths.
|
|
||||||
3. Add or update a focused runtime anchor in `backend/tests/blocking_io/`.
|
|
||||||
4. Let CI prevent that path from regressing.
|
|
||||||
|
|
||||||
Contributors changing backend async code can run the `blocking-io-guard` skill
|
|
||||||
(`.agent/skills/blocking-io-guard/`) to execute steps 1–3 for their own diff: it
|
|
||||||
scans the change for blocking-IO candidates, drafts or extends a runtime anchor,
|
|
||||||
and verifies the anchor fails when the blocking IO regresses.
|
|
||||||
|
|
||||||
Runtime detection has two maintenance paths.
|
|
||||||
|
|
||||||
### Add a runtime rule
|
|
||||||
|
|
||||||
Add a runtime rule when Blockbuster's default rules do not cover a generic
|
|
||||||
blocking primitive used by production code.
|
|
||||||
|
|
||||||
Rules belong in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
backend/tests/support/detectors/blocking_io_runtime.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Add them to `_PROJECT_BLOCKING_RULES`, not directly inside individual tests.
|
|
||||||
Keeping rules centralized makes it clear which extra primitives DeerFlow
|
|
||||||
expects Blockbuster to catch.
|
|
||||||
|
|
||||||
Example shape:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from blockbuster import BlockBusterFunction
|
|
||||||
|
|
||||||
_PROJECT_BLOCKING_RULES = (
|
|
||||||
(
|
|
||||||
"subprocess.Popen.__init__",
|
|
||||||
BlockBusterFunction(
|
|
||||||
subprocess.Popen,
|
|
||||||
"__init__",
|
|
||||||
scanned_modules=["app", "deerflow"],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not add a runtime rule just because a business path is not tested. A rule
|
|
||||||
only expands what Blockbuster can intercept after code runs.
|
|
||||||
|
|
||||||
### Add a runtime anchor
|
|
||||||
|
|
||||||
Add a runtime anchor when a high-risk async production path should be protected
|
|
||||||
by CI but no existing `backend/tests/blocking_io/` test executes it.
|
|
||||||
|
|
||||||
Anchors belong in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
backend/tests/blocking_io/
|
|
||||||
```
|
|
||||||
|
|
||||||
A good anchor should:
|
|
||||||
|
|
||||||
- Call the real production async entry point.
|
|
||||||
- Avoid bypassing the blocking surface with test-only `asyncio.to_thread`
|
|
||||||
wrappers.
|
|
||||||
- Use real local filesystem inputs when the bug shape is filesystem IO.
|
|
||||||
- Mock only the external dependency boundary, such as a network service or
|
|
||||||
third-party saver class.
|
|
||||||
- Fail if a future change moves the blocking operation back onto the event
|
|
||||||
loop.
|
|
||||||
|
|
||||||
Avoid testing only the low-level helper unless that helper is the production
|
|
||||||
async entry point. The runtime gate is most useful when it protects the caller
|
|
||||||
that production actually executes.
|
|
||||||
|
|
||||||
## Current runtime coverage
|
|
||||||
|
|
||||||
The runtime anchors protect confirmed blocking-IO bug shapes:
|
|
||||||
|
|
||||||
- SQLite checkpointer setup, including path resolution and parent-directory
|
|
||||||
creation.
|
|
||||||
- Subagent skill metadata loading through `SubagentExecutor._load_skills()`.
|
|
||||||
- `JsonlRunEventStore` async API (`put` / `list_*` / `delete_*`): the JSONL
|
|
||||||
run-event backend offloads its synchronous file IO via `asyncio.to_thread`
|
|
||||||
(fix #3084); this anchor drives the real async API under the gate so any
|
|
||||||
blocking IO reintroduced on the loop fails, not only removal of one
|
|
||||||
`to_thread` call.
|
|
||||||
- `UploadsMiddleware.before_agent` uploads-directory scan: a sync-only middleware
|
|
||||||
hook runs on the event loop under async graph execution, so the scan is
|
|
||||||
offloaded via `abefore_agent` + `run_in_executor`.
|
|
||||||
- Gate health checks: Blockbuster catches unoffloaded calls, opt-out works, and
|
|
||||||
patches are restored after exceptions.
|
|
||||||
|
|
||||||
As static detection and review identify more high-risk async paths, add new
|
|
||||||
runtime anchors incrementally.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user