393 KiB
CLAUDE.md
Cold agent construction (2026-10-30)
runtime/runs/worker.py dispatches opted-in synchronous factories through
build_agent_with_timing, backed by utils/background_init.py's
run_background_init and one shared FileIOPool(1, 1, "agent-init").
make_lead_agent._deerflow_background_build = True opts the built-in Lead Agent
into background construction; unmarked custom factories retain their caller-loop
contract. Serial admission prevents concurrent SDK cold imports and keeps waiting
builds out of authentication's executor. The synchronous timing helper remains
available. Model execution, checkpoint/store attachment and streaming stay in the
original run task; no graph is shared or cached across runs/users.
Every async Lead Agent entry point uses this builder, including non-streaming
and streaming open-chat endpoints and Workflow agent nodes. Do not call the
synchronous factory directly on their event loop: it can block SDK import locks
or deadlock while another background build awaits MCP discovery on that loop.
Async tool consumers (AI writing skill agents and task_tool) use
get_available_tools_async; deep-research capabilities and job execution also
construct their material providers through run_background_init. Keep the
common helper in utils with a lazy MCP import to avoid runtime/agent/tools
import cycles. Synchronous client/provider APIs remain available.
Copy the caller's ContextVars into construction and restore the successfully built
context before streaming, including a deferred-tool registry reset to None.
The mcp_initialization_loop context manager forwards lazy MCP discovery from the
worker to the owner event loop; exit it before copying back context. MCP cache
invalidation and legacy synchronous callers retain their existing semantics.
Cancellation drains the actual worker before releasing capacity, never starts the
graph afterwards, and preserves terminal status/SSE cleanup. Check abort_event
after construction as well as honoring task cancellation.
Regression: packages/harness/tests/test_agent_build_responsiveness.py,
test_agent_build_timing.py, test_mcp_background_build.py and the run-finalization
tests; tests/test_async_agent_entrypoints.py and
tests/test_async_tool_initialization.py exercise overlapping HTTP/workflow/tool
initializers. The HTTP/workflow cases run in subprocesses so an event-loop
deadlock fails with a bounded timeout. Cold profiling found ~4.7 s in model SDK imports/client setup, compared with
~14 ms in skill loading; moving only disk reads would not address this stall.
Local live cold acceptance (2026-10-30, official MiniMax-M2.7, tools enabled):
the first build took 4.28 s while a second request received stream metadata in
402 ms. During the conservative interior of the build window, 43 health/auth
probes completed successfully; across all three conversations, health/auth
P95 latency was 28/73 ms, with no event-loop-stall warning. Every answer ended
normally and all synthetic threads were removed. Initialization/model latency
still contributes to first-answer time; this change prevents blocking the server
loop, rather than promising immediate model output.
A separate live read-only tool check uploaded a random marker, allowed only
read_file, and confirmed the tool result and final answer reproduced that marker
without placing it in the prompt; the stream ended normally and the thread was removed.
Worker compatibility: no HTTP/SSE, task payload, auth or database contract changes.
Running workers without reload retain old construction behavior; the new factory
marker is harmless to old executors. worker_pool/revision.py hashes only the
adapter directory and does not identify this harness patch. This change does not
alter the controller's existing snapshot/ImageID-based drain-and-replace policy
or on_disconnect=cancel behavior: restarting a production controller can still
rotate idle workers or disconnect proxied streams. Do not infer uninterrupted
worker operation from these process-local initialization changes. The live local
acceptance instance has no enabled worker controller and does not touch remote
workers. Existing worker routing/admission/storage/discovery/diagnostics/launcher
compatibility regressions: 68 passed.
Final combined regression (2026-10-30): all 58 newly added responsiveness,
cancellation, MCP-entrypoint and provider-lifecycle cases plus the 68 existing
worker compatibility cases passed together (126 passed, no skips).
The live recheck uploaded and converted 20 DOCX files while another conversation
produced 43 visible text increments; all 172 concurrent authenticated probes
succeeded (P95 105 ms, max 436 ms). A fresh-process cold build took 10.92 s while
617 heartbeat samples continued (P95 scheduling delay 25 ms, max 146 ms).
The existing backend and child workers were not restarted for this recheck;
later entrypoint changes were verified through isolated HTTP/workflow tests.
The full versioned backend/harness run completed with 3140 passed, 39 failed,
4 skipped and 1 deselected. Against baseline 47cfed02, 30 failures also failed
on HEAD and the other 9 passed in isolated reruns on both versions; no current-only
stable failure remained. The deselected legacy
test_cancelled_history_closes_iterator hangs on both versions: its fake saver
implements alist, while limit=1 takes aget_tuple, so its unbounded entry
event wait never completes. This is not a claim that the repository's entire
test suite is green. Changed Python files introduced no Ruff diagnostics.
Upload responsiveness (2026-10-30)
app/gateway/routers/uploads.py runs each staged upload transaction in a dedicated
FileIOPool(2, 2, "uploads"), including 256 KiB spool copies, signature validation,
optional synchronous conversion, sandbox acquisition/sync, commit, rollback and
cleanup. Resolve the thread owner asynchronously before dispatch and propagate
contextvars into workers. Waiting batches consume no worker from authentication,
artifacts or event streaming. Per-event-loop directory locks serialize same-thread
batches and listing/deletion through rollback/cleanup; keep them held until a
cancelled worker stops. Listing/deletion use the artifact file pool off the loop.
Cancellation signals a cooperative event, checks between file chunks and around
commit/sync, and waits for real completion before multipart handles may close.
The response contract and upload-policy enforcement remain unchanged.
sandbox/sandbox_provider.py serializes initial singleton construction with
reset/replacement/shutdown. Concurrent cold uploads must not create competing
providers with independent sandbox reconciliation and idle-cleanup threads.
Regression: packages/harness/tests/test_sandbox_provider_concurrency.py.
file_conversion.py also isolates its asynchronous conversion/extraction callers
in a separate two-thread pool. PDF/Office parsing runs in a lazily initialized,
two-process pool using spawn on Windows and Linux, so Python parser CPU work
does not contend for the gateway GIL. Pass only file paths and converter choices;
bound returned text inside the child and write sidecars in the owning parent
worker. The global parser semaphore bounds sync/async callers together. A broken
process pool fails the current conversion and is rebuilt on the next call.
FileIOPool.run_until_complete provides non-abandoning cancellation for workers
that own temporary input files; cancellation cannot close/delete an input still
being parsed by a child process.
Regression: tests/test_upload_responsiveness.py,
tests/test_conversion_responsiveness.py, tests/test_uploads_policy_enforcement.py.
Local acceptance (Windows, Python 3.12, synthetic files via ASGI; no delay injected):
20 x 900 KiB TXT uploads reduced first concurrent probe delay from 664/682 ms to
3/4 ms. With real MarkItDown conversion, 20 x 800-paragraph DOCX uploads reduced
maximum probe scheduling lag from 286/285 ms to 41/14 ms; all 20 sidecars matched
the source text. The first spawned reader took 13.7 s to initialize/convert in
this environment, while concurrent probe lag stayed below 16 ms. These are local
responsiveness measurements, not production throughput guarantees. Upload,
policy, conversion, large-file and runtime-contention regressions: 87 passed.
Full-suite collection is separately blocked by the pre-existing missing
deerflow.models.global_prompt import in tests/test_global_prompt.py.
Thread metadata query performance and ORM deadlines (2026-10-22)
ThreadMetaRepository._query_conditions is shared by search/count: common
SQLite scalar predicates and PG/MySQL string predicates plus system-thread
exclusion run before SQL pagination/counting. Preserve AUTO owner resolution and
the nonempty taskId owner bypass while applying agent_id/actionId together.
json_compat.JsonPredicate binds values, preserves local missing/null and numeric
equality on SQLite, and uses binary MySQL id equality and Unicode-safe blank
seat detection. Nested/unusual-key filters and PG/MySQL numeric filters remain exact
residual filters; openGauss retains its original path. Never drop an unsupported
predicate or replace this with upstream's unadapted SQLite/PG-only compiler.
PG's omitted pool_recycle defaults to 300s (other backends remain 1800s); database.command_timeout is finite positive or null, default 30s. The shared pooled-engine kwargs are used on initial creation and auto-create retry. Only asyncpg receives command_timeout; this does not configure psycopg checkpointers. Regression entries: packages/harness/tests/test_thread_metadata_sql_filters.py, test_database_pool_deadlines.py and the existing test_thread_operation_timing.py. Master integration (2026-10-23) retains the maintained task/agent/action index columns as additional SQL narrowing before exact JSON predicates. Keep dedicated task deep-link card synchronization on every existing metadata mutation. Hydrated history still uses slim_display to bound oversized tool payloads without editing checkpoint contents; frontend retrieval-progress scopes remain isolated per turn. Thread POST defaults to metadata-only creation; only an explicit initialize_checkpoint=true writes an empty checkpoint. Never enqueue an empty checkpoint in the background: it could become the head after the real first run. Authorized metadata-only threads have empty state/history. Metadata GET defaults to the SQL row (running maps to busy); include_state=true and checkpoint-only legacy rows retain state reads. Owner decorators run before unscoped row reads. History explicitly uses root checkpoint_ns and closes alist before serialization; only the latest head carries full channel values.
The frontend uses BackgroundHistory as the SDK's external history adapter. Its post-stream mutate immediately captures streamed state with no checkpoint id; explicit end triggers background hydration. EOF/error still requires existing run confirmation/rejoin. Every async write is fenced by thread/run/epoch/request; new turns, navigation and unmount invalidate prior requests even if abort is ignored. Reject history missing the newest streamed turn, bound each request to 15 seconds and retry completion sync once. Failure keeps the streamed snapshot. Normal explicit-end callbacks use the completed streamed snapshot before the next send can change page refs; background hydration never repeats them. Recovery may have missed final SSE frames, so its callback waits for guarded hydration or the retained snapshot fallback. New submits use checkpoint:null to follow the latest server head.
Auth's SQLiteUserRepository is a historical name: its injected session factory is the shared business ORM. With database.backend=mysql, login/auth/users and SQL business repositories use MySQL, independently of checkpoint configuration. LangGraph Store still follows checkpointer config and startup awaits runtime setup/migrations; do not claim total service/startup isolation from PG or migrate existing stores implicitly. See docs/THREAD_LATENCY_OPTIMIZATION_ZH.md for the audit. Additional regression entries: test_thread_lazy_checkpoint.py, tests/test_account_authentication.py and frontend pnpm test:thread-history.
Multi-user contention controls (2026-10-22)
utils/file_io.pyseparates bounded artifact IO (4 workers / 8 admitted) and event disk IO (2 / 4) from the default executor used by authentication. Contextvars propagate; cancellation cannot release admission until disk work actually stops. Heavy artifact listing, ZIP previews, sniffing and writes use it.runtime/runs/manager.pysweeps completed live records after 300 seconds, 200 candidates per pass. It requires the actual task to be done and verifies status/error/metadata/kwargs in the backing RunStore, updating a final snapshot when necessary. Failed persistence retains the record for later retry. It never deletes RunStore rows, checkpoints, events or stream buffers.watch_completionstarts grace after finalization, and lifespan closes the sweeper. The live superseded fence survives a newer record's retirement; finalization never adds a business-database history read to its completion fence.- Run routes authorize the thread before unscoped persisted lookups, and verify that the loaded run belongs to that exact thread. Preserve private-owner 404s, taskId/system-thread sharing and existing action/agent partitions. Completed rejoin without a live bridge buffer emits end immediately; Last-Event-ID replay during the existing replay window remains untouched.
run_events.backend=memoryis a compatibility alias forSpoolingRunEventStore, stored underruntime_home()/run-events/events.sqlite3. Per-thread sequence assignment and each batch commit are transactional; paged queries do not retain completed events in RAM. No automatic event/message deletion or truncation is introduced. The pureMemoryRunEventStoreremains available for unit tests. Explicit db/JSONL formats are unchanged; JSONL disk operations now use the event executor and striped per-thread locks. Keep the spool and its WAL on the persistent local runtime volume. Do not reclassify it as a PG checkpoint or silently change explicit db configuration.runtime/fair_queue.pyprovides bounded round-robin admission by user for the subagent scheduler (3 total / 2 per user) and background roundtables (4 / 2). Queued jobs consume no worker; subagent deadlines include queue time. Per-user pending capacity also prevents one user from exhausting the entire backlog. Actual coroutine completion, including cancellation before its first step, acknowledges slot release. Parent context/identity propagation stays intact.RuntimeContentionConfigdefines optional budgets; limits apply per process and require restart. The older roundtable per-user environment override remains.- Both internal roundtable creation helpers only create metadata. Init does not
append a synthetic seat message via loopback;
_leader_runinjects authoritative IDs on each real input, deriving them from the thread mapping for older clients without seat_meta. Background first-leader injection remains. Never schedule a late empty checkpoint write: it could overwrite real history.
Regression entries: test_runtime_contention.py, test_completed_run_retention.py,
test_thread_lazy_checkpoint.py, test_run_completion_timeout.py,
tests/test_position_roundtable_sessions.py, roundtable role/model/seat tests,
artifact/auth/model concurrency tests and frontend pnpm test:thread-history.
See docs/MULTI_USER_CONTENTION_AUDIT_ZH.md for measured scope and remaining
model/SQL/checkpointer contention. Process-local run control still requires the
current single-worker deployment; these queues do not enable multi-worker routing.
Ordinary knowledge smart retrieval dual surface (2026-10-16)
SmartReasoningRetrievalMiddleware treats knowledge retrieval as an ordinary
model-selected information tool inside the reasoning loop. A direct answer or
skill-only turn is never followed by a forced search. Once the model decides it
needs selected-library evidence, the first retrieval action is
ordinary_knowledge_search; after reading those raw hits, the model decides
whether Wiki search/read, regex verification, document reads, or another raw
query is needed for a real evidence gap. Each model iteration executes at most
one smart-retrieval call, and any content emitted beside that call is removed so
the final answer can only follow the completed tool loop. The middleware
deduplicates current-turn raw queries, regexes, Wiki
patterns, page_ref+cursor reads and document pages; a new continuation cursor
is a distinct action, while a repeated/non-advancing cursor terminates that
page. If deduplication removes an empty duplicate-only response, the middleware
makes one tool-free synthesis retry over existing evidence instead of returning
a generic non-answer. The user only asks the question; no retrieval recipe is
required in the message. Original document chunks win when Wiki and raw
evidence conflict, while stable general knowledge may explain background if it
is clearly distinguished from library facts and inference. Smart-mode answers
do not require [1]/[2] markers or a numbered reference list. Classic
retrieval does not mount this behavior.
Default WeKnora clients are cached per running event loop because they own an
async authentication lock and a cached directory task; never restore a
process-wide client cache. Smart-tool mapping authorization reads are routed to
the SQL engine's owning loop before provider I/O. These two boundaries prevent
healthy selected knowledge bases from being reported as PROVIDER_UNAVAILABLE
when Gateway and LangGraph work run on different loops.
Wiki creation resolves its KnowledgeQA model only from WeKnora's database model
records. It prefers an active explicit default and calls WeKnora's model debug
endpoint before binding that model to summary_model_id and
wiki_config.synthesis_model_id; active alone is not proof that the upstream
provider still serves the model. A candidate returning ok=false may be skipped,
but authentication, timeout, server, and response-contract failures remain service
errors. Existing documents that already reached Wiki dead letters require an
explicit reprocess after both model fields are repaired. Directory and focus
instructions remain persisted in the knowledge base's database wiki_config.
llmwiki_reasoning_tools._wiki_search_impl applies the safe POSIX expression
to normalized Wiki pages, but obtains bounded candidates from both the local
database keyword index and the newest-page snapshot. A completed local mirror
with no regex match probes the live WeKnora provider, which prevents newly
generated Wiki pages from being hidden by a stale mirror when periodic sync is
off. A 0/0 mirror is never considered complete. Provider content,
content_md, and markdown bodies are normalized for both search and page
read. Keep the live-provider zero-match fallback when changing mirror policy.
Gongwen bounded delivery (2026-10-07)
Gongwen seed v3 ships scripts/delivery.py through the restricted
run_gongwen.py deliver / verify-file commands. Deliver stages a new DOCX,
runs draft or repair with one output verification, injects only the explicit
elements JSON, then checks package/XML/body/element presence without treating
the red header or imprint as body text. It publishes without replacing an
existing output and returns ready/review_required/failed JSON plus the review.
No renderer or external model is invoked; visual pagination and factual review
are not claimed. Do not add automatic repair/check loops or ad-hoc Python to
the workflow/SOUL prompts. P1/P2 warnings are not delivery blockers.
The vendored optimize command reuses its input check and returns full output
review details; failed verification exits nonzero. Draft propagates its resolved
document type into optimization. Gateway generate/repair reuse this invocation's
review, with one check fallback for older custom skill versions. Do not reuse
arbitrary client-supplied review files as trusted verification.
Regression coverage: tests/test_gongwen_delivery.py and existing gongwen tests.
Retrieved skill files (2026-10-07)
citation_display.retrieved_files_enabled (default true) controls retrieved-file
cards globally. It is read and patched through /api/system-settings/citation-display;
updates require _require_admin and preserve other citation settings.
Chat file cards reuse the existing artifact and knowledge original-file readers.
GET /api/llmwiki/sources/resolve accepts either a local mapping id or a WeKnora
provider id and returns only an authorized local mapping id. A provider lookup
must be followed by the existing get_authorized(..., write=False) check before
returning any identifier; missing and unreadable sources both return 404.
The frontend uses this resolved id for both preview and original-file download.
Qwen3.8 Flash Next stream integrity (2026-10-07)
deerflow.models.chat_stream_state.ChatStreamState belongs to one provider invocation.
PatchedChatOpenAI enables it only for the normalized qwen3.8-flash-next id;
the model factory also checks the configured name for deployments using a generic
provider id. The local-only strict_stream_integrity override is never sent upstream.
Keep unrelated Qwen versions and other models on their existing stream path by default.
Only explicit choice.message string snapshots are converted into unseen suffixes.
True delta text wins and is appended verbatim, including repeated content/reasoning;
structured provider payloads stay opaque. Repair a null terminal delta for this model.
EOF without a first-choice finish_reason, or a contradictory snapshot, raises the
content-free IncompleteChatStreamError. The existing LLM middleware retries within
its normal cap before any failed result reaches the checkpoint or tool executor.
Cancellation propagates without retry; a declared finish reason (including length)
retains provider semantics. Do not guess completion from answer punctuation.
Non-stream exchange capture is opened and closed in _generate / _agenerate so
executor-based conversion never resets a ContextVar token from another context.
Reuse the safe response dump for conversion and preserve typed parsed / refusal
metadata without serializing arbitrary parsed objects a second time.
Regression entry: packages/harness/tests/test_patched_openai_text_integrity.py.
Coverage includes sync/async provider replay, model/alias scoping, failed-attempt
checkpoint isolation, same-thread follow-ups, tool safety, cancellation and 20
overlapping streams that reuse the same provider completion id. These fixtures
exercise code-level compatibility gaps; they do not establish the deployed server's
actual trigger without its response evidence.
??
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Personal follow-up question preference
GET/PUT /api/user-preferences exposes followup_suggestions_enabled, defaulting to false for both new accounts and older JSON rows without the key. Partial updates merge into the current account's user_preferences.preferences JSON alongside hide_admin_prompt_prefix; no database migration is needed. Frontend recommendation generation and display must both require this explicit opt-in. Regression coverage: tests/test_user_preferences.py (default-off behavior, persisted opt-in/opt-out, account isolation and partial-update preservation).
Ordinary Q&A map visualization
The ordinary lead-agent prompt includes _ORDINARY_QA_VISUALIZATION_SECTION only for normal, non-bootstrap, non-writing, non-notebook, non-scheduled runs and defines the highest-priority output-carrier gate. Only an explicit current-request ask for HTML, a webpage/page, dashboard, large-screen view, or .html file permits reading page-report-generation, calling page_report_template, or writing HTML. “Draw a flowchart/chart/map/mind map” and “make a visualization” never imply HTML: ordinary flowcharts must be inline Mermaid, ordinary charts keep the established message-inline path, and requested maps emit strict JSON in a map-json fence (markers use name+coordinate plus optional color; lines are included only when the user explicitly asks to connect places, must use coordinates with at least two [lng,lat] points, not from/to/coords, and default to arrow/flow). The frontend recovers those common aliases, assigns distinct marker colors, draws requested lines as looping SVG arrows, and drops unusable lines so markers still render. A mind map is emitted only for an explicit current-request ask for a mind map/knowledge map (or analysis-mindmap); ordinary analyses, summaries, outlines, flowcharts, and generic JSON must never auto-upgrade into one. Its strict mindmap-json contract is a bounded rooted tree (id, parentId, label, semantic kind, optional detail/source refs), targeting 12–24 nodes with a hard cap of 36, at most four layers including the center topic, at most six top-level dimensions and three children beneath every later node. Models must consolidate related facts into detail/sourceRefs, not make one node per report bullet, datum, paragraph, or reference. An unfinished dedicated mindmap-json fence (and compatible historical mindmap fence) remains an ordinary streaming code block; only the completed, valid object lazy-renders as a compact left-to-right message card. Clicking a parent node, or its +/− control at the outgoing connection point, expands/collapses its child tree and the canvas uses an explicit visible edge stroke. The full dialog keeps its close and view tools in the top-right and a layout-derived all-map preview in the bottom-right; the message preview also permits pan, wheel zoom, and zoom/reset controls. The message card's icon-only download control opens PNG/SVG/JSON choices. Generic JSON never invokes this renderer. Ambiguity defaults to inline output. Once the HTML gate passes, delivery is mandatory as a sandbox-generated /mnt/user-data/outputs/*.html artifact opened with present_files; a full inline html fence is forbidden as a substitute. The shared frontend MarkdownContent keeps its exact legacy branch when no map-shaped fence exists; dedicated map-json fences and complete generic json fences that contain map fields and pass the full map schema are validated and lazy-rendered from components/workspace/visualizations/. Map/loading/error cards use the full message-content width so their edges align with the step panel. Non-map and incomplete generic JSON retain the ordinary code-block path.
HTML artifacts are single-page by default. Omitted preferred_page_count is normalized to 1 during direct validation. A count greater than one is allowed only when the current user directly asks for multiple pages or gives a page count; content length and report chapter count never imply multi-page intent. Explicit multi-page intent without a count defaults to a three-page lightweight portal. It is one artifact HTML, not a folder of linked pages: <main class="pages"> contains logical .page sections with unique id/data-page, and href="#page-..." navigation works in the sandboxed Blob preview through the injected anchor handler. Its validator requires the local Bootstrap/Alpine assets, .pages, multiple data-page sections and real anchor navigation: every actual <a href="#page-..."> and every script-set location.hash must match a unique logical-page id, and each page must be reachable; comments or #overview/#stations data-page values cannot spoof this check. MultiPageHtmlMiddleware repeats that structural inspection at the present_files boundary, so a model that skips validate or writes custom tabs cannot expose a nonconforming portal. A user-uploaded background goes only on the requested logical .page in this mode (home page when unspecified), not on every page. When a template is explicitly requested, prepare strictly excludes profiles from the wrong page-count mode, persists requested_page_count and page_mode, and validate reloads them. Validation rejects multiple detected page views (data-page, tabpanels, .report-page, or .view) in single-page mode, or a single view when multi-page was explicitly requested.
Ordinary HTML generation is from scratch by default: do not read page-template-library, call catalog/prepare, or select a bundled layout unless the current user explicitly asks to use a template. The agent writes the final path directly under /mnt/user-data/outputs/, which lets frontend live preview open on the first write_file; it then validates and presents the same file without a bash/cp copy. Single-page HTML means a scrollable responsive document, not a fixed single-screen canvas. Direct validate derives layout_mode and page count from page_brief / preferred_page_count; it rejects html/body overflow:hidden, fixed 1920×1080 viewport scaling, undersized G6 containers, and long LR diagrams. Explicit template requests may use the shared light-document / dark-document profile backed by report-document-responsive-template.html; prepare creates its editable working_copy directly in outputs and persists the same constraints.
For an explicit multi-page HTML request without an explicit template request, MultiPageHtmlMiddleware rejects page_report_template catalog/prepare calls; the model must make one complete first write_file, then one validate. A failed validation returns patch-only repair guidance: retain the artifact and use the smallest str_replace edits for all listed CSS/navigation/JSON/DOM errors before one revalidation; do not recreate the page. html_postprocess.maybe_fix_generated_html also injects a marker-guarded dark Bootstrap compatibility layer into dark HTML during every write or replacement. It sets Bootstrap body/emphasis/secondary tokens and explicit readable foregrounds for .card, .data-table cells, .modal-*, and common form controls. Validation mirrors this by rejecting dark .card, .data-table, and modal markup without an explicit matching foreground color, so a preview cannot silently render black text on dark panels.
Generated HTML artifact visualization runtimes
The same normal lead-agent prompt includes _HTML_ARTIFACT_VISUALIZATION_SECTION, but its contents are executable only after the explicit HTML-intent gate above passes; visualization content alone never implies this intent. New generated HTML explicitly loads browser distributions from Gateway /api/public/html-runtime/: bootstrap.min.css, alpine.min.js, bootstrap.bundle.min.js, g6.min.js, echarts.min.js, and then deerflow-runtime.js; it can also load the packaged country/region map catalog: china-provinces, us-states, japan-prefectures, and taiwan-counties. Bootstrap is the responsive layout/modal layer and Alpine is the small state/list-rendering layer; jQuery, React, Vue, Babel and public CDNs are not part of this artifact contract. The files are package resources under packages/harness/deerflow/html_runtime/assets/ and ship with the harness wheel, so HTML execution does not depend on frontend Vite/node_modules assets or a public CDN. system_settings.json -> html_map.engine (GET|PUT /api/system-settings/html-map, admin write) chooses echarts (default, local GeoJSON data-df-geo) or leaflet (地图网页, data-df-map). The map tile URL saved in the settings dialog (system_settings.json -> html_runtime.map_tile_url, GET|PUT /api/system-settings/html-runtime) is injected into deerflow-runtime.js as mapTileUrl and is used only by the Leaflet engine. Both initializers stay in the runtime so already generated pages keep rendering after the switch. The next ordinary Q&A prompt and page_report_template validation follow the selected engine. The runtime base URL saved in the same settings block is the browser-reachable absolute address written into generated source. An empty value falls back to an older config.yaml entry and then http://localhost:8001/api/public/html-runtime. Flowcharts use paired data-df-flow JSON blocks and AntV G6, not Mermaid; charts use data-df-chart; maps use ECharts Canvas-backed data-df-geo with the local GeoJSON catalog. data-df-chart must be strict JSON: no JavaScript callbacks or function-valued colors, with literal colors/data-item styles and string tooltip formatters instead. Dark HTML must set data-df-theme="dark" and explicitly give ECharts global/title/legend/axis/visible-series labels readable light text, including nameTextStyle for named axes. Map description values are plain text because the runtime escapes markup; click-to-filter or page-switch behavior listens to deerflow:geo-map-click and uses a valid #page-... anchor. deerflow-runtime.js waits until a hidden logical page has a real layout before creating ECharts and tracks later container changes with ResizeObserver, so map/chart canvases never depend on a browser-window resize for their first visible render. It initializes all three contracts, opens a detail popover for described regions/markers, and exposes catalog URLs through its public dynamic response. Map large screens use a centered map stage with narrow side rails and bottom summaries, rather than cards layered across a full-page map background. Frontend runtime replacement is bypassed whenever this explicit Gateway runtime is present. This remains separate from ordinary message rendering; ordinary map-json keeps its existing frontend tile renderer and VITE_MAP_TILE_URL configuration, and ordinary flow-json/chart-json must not be reintroduced.
User-uploaded raster images can be used as generated-HTML backgrounds through signed, unauthenticated GET /api/public/html-images/{token} URLs. UploadsMiddleware mints one URL per uploaded .jpg/.jpeg/.png/.webp/.gif/.bmp and injects it into <uploaded_files> as Public URL; the HTML contract requires that exact absolute URL in background-image, never a sandbox path, artifact API path, relative path, or base64 data URI. Single-page artifacts place it on html/body; the multi-page portal places it only on the requested logical page. Tokens are HMAC-signed, do not expire (the URL is written into durable HTML), and only grant read access to one file under the thread uploads/ or outputs/ directory. Engine: deerflow.html_runtime.public_images; router: app/gateway/routers/html_images.py; tests: tests/test_html_images.py.
The G6 runtime must not mutate author layout by auto-switching long LR graphs to TB or by increasing the target element height after render. Layout quality is decided before delivery: keep a flow to at most eight nodes where possible; otherwise split it or declare TB with at least 600px. Each flow gets its own document row and a parent that can grow naturally, preventing canvas overlap with later sections.
Markdown-to-HTML conversion must inventory source Mermaid/flowcharts, charts, and maps before editing. Direct page_report_template validate derives required_visualizations from its required_features and page_brief; an explicit-template prepare also persists a machine-readable visualization_contract in the per-output brief, which validate reloads and merges with its arguments. Required flowcharts fail validation unless the HTML contains g6.min.js, deerflow-runtime.js, a .df-flow container, and data-df-flow; embedded Mermaid and inline new G6.Graph(...) are errors. Required charts use ECharts + data-df-chart. Required maps follow html_map.engine: ECharts uses data-df-geo, an .df-geo-map container, and one of the four local GeoJSON catalog entries; 地图网页 uses Leaflet leaflet.css/leaflet.js, data-df-map, and a .df-map container. The unselected contract, handwritten map initialization, and (for ECharts) map tile URLs are validation errors. A failed report must be repaired and revalidated before present_files.
G6 nodes use light semantic rounded cards with matching borders, dark high-contrast text, centered type-prefixed labels, subtle shadows, and non-animated initial rendering (avoids a faded first frame). More than seven nodes automatically switch an LR request to layered TB layout and expand a short container to 720px, preventing long workflows from shrinking into illegible shapes; drag/zoom remain enabled.
问答记忆运行时总开关
通用/个人问答记忆由 system_settings.json -> memory.enabled 控制,默认 false,并且是该通用记忆域的权威总开关。关闭时 lead_agent 不挂载通用 MemoryMiddleware,工具注册不暴露 memory/hindsight 工具,prompt 不读取通用记忆,因此也不会触发 V1 的延迟提取模型调用。普通问答项目的项目级跨会话记忆是独立安全域,只受项目自身 enabled 控制,不得被这个通用总开关阻断。config.yaml -> memory 仅保留 provider/version/限额等参数,不能自行启用通用记忆。管理员通过 GET/PUT /api/system-settings/memory 和「设置 → 问答记忆」修改通用记忆开关;下一次创建的问答立即按新值构建。
项目空间(固定问答模式)
app/gateway/routers/analysis_projects.py:用户私有项目 CRUD 和项目下新建/查询对话;更新请求不允许mode,对话归属不通过可编辑 metadata 保存。- 项目线程 metadata 的
analysis_project_id/analysis_project_mode/analysis_project_name仅用于最近对话的类型、项目名与路由展示;创建、列表修复和每次运行都由服务端覆盖,授权与真实归属仍以analysis_project_threads绑定为准。 deerflow.persistence.analysis_projects:analysis_projects/analysis_project_threads/ additiveanalysis_project_configs,经 models 注册和 Gateway deps 初始化自动建表。配置表保存项目默认智能体、策略 Lens、报告结构快照及每条对话所选智能体,避免修改旧表破坏已有部署。app/gateway/analysis_projects.py::prepare_project_run在共享start_run前置读取绑定、校验所有者及知识库、覆盖运行模式;运行知识范围是项目自身普通/助手知识库与该线程已绑定智能体普通知识库的去重并集,不能只取智能体配置而丢掉项目知识库;authz.py同时保护直接读取线程。不要将analysis_project加入公共 context 白名单。deerflow.agents.analysis_project提供三个模式的系统约束和固定方案模板;方案复用 deep_research_report 的 fixed structure,thread_harvester 在方案项目中额外采集服务端检索的引用。普通写作仍使用原有 adaptive 配置。- 普通问答项目的跨会话记忆是独立安全域,权威 scope 为
(owner_user_id, project_id),只信任analysis_project_threads的服务端绑定。Gateway 会清除客户端伪造的project_memory_scope/project_memory,再由prepare_project_memory注入;services.inject_validated_analysis_project_context同时禁用通用MemoryMiddleware与 memory/hindsight 工具。harness 只消费转义、有界、明确标为非指令数据的<project_memory>,不得反向导入app.*。 - 项目记忆只受项目自身
enabled和 store readiness 门控;system_settings.json -> memory.enabled只管理通用/个人记忆,即使它关闭,prepare_project_memory、成功回调入队、executor 与 dispatcher 仍须为已启用的 QA 项目工作。项目暂停会取消并围栏该项目 queued/running job。项目记忆异常必须降级为无记忆问答,不能污染普通运行配置。 - 项目记忆 job 的内部
causal_generation保存在payload_json(不得为此新增数据库列;兼容已由create_all建好的表),状态只按最新因果代次判断;一个全量重建的所有 job 共用 generation,任一失败使该批次失败。checkpoint 枚举完成前必须保持未 seal 的rebuilding,避免先完成的部分 job 提前转 ready。dispatcher 通过claim_stalled_rebuilds恢复超过宽限期且没有活动 job 的rebuilding/recovering,认领和重放期间召回必须 fail-closed。 - 持久化位于
deerflow.persistence.analysis_project_memory:states保存项目设置和确定性快照,items保存带来源、logical key、版本和可选 embedding 的原子记忆,jobs保存脱敏后的幂等抽取任务。三个模型由统一Base.metadata.create_all自动建表,不需要新增 Alembic revision。 - 成功 run 只把最近完整 user/assistant exchange 入队,后台 executor 抽取并可调用共享 Wiki encoder。召回为 lexical + vector + importance/confidence/recency 混合排序;只有 embedding model/fingerprint 与 query encoder 完全匹配时使用向量,否则降级关键词。召回要排除当前 thread 的原子条目,并受 item/episode/token budget 限制;抽取任务中的 checkpoint 文本、项目名和背景在落库前必须脱敏。
- 对话编辑、单轮删除、清空、整线程删除以及直接 state 消息替换,必须先调用
invalidate_project_memory_before_thread_mutation持久化失效并取消 lease,再修改 checkpoint;前置失效失败时拒绝修改。后续 rebuild 失败时保持rebuilding,recall fail-closed。start_rebuild删除旧的自动条目但保留手工或 pinned 条目;同 logical key 按 checkpointsource_order收敛,不能由并发 worker 的完成顺序决定。dispatcher 只能按真实空槽 claim,过期任务达到max_attempts后必须终止。 - 管理 API:
GET/PATCH /api/analysis-projects/{id}/memory[/settings]、POST .../memory/rebuild、DELETE .../memory和.../memory/items*;全部先走owned_project且只接受mode=qa。前端ProjectMemoryPanel展示状态、快照、来源,并提供手工维护、固定、清空和重建。专项回归为tests/test_analysis_project_memory*.py加tests/test_analysis_projects.py,必须同时覆盖内存与 SQLite、通用记忆关闭时 A 会话仍可沉淀到 B 会话召回、跨项目/跨用户隔离、项目暂停、来源失效、纠错顺序、租约与关键词降级。 - 方案生成首份报告对用户保持两步语义:「正常问答理解需求 → 结束问答并生成方案」。第一步应自然交流并先做语义级意图理解:复述真实目标、面向对象、可观察的成功结果与非目标/边界,推断必须标为暂定;后续消息增量更新整体理解,最新明确要求覆盖较早的冲突表述。仅在关键条件缺失时少量追问;信息足够后输出自然段式的简短「意图理解」,以及唯一的
solution-intent-jsonHTML 注释快照和末尾就绪标记,不得呈现为表单式访谈或额外确认阶段。快照的goal/audience/success_outcome/scope/constraints/deliverable/acceptance_criteria必须是有界非空字符串,open_questions必须是有界字符串数组且只有为空时可就绪。prepare_project_run删除客户端伪造的solution_requirements_confirmed/solution_intent,只解析检查点中最新用户轮次后的最新助手回复;只有可读摘要、合法快照、末尾就绪标记和当前用户确认标记同时成立时才确认,并把归一化快照作为服务端权威solution_intent注入运行配置。services.inject_validated_analysis_project_context负责绕过公共白名单安全注入这些服务端字段,确认后的系统提示把转义后的快照交给最终生成。已有report.md仅用于后续编辑的兼容解锁;deep_research_report在首份方案未确认时必须拒绝执行。不要把该闸门退化为仅靠前端按钮或提示词的约定。 - 方案阶段的关键追问必须调用现有
ask_clarification:普通方案需求第一步至少给出 1 个最关键的可点击决策问题,一个调用只问一个决策点,提供 2–4 个稳定选项(含「其他/我补充」),总量限制 1–3 个,由ClarificationMiddleware继续输出既有{question, clarification_type, context, options, allow_custom, allow_multiple}载荷供前端点击。独立问题可同轮多次调用;不要用普通 Markdown 问句替代工具卡,也不要在用户回答前只输出快照/就绪标记。另有服务器校验的强制生成令牌<!-- solution-requirements:force -->:只认本轮最后一条 user/human 消息,仍清除客户端伪造的solution_force_generate/solution_requirements_confirmed/solution_intent。强制生成可复用 checkpoint 中最近一个合法但未就绪的快照(允许open_questions非空;没有则为None),prompt 必须结合完整对话推断缺项、显式标注暂定假设并禁止再次澄清。确认/强制生成首报轮以WritingModeReportMiddleware(require_pipeline=True)做运行时兜底:纯文本“已生成”或澄清调用会被确定性改写为deep_research_report;任何完成提醒/循环成功判定都必须同时看到固定成功工具结果和状态中真实登记的/mnt/user-data/outputs/report.mdartifact,被意图门禁拒绝的 ToolMessage 绝不能触发“已完成”提醒。当前用户轮次里只要管线返回结果却没有 artifact,就输出确定性的失败/重试提示并结束,禁止自动重试到循环上限;下一次用户点击「重新生成方案」才开始新调用。 - 方案项目最终落盘的
report.md禁止[1]/【1】等数字引用标记。deep_research_report_tool在 solution 模式下同时注入无编号引用要求并对最终 Markdown 做确定性清理:正文删除数字引用标记,参考来源改为无编号列表,代码围栏和行内代码保持原样;普通写作与深度研究不走该分支。后续方案修改的系统提示也必须维持这项约束。 - 前端
frontend-web/src/pages/AnalysisProjectsPage.tsx和pages/analysis-projects/ProjectConversation.tsx嵌入现有 WorkspaceSidebar,复用消息流、输入框、研究进度和产物画布;策略项目默认打开真实消息流,用户问答、reasoning 与技能/工具执行步骤必须在运行中可见,结构化看板仍可一键切换。StrategyBoard.tsx只解析对用户可见的「意图理解 / 多视角策略分析与辩论 / 完整策略方案」Markdown,不读取 reasoning/tool payload。完整策略方案以可导出的文档结构呈现。方案设置复用WorkflowConfigDialog管理报告结构。 - 回归测试:
tests/test_analysis_projects.py(项目 CRUD、权限、模式锁定、运行配置、模板及记忆管理 API)和tests/test_analysis_project_memory*.py(持久化、运行时、双后端可靠性与真实跨会话效果)。
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:
- Gateway API (port 8001): REST API plus embedded LangGraph-compatible agent runtime
- Frontend (port 3000): Next.js web interface
- Nginx (port 2026): Unified reverse proxy entry point
- Provisioner (port 8002, optional in Docker dev): Started only when sandbox is configured for provisioner/Kubernetes mode
Runtime:
make dev, Docker dev, and production all run the agent runtime in Gateway viaRunManager+run_agent()+StreamBridge(packages/harness/deerflow/runtime/). Nginx exposes that runtime at/api/langgraph/*and rewrites it to Gateway's native/api/*routers.
Project Structure:
deer-flow/
??? Makefile # Root commands (check, install, dev, stop)
??? config.yaml # Main application configuration
??? extensions_config.json # MCP servers and skills configuration
??? backend/ # Backend application (this directory)
? ??? Makefile # Backend-only commands (dev, gateway, lint)
? ??? langgraph.json # LangGraph Studio graph configuration
? ??? packages/
? ? ??? harness/ # deerflow-harness package (import: deerflow.*)
? ? ??? pyproject.toml
? ? ??? deerflow/
? ? ??? agents/ # LangGraph agent system
? ? ? ??? lead_agent/ # Main agent (factory + system prompt)
? ? ? ??? middlewares/ # 10 middleware components
? ? ? ??? memory/ # Memory V2: provider/manager/builtin/hindsight
? ? ? ??? thread_state.py # ThreadState schema
? ? ??? sandbox/ # Sandbox execution system
? ? ? ??? local/ # Local filesystem provider
? ? ? ??? sandbox.py # Abstract Sandbox interface
? ? ? ??? tools.py # bash, ls, read/write/str_replace
? ? ? ??? middleware.py # Sandbox lifecycle management
? ? ??? subagents/ # Subagent delegation system
? ? ? ??? builtins/ # general-purpose, bash agents
? ? ? ??? executor.py # Background execution engine
? ? ? ??? registry.py # Agent registry
? ? ??? tools/builtins/ # Built-in tools (present_files, ask_clarification, view_image)
? ? ??? mcp/ # MCP integration (tools, cache, client)
? ? ??? models/ # Model factory with thinking/vision support
? ? ??? skills/ # Skills discovery, loading, parsing
? ? ??? config/ # Configuration system (app, model, sandbox, tool, etc.)
? ? ??? community/ # Community tools (tavily, jina_ai, firecrawl, image_search, aio_sandbox)
? ? ??? reflection/ # Dynamic module loading (resolve_variable, resolve_class)
? ? ??? utils/ # Utilities (network, readability)
? ? ??? client.py # Embedded Python client (DeerFlowClient)
? ??? app/ # Application layer (import: app.*)
? ? ??? gateway/ # FastAPI Gateway API
? ? ? ??? app.py # FastAPI application
? ? ? ??? routers/ # FastAPI route modules (models, mcp, memory, skills, uploads, threads, artifacts, agents, suggestions, channels)
? ? ??? channels/ # IM platform integrations
? ??? tests/ # Test suite
? ??? docs/ # Documentation
??? frontend/ # Next.js frontend application
??? skills/ # Agent skills directory
??? public/ # Public skills (committed)
??? custom/ # Custom skills (gitignored)
Important Development Guidelines
Documentation Update Policy
CRITICAL: Always update README.md and CLAUDE.md after every code change
When making code changes, you MUST update the relevant documentation:
- Update
README.mdfor user-facing changes (features, setup, usage instructions) - Update
CLAUDE.mdfor development changes (architecture, commands, workflows, internal systems) - Keep documentation synchronized with the codebase at all times
- Ensure accuracy and timeliness of all documentation
Workflow Studio stream and canvas compatibility
PatchedChatOpenAIaccepts provider response fieldsreasoning_contentandreasoning, normalizing both intoAIMessage.additional_kwargs.reasoning_content. For OpenAI-compatible models that declaresupports_thinking: truewithout an explicitwhen_thinking_enabled,create_chat_modelsendsextra_body.enable_thinking=truewhenever the frontend enables thinking.app/gateway/workflow_agent_runner.pyis the boundary between LangGraph token chunks and durable workflow events. Provider reasoning inAIMessage.additional_kwargs.reasoning_content/reasoningis emitted to the UI as balanced inline<think>blocks; only visible answer content becomes the node'stextoutput for downstream nodes. It must also emit every original[AIMessage|ToolMessage, metadata]tuple asnode.message.data.messageswith no whitelist, preview or length crop. Tool results andadditional_kwargs.clarificationare needed by the Studio's DeerFlow-compatible message UI and must survive durable SSE replay.- An agent
ask_clarificationToolMessage pauses the workflow at that agent node (awaiting_input), not as a terminal run. The pause records the sourcenodeRunId/toolCallId; resuming turns the submitted reply into the next user message on the samewf-{run}-{node}LangGraph thread. Keep the raw ToolMessage authoritative; do not invent a second card payload. app/gateway/workflow_proposal_planner.pyconverts the workflow controller's selected catalog roles into per-agentmission/deliverable/scope/handoffcontracts. Only a selected visibleagentIdmay contribute a contract; every omitted or malformed field falls back to a deterministic role-aware contract. Preserve this prompt-level worker boundary when adding strategies: researchers must not silently become final writers, and sequential roles must receive their upstream handoff binding plus an explicit next handoff.app/gateway/routers/workflows_coze_compat.pymust return bothcreator.self: trueand editable VCS metadata for an owned standalone workflow. Coze Playground derives its preview/read-only mode from those fields, so omittingcreator.selfdisables node dragging even when the standalone page passesreadonly={false}.
Commands
Root directory (for full application):
make check # Check system requirements
make install # Install all dependencies (frontend + backend)
make dev # Start all services (Gateway + Frontend + Nginx), with config.yaml preflight
make start # Start production services locally
make stop # Stop all services
Backend directory (for backend development only):
make install # Install backend dependencies
make dev # Run Gateway API with reload (port 8001)
make gateway # Run Gateway API only (port 8001)
make test # Run all backend tests
make lint # Lint with ruff
make format # Format code with ruff
Regression tests related to Docker/provisioner behavior:
tests/test_docker_sandbox_mode_detection.py(mode detection fromconfig.yaml)tests/test_provisioner_kubeconfig.py(kubeconfig file/directory handling)
Boundary check (harness ? app import firewall):
tests/test_harness_boundary.py? ensurespackages/harness/deerflow/never imports fromapp.*
CI runs these regression tests for every pull request via .github/workflows/backend-unit-tests.yml.
Architecture
Position roundtable recovery persistence
/api/position-roundtable uses the shared /api/intent/* LangGraph flow as
the live Step-1 execution source and stores an additional SQL recovery copy in
position_roundtable_sessions/position_roundtable_nodes. The recovery copy
contains complete intent/node/summary/action-plan conversations, unsent
composer drafts, workflow snapshots, and bounded full text for generated
Markdown/JSON artifacts. This lets history and downstream closing agents keep
working when container-local checkpoint or sandbox volumes are replaced.
Revision 20260723_05 adds the conversation columns. Session deletion
explicitly removes child nodes so SQLite deployments do not depend on FK
cascade pragmas. The router is mounted canonically below
/api/position-roundtable and, for intranet gateway allowlists, below the
schema-hidden compatibility prefix /api/multi-agent/position-roundtable.
Clients may retry the compatibility route only for a framework/proxy-level
404 Not Found; domain-level missing-session responses must remain real 404s.
The position-roundtable state machine is multi-worker safe: node commands
(complete, bind, conversation save, reject) and session commands
(activate, archive, delete) lock the parent session and affected nodes in one
transaction, then record a unique command id for retry replay. The task and
intent snapshot writes use the same parent-row lock; they are permitted only
while status=intent_pending, so a late “confirm intent” write cannot change a
chain that another user has already activated. Activation itself rechecks the
stored confirmed intent after taking the lock. tests/test_position_roundtable_ commands.py and tests/test_position_roundtable_concurrency.py cover the
state transitions; tests/test_roundtable_postgres_concurrency.py is the real
PostgreSQL acceptance suite (set DEERFLOW_POSTGRES_CONCURRENCY_TEST_URL).
Step-2 roundtable rooms share one deployment-wide cap,
system_settings.json -> roundtable_room_limit (default enabled, 3 rooms).
GET/PUT /api/system-settings/roundtable-room-limit is the admin switch.
Occupancy is summed across every account: durable foreground leases keyed
by a hidden per-live-execution roomSessionId, in-flight LangGraph runs on
threads stamped with the corresponding roundtable_room_key, and executing
background jobs (queued / running / cancel_requested with a live
worker lease or a recent update). Different pages running the same saved draft
have different session ids and therefore take different rooms; all seats and
Step 3 calls within one live execution share its key. Run-manager inspection
is reconciliation-only: when a durable lease/job already owns the same
draft+user/task scope, stale or overlapping run keys in that scope are not
counted again. Merely opening the page,
viewing saved history, paused awaiting_input jobs, stale historical rows,
and Step-1 intent threads do not occupy a slot. Foreground-to-background
handoff replaces the foreground lease with the durable job occupancy. There is
no per-user pool. Admission is serialized by a database gate row, and the cap
is enforced at acquire, init, every model-producing run, and background-job
start. Full pools return HTTP 409; an enabled gate with missing storage or a
missing session id fails closed. The switch applies on the next admission
without a restart. Position and eight-department roundtables are not counted.
Roundtable backend jobs use a database unique active-dedupe key plus a leased
dispatcher, never process-local start deduplication. Cancellation is two
phase (cancel_requested then cancelled): the lease owner’s watcher normally
finalizes it, while the dispatcher reaps a cancellation whose lease has
expired after a worker crash. A worker that loses lease stops its local model
run immediately. Do not reintroduce direct executor.cancel() from the HTTP
cancel route, because it can cancel the watcher before the database state is
finalized. The boolean defaults on these new rows must use SQLAlchemy
false() rather than numeric 0, so fresh PostgreSQL schemas can be created.
Position roundtable selects the separately seeded built-in agent
position-roundtable-intent by passing intent_agent_id to the shared
/api/intent/init and /api/intent/stream endpoints. Its request lifecycle,
files, and SSE protocol match roundtable-intent, but its cap is five
user-assistance rounds. A single round may emit several independent
ask_clarification tool calls; those calls are separate cards, not one card
containing several numbered questions. Count that as one round by the
tool-calling AI message, never by card count. Topic-only requests must request
all material missing decisions as separate cards rather than being treated as a
resolved intent; after the fifth set is answered it must emit completed final
JSON without unresolved placeholders. The required arrays are
coreGoals, riskWarnings, keyPoints, and strategicSignificance. Do not
treat a fresh dedicated-agent ask_clarification as stale: it must win over
a same-run premature [INTENT_READY] and leave the UI waiting for the user.
Conversely, the prior turn’s historical clarification must not block the user
reply from completing. Do not
change roundtable-intent's {objective,constraints,assumptions} contract for
this feature. The position router accepts the new schema for activation and
injects all four sections into downstream position-node and closing-agent
context; legacy stored summaries are mapped read-only for compatibility.
/api/intent/init must create the empty Step-1 thread through
multi_agent._create_thread_direct, exactly like multi-agent initialization;
do not restore the old HTTP loopback to /api/threads, because deployed
Gateway ports/proxies may make the automatic page initialization fail while
later manual turns still work. If checkpoint creation fails or the parallel seat
task is cancelled, the helper compensates by deleting any partial checkpoint and
metadata row (the cancellation branch runs under asyncio.shield so a second
cancellation cannot abort it half-way). Streaming stays on the existing loopback
path.
ThreadMetaStore.create never issues a post-commit session.refresh(). Every
ThreadMetaRow column is assigned before commit and the session factory uses
expire_on_commit=False, so the returned dict is built from the in-memory row.
The removed read-back ran on a different pooled connection than the INSERT,
which a MySQL read/write-splitting endpoint can route to a lagging replica — the
row comes back empty and SQLAlchemy raises Could not refresh instance, surfacing
as 500 "Failed to create thread". Concurrency makes that far more likely, so a
single-user deployment never sees it. No caller uses the return value, and all
seven call sites (multi_agent._create_thread_direct, services.start_run,
routers/threads.create_thread, roundtable_inprocess_gateway,
scheduler/service, thread_shares, authz) benefit. Keep new create
implementations read-back-free; tests/test_position_roundtable_sessions.py:: test_thread_meta_create_never_reads_back_after_commit guards this.
Harness / App Split
The backend is split into two layers with a strict dependency direction:
- Harness (
packages/harness/deerflow/): Publishable agent framework package (deerflow-harness). Import prefix:deerflow.*. Contains agent orchestration, tools, sandbox, models, MCP, skills, config ? everything needed to build and run agents. - App (
app/): Unpublished application code. Import prefix:app.*. Contains the FastAPI Gateway API and IM channel integrations (Feishu, Slack, Telegram, DingTalk).
Dependency rule: App imports deerflow, but deerflow never imports app. This boundary is enforced by tests/test_harness_boundary.py which runs in CI.
Report Collaboration (AgentScope, in progress)
Independent of Workflow Studio and the existing roundtable. Plan: repo-root docs/agentscope-多智能体报告协作工作台-后端实施计划.md(RC-BE-000~018 已落地,下一票 RC-BE-019)。Spike baseline: docs/AGENTSCOPE_BASELINE.md.
- Config:
config.yaml → report_collaboration(deerflow.config.report_collaboration_config.ReportCollaborationConfig). Default off (enabled/worker_enabled). - Contracts:
app/report_collaboration/contracts/— snake_case Pydantic models matchingfrontend-web/src/report-collaboration/api/types.ts. SSE envelope uses camelCase (eventId/sessionId/runId).node_run_idis always${node_id}-attempt{attempt}. - Persistence:
deerflow.persistence.report_collaboration(report_collaboration_*tables, migrations20260905_01/20260906_01/20260907_01). SQL + memory stores; one active run per session viaactive_dedupe_key; command/message/run/version idempotency keys refuse cross-session reuse; eventseqtaken from the run row. Session delete removes children explicitly. - Gateway:
/api/report-collaboration(app/gateway/routers/report_collaboration.py).enabled=false→ 503. Pre-run intent dialogue uses the separately seeded built-in agentreport-collaboration-intentthrough the shared real/api/intent/init+/api/intent/streamLangGraph thread path. The browser consumes upstreammessages-tupleframes incrementally (text, provider reasoning and tool steps) and renders them in the normalMessageList; this path must never be replaced withPlannerAgent.ainvoke()or another wait-for-completion wrapper.POST /sessions/{id}/intent-turnsreceives only the completed streamed turn for durable mirroring; it does not invoke an intent model. AnINTENT_READYterminal summary is converted to the report requirement and automatically fulfilled throughPlanProposalService, with no first-plan confirmation click. The old pre-run/messagespath returnsUSE_STREAMING_INTENT.POST /sessions/{id}/plan-requestsremains for regeneration/compatibility. Workflow design after intent confirmation is LLM-generated, assembled against the safe agent catalog and validated (acyclic DAG, artifact types, allowlisted tools). Selecting a plan and starting a run remain independent commands.POST /runsfreezes role models, report-structure template and run budget. Rewrite / apply / restore are live (reporting/).GET /runs/{id}/streamis a read-only durable SSE tail (execution/live_hub.py): persist then publish, replay +after_seq/Last-Event-ID, heartbeat comments, token-delta coalesce before seq assignment. Disconnect does not cancel the run. Lifespan mountsReportCollaborationDispatcheronly whenenabledandworker_enabledare both true; default both false. - Runtime: AgentScope adapters live in
app/report_collaboration/agentscope_runtime/(not the publishable harness).ConversationReplicamaps AgentScope 2.0.5 events 1:1 onto collaboration SSE types; TeamSay isHINT_BLOCK.DeerFlowChatModelAdapterwrapscreate_chat_model(no second Provider config); role models are frozen onto nodeagent_snapshotatPOST /runsand missing/incapable models fail with 422 before the run is created. Fallback is recorded on the snapshot plus a durableheartbeataudit event. Nodes complete only after a validated artifact (app/report_collaboration/execution/completion.py).TaskLedger(execution/ledger.py) owns ready waves, attempts and terminal node status; AgentScope / coordinator suggestions never pick the next node. A writer-bearing run iscompletedonly afterReportFinalizerpublishes a formal version.WaveExecutor+QualityRoleKernel(execution/quality_roles.py) remain for fixtures: Verifier marks stale/contested claims, Analyst/Writer only cite supported IDs, Reviewerrevisereplans only the target write node plus review. Production worker injectsAgentScopeMemberKernel(agentscope_runtime/member_kernel.py) with DeerFlow tools and per-run budget.QualityGate(execution/quality_gate.py) is the claim-level gate: conflicts/missing units, unverified facts, coverage gaps, and unmapped review targets fail beforevalidated;ArtifactBoard.supersede_previoushides old attempts from downstream.ReportCollaborationLiveHub(execution/live_hub.py) fans out persisted envelopes to SSE viewers;PublishingReportCollaborationStoresanitizes secrets/privacy/hidden reasoning without truncating visible tool results, and writes redaction + event audits. Dispatcher (execution/dispatcher.py) atomically claims queued/recovering/expired-lease runs, heartbeats viarenew_lease, two-phase cancels (cancel_requested→finalize_cancel), and recovers orphans (execution/recovery.py): keep completed work, mark leftover in-flightinterrupted/superseded, open a newplannedattempt. Late artifacts after cancel/reclaim aresupersededand must not change terminal run status. Tests drivedispatch_once(); do not rely on the background loop. Per-run budgets (security/budget.py) fail-closed; per-user active-run caps are enforced atcreate_run. - Quality eval:
app/report_collaboration/eval/(RC-BE-018) — 20 annotated tasks (policy/market/enterprise/event/comparison ×4) with required angles, key facts, forbidden fabrications, templates and review rules. Deterministic scorer (no LLM judge) gates citation/angle/template/directed-mod; gold reports must pass, canned failures (including a roundtable-style essay) must fail and stay reproducible. Recommended runtime knobs live ineval/thresholds.pyand match currentReportCollaborationConfigdefaults (worker_enabledremains false). Tests:tests/test_report_collaboration_eval.py. - Runtime intervention:
app/report_collaboration/interventions/owns run-time natural-language commands.RuntimeIntentRouterAgentuses explicit rules first and a tool-free coordinator-model fallback only for ambiguity; its Pydantic decision cannot dispatch or mutate anything.ImpactAnalyzervalidates server-side node/agent ownership, resolves angle-specific lineage, and enforces confidence/cost/parallel-branch confirmation (intent_confirmation_threshold, default0.8).RuntimeCommandServicemirrors the human message, emits frozen command events, CAS-claims accepted commands, and applies them only throughTaskLedgersafe points. Rework persists command overlays, supersedes affected attempts/artifacts, creates monotonic attempts, and prevents late output from entering downstream. Waiting-node answers resume only those nodes.rerun_nodewithout a legal target does not expand to the whole graph; requirement patches apply only after rework succeeds; stuckexecutingcommands are reclaimed on takeover or lease timeout;command.cancelledis a durable SSE event. In-runPOST /sessions/{id}/messagesreturns 409 (RUN_ACTIVE). Formal rewrite candidates and version apply/restore live inreporting/(RC-BE-015). - Dependency: optional extra
report-collaboration→agentscope==2.0.5. Do not add AgentScope to the harnesspyproject.toml.
Tests: tests/test_report_collaboration_*.py.
Workflow Studio
Coze Studio is canvas-only; ChatDev is reference-only. Plan + status per phase: docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md; decisions: docs/adr/0001-workflow-studio-runtime.md; contracts: docs/WORKFLOW_SCHEMA_V1_ZH.md + docs/WORKFLOW_SSE_V1_ZH.md + docs/WORKFLOW_OPENAPI_DRAFT_V1.yaml. Master switch + all limits: config.yaml → workflows (deerflow.config.workflow_config.WorkflowConfig). workflows.agent_recursion_limit defaults to 250: LangGraph counts each model→tool research exchange as about two super-steps, so workflow agent/skill nodes must not fall back to a chat-sized 60-step limit. In the local Studio proxy, keep Rsbuild server.compress: false; dev gzip buffers small SSE frames and makes live planning/run output arrive only at completion despite the backend's no-transform / X-Accel-Buffering: no headers. The composer’s optional modelName must be validated against AppConfig.models: it selects the controller model and is copied only into newly generated candidate agent configs (or the projected direct-agent task), never injected into an already-confirmed proposal/run.
Execution kernel is a custom DAG scheduler, not a compiled LangGraph. LangGraph stays the kernel inside an agent/skill node. The reason is single-source-of-truth recovery: a re-leased or resumed run replays completed node outputs from workflow_node_runs plus the workflow_run_events log, so a second (checkpoint) copy of workflow-level state would have no defensible tie-breaker. See the ADR's phase-2 amendment.
Harness (packages/harness/deerflow/workflows/, never imports app.*):
schemas/events/errors/ports/validator/samples— frozen v1 contracts.expressions.py—{{ inputs.x }}/{{ nodes.n.data.y }}templates and conditions resolved by path walking + a small comparator set. Noeval. Roots are restricted toinputs/nodes/run/loop/env.runtime/engine.py— the scheduler. Every edge ispending→active(source chose this port) orpruned; a node is ready when its non-back incoming edges are all resolved and at least one isactive, and is skipped when all arepruned(skips propagate). That one rule covers fan-out, condition branching, and merge joins with no topological pre-pass. Theloopnode'scontinueback edge is the only permitted cycle; re-entry clears the body's results and invalidates its node runs.runtime/{sink,sql_runner,code_runner,context}.py,nodes/*(15 executors, including deterministicevidence_normalizerand durabledeep_research_write),security/{http_policy,sql_policy,secret_box}.py.
Persistence: workflow_definitions / workflow_versions (migration 20260828_01), workflow_runs / workflow_node_runs / workflow_run_events / workflow_run_artifacts (20260828_02), workflow_data_sources (20260828_03), and short-lived-but-durable workflow_planning_sessions / workflow_planning_proposals (20260831_01). Stores under deerflow.persistence.workflow_{runs,events,data_sources,planning} with SQL + in-memory implementations. Because planning proposal rows have a scalar FK rather than an ORM relationship, SqlWorkflowPlanningStore.create_session must flush the parent session before adding its children; otherwise SQLite can insert the child first and return a 500. A planning proposal is distinct from a run: its graph can be edited with optimistic graph_revision checks, but it becomes executable only once selected and atomically bound to one run.
App layer: workflow_dispatcher.py (lease scan → atomic claim; an expired lease is how a dead worker's run is reclaimed, so every worker can run the loop), workflow_executor.py (one lease's lifecycle: replay → engine under a run timeout with heartbeat + cancel watcher → exactly one terminal transition; also runs subworkflows inline, depth ≤ 3), workflow_retention.py (hourly sweep of workflows.retention.event_retention_days / run_retention_days), workflow_live_hub.py (in-process SSE fan-out), workflow_agent_runner.py (agent/skill nodes → lead agent; disable_tools=True means an empty bound-tool set for control-plane callers), workflow_deep_research_adapter.py (Evidence Pack → selected Deep Research sources/job/event projection; no internal HTTP call), and workflow_proposal_planner.py (calls the dedicated workflow-planner controller over visible-agent metadata, then constructs safe candidate graphs server-side). workflow-planner is a separate Workflow Studio control-plane agent; never reuse or alias roundtable-coordinator for workflow planning, and never let it dispatch a run, call research tools, or return arbitrary graph JSON. It always runs with tools and model thinking disabled.
Gateway routes (register static prefixes before workflows.router's /{workflow_id} catch-alls): /api/workflows CRUD/draft/validate/publish/copy, /api/workflows/node-types + /resources/*, /api/workflows/{id}/planning-sessions{/stream} + /api/workflows/planning-sessions/{session_id}{,/proposals/{proposal_id},/proposals/{proposal_id}/graph}, /api/workflows/{id}/runs + /api/workflows/runs/{run_id}{,/events,/stream,/cancel,/resume,/retry,/feedback,/artifacts}, /api/workflows/data-sources (+ /{id}/introspect, /{id}/validate-query), /api/workflows/embed/{tickets,verify}, /api/workflow_api/* Coze compat. The planning /stream endpoint sends product-safe real controller milestones (catalog, controller, selection, assembly, validation, complete) then a final result session; never stream agent catalog descriptions or raw planner JSON. Formal runs accept the selected planningSessionId and proposalId; creating the same pair again returns the already-bound run rather than launching another execution.
Guarantees worth not breaking: events are persisted then published, and the DB is authoritative for seq ordering (live frames at or below the replay cursor are dropped and re-read on the next poll, so a reconnect sees no gap; Last-Event-ID wins over ?after=); a run reaching a terminal status always gets a terminal event, synthesised if necessary, or SSE clients hang; resumeToken appears only in the run.awaiting_input event, never in the run view; POST /retry on a failed run copies completed node outputs onto a new run marked retry_of_run_id so the engine resumes from the failed node; POST /feedback never mutates its source run—on a terminal acyclic run it may target only a completed agent/skill, reuses completed nodes outside that node's downstream closure, records those reusedNodeIds alongside the feedback summary, and puts the bounded feedback only in RunContext.env.nodeFeedback[target] before creating a revision run; data-source secrets are write-only (encrypted via WORKFLOW_SECRET_KEY, decrypted only inside the executor, reads expose masked_target); the http node denies private/loopback/link-local targets by default (SSRF), sql_read allows one read-only statement with bound parameters, and the code node is off by default because its runner is a hardened subprocess rather than a container.
HTTP interface resources persist a non-secret base_url plus an allowed_methods allowlist (migration 20260829_01); request headers stay only in the encrypted secret blob. The resource catalog may return the address and methods to the owning user, but must never return headers or any decrypted secret. The executor receives HTTP metadata only through its data-source resolver.
The Workflow Studio agent/skill resource catalog is a read-only display projection over the existing
agent and skill ownership stores. It returns stable agentId / skillId, an agent's cached skill names,
and a createdBy display label. Resolve human labels from the immutable owner id at request time and
fall back to that id if lookup fails; built-in records must return 系统内置. Do not create a parallel
creator table or accept creator data from the browser.
evidence_normalizer is a deterministic data node, not an LLM: it consumes only configured/upstream
node results and emits a bounded evidencePack of claims with node provenance, role labels and gaps.
The planner inserts it between parallel research roles and the synthesis Agent. It must not treat
source text as verified fact or fetch any external resource, and its explicit sources[].nodeId values
must stay upstream-only under validate_workflow_graph.
deep_research_write is a durable report node, not an Agent prompt shortcut. It accepts only an
Evidence Pack, derives a session identity from the workflow run/node plus the topic, evidence snapshot and writing config, writes
the pack as selected other source rows with upstream-node provenance, and queues/reuses the existing
Deep Research regenerate job. Its live report_delta and persisted report_chunk frames are
deduplicated before becoming workflow node.output.delta events; only report prose is forwarded (never
planning/reasoning deltas). Workflow cancellation requests cancellation on that same job. Do not make it
self-call /api/deep-research/*, use Deep Research multi_agent inside this node, or treat Evidence
Pack claims as verified facts. The parallel-research candidate grants the node an explicit 1800-second
timeout; deployments that override workflows.node_timeout_seconds must keep it at least that high.
Tests: tests/test_workflow_schemas.py, tests/test_workflow_definitions.py, tests/test_workflow_engine.py, tests/test_workflow_runs.py.
Import conventions:
# Harness internal
from deerflow.agents import make_lead_agent
from deerflow.models import create_chat_model
# App internal
from app.gateway.app import app
from app.channels.service import start_channel_service
# App ? Harness (allowed)
from deerflow.config import get_app_config
# Harness ? App (FORBIDDEN ? enforced by test_harness_boundary.py)
# from app.gateway.routers.uploads import ... # ? will fail CI
Agent System
Gallery chat link. An agent's config.yaml may store chat_link (url, open_mode of blank/self, and light-app-shaped route_params). The gallery 「对话」 button opens that resolved URL; an empty url keeps the in-app chat. The list endpoint reads the mirrored agent_extras.chat_link column (nullable JSON, added by the startup column sweep) instead of parsing every config file. Create/update and directory backfill write the mirror; a skills-only set_extras leaves an existing link in place.
Lead Agent (packages/harness/deerflow/agents/lead_agent/agent.py):
- Entry point:
make_lead_agent(config: RunnableConfig)registered inlanggraph.json - Dynamic model selection via
create_chat_model()with thinking/vision support - Tools loaded via
get_available_tools()- combines sandbox, built-in, MCP, community, and subagent tools - System prompt generated by
apply_prompt_template()with skills, memory, and subagent instructions
agentfx built-in analyst + report convert skills. agentfx-analyst (「任务研判报告助手」) is seeded from app/gateway/routers/_agentfx_seed_assets/ before _sync_legacy_agents. After mandatory web_search, the agent writes each tool JSON to /mnt/user-data/workspace/hits/qN.json, writes task metadata as JSON data to the fixed /mnt/user-data/workspace/task-context.json, and runs one constant command — task-report-build (group all) — to fill the 14-category task-reports.json for the dashboard slots; untrusted task text never enters the shell command. The four per-page skills task-report-{enemy,our,env,judge} remain for re-running a single dashboard page. All five are thin CLIs over the shared stdlib engine skills/public/task-report-lib/convert_lib.py (no SKILL.md on the lib, so it never enters the skill list). The engine emits the exact category-specific shapes consumed by sq-report.ts, marks evidence-derived content with [检索证据], rejects cross-task partial merges, and atomically writes Unicode JSON. The model must not write_file that JSON. Because the sandbox maps /mnt/user-data/... onto a host dir, discover_user_data_root + resolve_sandbox_path map virtual paths to the current thread's literal user-data root; hits, task context and output are canonically confined there, including symlink and Windows drive checks. resolve_hit_files may try confined mapped/relative/basename candidates and reports every miss so the agent never has to write a debug script. Assemble of the 14 categories is the same function (assemble_records); the model must not copy or merge files. If the model is interrupted after convert, task-reports.json is already in outputs and the frontend import card also scans GET /api/threads/{id}/artifacts so入库 still works without present_files. Import remains task-report-import. Existing agent/skill directories are never overwritten. Tests: tests/test_task_report_convert.py, tests/test_agentfx_seed.py.
Forced-research built-in agent. forced-research-responder (「强制检索输出助手」) is seeded from app/gateway/routers/_forced_research_seed_assets/ before _sync_legacy_agents. ForcedResearchMiddleware is mounted only for this stable id. On every visible user turn it requires a successful information-gathering tool result before the model may finish; tool_choice=required is backed by an after_model -> model retry for gateways that ignore tool choice, with an eight-attempt failure cap that permits only an honest failure response. Skill discovery, reading SKILL.md, and writing a helper script do not satisfy the gate; web/knowledge/MCP results, non-skill uploaded-file reads, or successful Python skill-script execution do. Tool filtering removes present_files, skill_manage, task/schedule/memory/clarification surfaces. The middleware permits write_file/str_replace only for /mnt/user-data/workspace/*.py and permits bash only for .py execution without redirection/document paths, so skills can run scripts but the agent cannot create Markdown/document artifacts or write outputs. Tests: tests/test_forced_research_agent.py.
Elasticsearch offline operations built-in agent. elasticsearch-offline-ops (「Elasticsearch 离线运维助手」) and its same-name public skill are seeded from app/gateway/routers/_elasticsearch_offline_ops_seed_assets/ before _sync_legacy_agents. Seeding is missing-directory-only so administrator edits survive restarts. The agent intentionally has file:read, file:write, and bash but no web group: air-gapped guidance comes from the shipped version-parameterized deployment and troubleshooting references. Its SOUL requires the exact ES version/OS/architecture/topology before final commands, keeps Elasticsearch security/TLS enabled, separates the DeerFlow sandbox from the target ES hosts, and makes read-only evidence collection precede changes. scripts/collect_diagnostics.sh gathers Linux/service/log and authenticated cluster evidence without mutations or --insecure; credentials come from operator-owned netrc/header files rather than command arguments or an unrestricted curl config. scripts/analyze_diagnostics.py is a stdlib-only heuristic that emits matched evidence and a next read-only check, or insufficient_evidence; it never claims an automatic root cause. Tests: tests/test_elasticsearch_offline_ops_seed.py, tests/test_seed_assets.py.
Strategy-selection functional agents. app/gateway/routers/_strategy_selection_seed.py seeds nine stable folder-backed agents from _strategy_selection_seed_assets/ before _sync_legacy_agents: intent, governance/diplomacy/economy/security/public-opinion/law-policy angles, judge, and solution editor. Seeding is missing-directory-only and must never overwrite administrator changes. The global strategy_agent_roles registry classifies them independently of projects: intent/judge/solution are fixed, unique roles that may be configured but never added, disabled, reordered, or deleted; angle roles are a shared, ordered catalog that administrators may add, edit, disable (soft delete), restore, and reorder through /api/strategy-agents. Public GET /api/strategy-agents returns enabled identity/description metadata only; include_disabled=true or include_config=true is admin-only. Admin writes are POST /api/strategy-agents/angles, PUT /api/strategy-agents/{id}, and DELETE /api/strategy-agents/{id}; DELETE retains the folder-backed agent and role row to preserve historical references. Dynamic angles remain built-in folder-backed agents under the reserved strategy-selection-angle- id namespace, with web + file:read; a blank SOUL on create receives a safe role template. All strategy skill writes validate that names exist, are active, and are globally visible; generic agent CRUD cannot create or hard-delete reserved strategy IDs, and parallel execution rejects disabled roles. Angle configs reuse policy-knowledge; intent reuses intent-clarification-policy; the judge has no retrieval skill; the solution editor gets only file:write. Keep the SOUL contracts neutral and evidence-aware, with explicit de-escalation/civilian-protection framing and hard bans on operational combat guidance and targeted political manipulation. Tests: tests/test_strategy_selection_seed.py, tests/test_strategy_agent_management.py.
Only strategy-selection-intent has a clarification reply surface in the strategy workbench. parallel_agents must exclude ask_clarification for every other strategy-selection-* role and inject a direct-delivery guard: angle, judge, and solution stages use conservative assumptions, lower confidence/score where needed, list gaps under data limitations, and still finish in one pass. The judge brief repeats this rule at runtime because existing folder-backed agent SOUL files may be administrator-customized and are intentionally never overwritten. If a legacy backend nevertheless emits a judge clarification, the frontend treats it as a bounded repair attempt rather than asking the user to repeat the same manual run. Malformed or incomplete evaluation JSON receives the same bounded repair path; prior attempts are replaced in the live panel, never concatenated into duplicate final content.
POST /api/parallel-agents/run/stream must remain observable during silent skill calls. Each running seat emits an agent_progress heartbeat every five seconds with the latest translated tool/reasoning/body phase and monotonic elapsed seconds; an upstream iterator that produces no frame for 180 seconds is closed and that seat emits agent_error, while the other seats continue. Set the terminal flag before queuing agent_done / clarification so a late heartbeat cannot reopen a completed card. The strategy workbench renders the intent Q&A history, readable skill execution steps and live heartbeat on the affected agent card; judge and synthesis traces use the same step translation. Tests: tests/test_roundtable_model_fallback.py and frontend src/strategy-selection/lib.test.ts.
ThreadState (packages/harness/deerflow/agents/thread_state.py):
- Extends
AgentStatewith:sandbox,thread_data,title,artifacts,todos,uploaded_files,viewed_images - Uses custom reducers:
merge_artifacts(deduplicate),merge_viewed_images(merge/clear)
Runtime Configuration (via config.configurable):
thinking_enabled- Enable model's extended thinkingmodel_name- Select specific LLM modelis_plan_mode- Enable TodoList middlewaresubagent_enabled- Enable task delegation tool
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):
- ThreadDataMiddleware - Creates per-thread directories under the creator's stable isolation scope (
backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}); resolves persisted threads throughresolve_path_user_id(thread_id)(falls back toget_effective_user_id()/"default"when metadata is unavailable); Web UI thread deletion now follows LangGraph thread removal with Gateway cleanup of the local thread directory - UploadsMiddleware - Tracks and injects newly uploaded files into conversation
- SandboxMiddleware - Acquires sandbox, stores
sandbox_idin state - 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"] - LLMErrorHandlingMiddleware - Normalizes provider/model invocation failures into recoverable assistant-facing errors before later middleware/tool stages run
- GuardrailMiddleware - Pre-tool-call authorization via pluggable
GuardrailProviderprotocol (optional, ifguardrails.enabledin config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-inAllowlistProvider(zero deps), OAP policy providers (e.g.aport-agent-guardrails), or custom providers. See docs/GUARDRAILS.md for setup, usage, and how to implement a provider. - SandboxAuditMiddleware - Audits sandboxed shell/file operations for security logging before tool execution continues
- ToolErrorHandlingMiddleware - Converts tool exceptions into error
ToolMessages so the run can continue instead of aborting - SummarizationMiddleware (
DeerFlowSummarizationMiddleware) - Context reduction when approaching token limits (optional, if enabled). Non-destructive: overrides the stockbefore_model(which persists aRemoveMessage(REMOVE_ALL_MESSAGES)) to a no-op and instead compresses transiently inwrap_model_call? the model request is replaced with[summary, *recent]while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). Thesummarymessage ishide_from_uiand never persisted; summaries are cached per-thread in a process-wide, thread-safe LRU (_GLOBAL_SUMMARY_CACHE, bounded to 512 threads, sticky boundary). The cache is module-level — not on the middleware instance — becausemake_lead_agentrebuilds a fresh middleware on every run; an instance cache would be cold each turn and force a blocking summary LLM call on every turn of a long conversation. Keyed bythread_id(resolved likeThreadDataMiddleware), so a summary computed on one turn is reused on the next, paying for an LLM call only on the first threshold crossing or a genuine re-compaction. Threshold-first gating:_plan_compressionreturns passthrough (no compression at all) whenever the full current conversation is still under the trigger — the cache/summary substitution only engages once the real context crosses the configured trigger. Stickiness headroom (_apply_stickiness_headroom,_KEEP_HEADROOM_FRACTION=0.75): after thekeeppolicy picks a cutoff, the kept suffix is forced below ~75% of every trigger before summarizing. Without it akeepin messages (e.g. 20) can itself exceed a token trigger (e.g. 60000) when recent messages are large (tool outputs / pasted docs), leaving the "compressed" context still over the trigger so it re-summarizes on every follow-up question; the headroom makes the sticky boundary actually stick (re-compaction only after genuinely new content). When the genuine (blocking) summarize path runs, it emits a{"type":"context_compacting","message":"正在压缩上下文…"}event on LangGraph'scustomstream channel (the cheap reuse path stays silent); the chat frontend'sonCustomEventrenders it as a transient toast. Tests:tests/test_summarization_middleware.py - TodoListMiddleware - Task tracking with
write_todostool (optional, if plan_mode) - TokenUsageMiddleware - Records token usage metrics when token tracking is enabled (optional)
- TitleMiddleware - Two-phase auto-title (never shows "untitled"):
before_modelsets an instant question-derived provisional title the moment the first user message arrives (no LLM, zero latency), thenafter_modelasynchronously generates the final LLM title and replaces it (tracked via thetitle_provisionalstate flag). Normalizes structured message content before prompting the title model - MemoryMiddleware - Memory V2:
wrap_model_callinjects Hindsight recall transiently;after_agentauto-retains the turn to Hindsight - ViewImageMiddleware - Injects base64 image data before LLM call (conditional on vision support)
- DeferredToolFilterMiddleware - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
- SubagentLimitMiddleware - Truncates excess
tasktool calls from model response to enforceMAX_CONCURRENT_SUBAGENTSlimit (optional, ifsubagent_enabled) - LoopDetectionMiddleware - Detects repeated tool-call loops; hard-stop responses clear both structured
tool_callsand raw provider tool-call metadata before forcing a final text answer - ContextWindowGuardMiddleware - Final transient request-size guard; enforces the selected model's
max_input_tokensafter every context/tool injection - ClarificationMiddleware - Intercepts
ask_clarificationtool calls, interrupts viaCommand(goto=END)(must be last) 8a. ToolMetricsMiddleware - Appended immediately afterToolErrorHandlingMiddlewarein_build_runtime_middlewares, so it sits deepest in thewrap_tool_callchain. Records onetool_call_metricsrow per tool/skill/MCP invocation ? timing duration and bucketing failures into a coarseerror_category(rate_limited / ip_blocked / auth_error / timeout / network_error / not_found / empty_result / tool_error). It catches both raised exceptions (before #8 converts them) and self-handled errors returned as errorToolMessages (search tools that swallow HTTP 429/403). It also captures the targeted Agent Skill intotool_call_metrics.skill_name: the lead agent invokes a skill byread_file-ing its/mnt/skills/<category>/<name>/SKILL.mdpath (per the<skill_system>prompt block ? there is no dedicated "use skill" tool), soskill_nameis recovered by scanning every tool call's string arguments for theskills/{public,custom}/{name}/path shape (_SKILL_PATH_RE), plus the explicitnameargument ofskill_view/skill_manage/skill_archive.skill_view-specific success detection handles its swallowed failures (Skill 'x' not found.returned as a plain string rather than raised). Regression coverage:tests/test_tool_metrics_middleware.py. Writes are best-effort fire-and-forget. Per-run skill merging:SqlToolMetricsStore.recordupserts skill rows by(run_id, skill_name)instead of inserting one row per call ? a skill touched by several tool calls / retries inside one run collapses to a single row. Any success in the run wins (a skill that failed then finally succeeded is recorded as onesuccess); a skill that only ever failed yields oneerrorrow whoseerror_messageaccumulates every attempt's message; durations are summed. Non-skill tool calls, and skill calls with norun_id, keep one row each. The read?merge?write is guarded by a per-event-loopasyncio.Lock. Surfaced by the adminGET /api/admin/tool-metricsrouter (which has akind=skillfilter and a per-skill/summarybreakdown). - PromptPrefixMiddleware - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's
additional_kwargs['prompt_prefix']by the Gateway (app.gateway.services) and prepended to content here at call time, so the persisted/displayed message stays prefix-free - SummarizationMiddleware (
DeerFlowSummarizationMiddleware) - Context reduction when approaching token limits (optional, if enabled). Non-destructive: overrides the stockbefore_model(which persists aRemoveMessage(REMOVE_ALL_MESSAGES)) to a no-op and instead compresses transiently inwrap_model_call? the model request is replaced with[summary, *recent]while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). Thesummarymessage ishide_from_uiand never persisted; summaries are cached per-thread in a process-wide, thread-safe LRU (_GLOBAL_SUMMARY_CACHE, bounded to 512 threads, sticky boundary). The cache is module-level — not on the middleware instance — becausemake_lead_agentrebuilds a fresh middleware on every run; an instance cache would be cold each turn and force a blocking summary LLM call on every turn of a long conversation. Keyed bythread_id(resolved likeThreadDataMiddleware), so a summary computed on one turn is reused on the next, paying for an LLM call only on the first threshold crossing or a genuine re-compaction. Threshold-first gating:_plan_compressionreturns passthrough (no compression at all) whenever the full current conversation is still under the trigger — the cache/summary substitution only engages once the real context crosses the configured trigger. Stickiness headroom (_apply_stickiness_headroom,_KEEP_HEADROOM_FRACTION=0.75): after thekeeppolicy picks a cutoff, the kept suffix is forced below ~75% of every trigger before summarizing. Without it akeepin messages (e.g. 20) can itself exceed a token trigger (e.g. 60000) when recent messages are large (tool outputs / pasted docs), leaving the "compressed" context still over the trigger so it re-summarizes on every follow-up question; the headroom makes the sticky boundary actually stick (re-compaction only after genuinely new content). When the genuine (blocking) summarize path runs, it emits a{"type":"context_compacting","message":"正在压缩上下文…"}event on LangGraph'scustomstream channel (the cheap reuse path stays silent); the chat frontend'sonCustomEventrenders it as a transient toast. Tests:tests/test_summarization_middleware.py - TodoListMiddleware - Task tracking with
write_todostool (optional, if plan_mode) - TokenUsageMiddleware - Records token usage metrics when token tracking is enabled (optional)
- TitleMiddleware - Two-phase auto-title (never shows "untitled"):
before_modelsets an instant question-derived provisional title the moment the first user message arrives (no LLM, zero latency), thenafter_modelasynchronously generates the final LLM title and replaces it (tracked via thetitle_provisionalstate flag). Normalizes structured message content before prompting the title model - MemoryMiddleware - Queues conversations for async memory update (filters to user + final AI responses)
- ViewImageMiddleware - Injects base64 image data before LLM call (conditional on vision support)
- DeferredToolFilterMiddleware - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
- SubagentLimitMiddleware - Truncates excess
tasktool calls from model response to enforceMAX_CONCURRENT_SUBAGENTSlimit (optional, ifsubagent_enabled) - LoopDetectionMiddleware - Detects repeated tool-call loops; hard-stop responses clear both structured
tool_callsand raw provider tool-call metadata before forcing a final text answer - ContextWindowGuardMiddleware - Final transient request-size guard; enforces the selected model's
max_input_tokensafter every context/tool injection - ClarificationMiddleware - Intercepts
ask_clarificationtool calls, interrupts viaCommand(goto=END)(must be last)
Configuration System
Main Configuration (config.yaml):
Setup: Copy config.example.yaml to config.yaml in the project root directory.
Config Versioning: config.example.yaml has a config_version field. On startup, AppConfig.from_file() compares user version vs example version and emits a warning if outdated. Missing config_version = version 0. Run make config-upgrade to auto-merge missing fields. When changing the config schema, bump config_version in config.example.yaml.
Config Caching: get_app_config() caches the parsed config, but automatically reloads it when the resolved config path changes or the file's mtime increases. This keeps Gateway and LangGraph reads aligned with config.yaml edits without requiring a manual process restart.
Configuration priority:
- Explicit
config_pathargument DEER_FLOW_CONFIG_PATHenvironment variableconfig.yamlin current directory (backend/)config.yamlin parent directory (project root - recommended location)
Config values starting with $ are resolved as environment variables (e.g., $OPENAI_API_KEY).
ModelConfig also declares use_responses_api and output_version so OpenAI /v1/responses can be enabled explicitly while still using langchain_openai:ChatOpenAI.
write_file_transport defaults to native and remains available for backward
compatibility. For a gateway that streams ordinary text but buffers long tool
arguments, administrators should use Settings → File streaming compatibility.
That runtime switch applies buffered_gateway to every compatible configured
model from the next question without restarting the Gateway. The model still
selects the original write_file, narrowed to
path/description/append for that request. The adapter then starts a tool-free
ordinary-content body request inside the same logical ChatModel stream and
projects it onto canonical incremental write_file chunks. LangGraph and
ToolNode receive one original writer call; begin_write_file is neither exposed
nor executed in this mode. Only a normal terminal frame after at least one body
byte appends the boolean commit marker. Provider reasoning and a leading inline
<think> stay outside the file; empty bodies, truncation and unexpected tool
calls abort before sandbox I/O. Ordinary answers remain a single provider
request. Turning the switch off restores each model's configured/native
transport. Native models must remain byte-for-byte unchanged while the switch
is off. artifact_renderer and text_bridge exist for older configurations
and are not the recommended buffered-gateway transport.
Extensions Configuration (extensions_config.json):
MCP servers and skills are configured together in extensions_config.json in project root:
Configuration priority:
- Explicit
config_pathargument DEER_FLOW_EXTENSIONS_CONFIG_PATHenvironment variableextensions_config.jsonin current directory (backend/)extensions_config.jsonin parent directory (project root - recommended location)
Gateway API (app/gateway/)
FastAPI application on port 8001 with health checks at GET|HEAD /health, /api/health, and /api/langgraph/health. These exact paths are always public in auth_middleware.PUBLIC_HEALTH_PATHS, including when authentication is enabled; reverse-proxy prefixes and trailing slashes are normalized before the whitelist check. Set GATEWAY_ENABLE_DOCS=false to disable /docs, /redoc, and /openapi.json in production (default: enabled). Regression coverage: tests/test_health_auth_bypass.py.
Routers:
| Router | Endpoints |
|---|---|
Models (/api/models) |
GET / - list models; GET /{name} - model details |
MCP (/api/mcp) |
GET /config - get config; PUT /config - update config (saves to extensions_config.json) |
Skills (/api/skills) |
GET / - list skills; GET /{name} - details; PUT /{name} - update enabled; POST /install - install from .skill archive (accepts standard optional frontmatter like version, author, compatibility) |
Memory V2 (/api/memory/v2) |
GET / - builtin data (USER.md/MEMORY.md); POST/PUT/DELETE /entries - add/replace/remove entry; GET/PATCH/DELETE /config - effective config + per-user overrides; POST /search - Hindsight recall/reflect proxy |
Agents (/api/agents) |
GET / - list built-in, owned, and published agents; POST / - create user-owned agent; GET/PUT/DELETE /{id} - read/update/delete by stable agent id; GET /check - validate non-empty display name (duplicates allowed) |
Skills (/api/skills) |
GET / - list skills (optional search fuzzy-name + tag_id filters; each item carries tags); GET /{name} - details; PUT /{name} - update enabled; POST /install - install from .skill archive (accepts standard optional frontmatter like version, author, compatibility) |
Skills (/api/skills) |
GET / - list skills (optional search fuzzy-name + tag_id filters; each item carries tags + owner-authored detail); GET /{name} - details; PUT /{name} - update enabled; PUT /{name}/detail - set the long-form detail text (custom skill: owner or admin; built-in/public: admin only ? materializes a skills row on demand); POST /install - install from .skill archive (accepts standard optional frontmatter like version, author, compatibility) |
Skills (/api/skills) |
GET / - list skills (optional search fuzzy-name + tag_id filters; each item carries tags + owner-authored detail); GET /{name} - details; PUT /{name} - update enabled; PUT /{name}/detail - set the long-form detail text (custom skill: owner or admin; built-in/public: admin only ? materializes a skills row on demand); GET /{name}/content - raw SKILL.md source, restricted to an admin or the publisher (custom-skill owner); other users get 403 and see only description + detail; POST /install - install from .skill archive (accepts standard optional frontmatter like version, author, compatibility) |
Skills (/api/skills) |
GET / - list skills (optional search fuzzy-name + repeatable tag_ids multi-select filter, OR-matched; each item carries tags + owner-authored detail); GET /{name} - details; PUT /{name} - update enabled; PUT /{name}/detail - set the long-form detail text (custom skill: owner or admin; built-in/public: admin only ? materializes a skills row on demand); GET /{name}/content - raw SKILL.md source, restricted to an admin or the publisher (custom-skill owner); other users get 403 and see only description + detail; POST /install - install from .skill archive (accepts standard optional frontmatter like version, author, compatibility) |
Memory (/api/memory) |
GET / - memory data; POST /reload - force reload; GET /config - config; GET /status - config + data |
Agents (/api/agents) |
GET / - list built-in, owned, and published agents (optional search + repeatable tag_ids multi-select filter, OR-matched; each item carries tags and featured_order); POST / - create user-owned agent; GET/PUT/DELETE /{id} - read/update/delete by stable agent id; GET /check - validate non-empty display name (duplicates allowed); GET /selector - homepage agent picker (single admin-curated list, visibility-filtered, sorted by featured_order asc); PUT /{id}/featured - admin sets/clears the homepage featured_order (must be published=true to feature; passing order: null clears unconditionally); PUT /featured/order - admin bulk-reorders the homepage list (body {agent_ids: [...]} ? indices become 0..N-1) |
Tags (/api/tags) |
GET / - list tags (optional tag_type filter; open to all); POST / - create tag (admin); PUT /{id} / DELETE /{id} - update/delete tag (admin); GET /assignments/{target_type}/{target_id} - tags on a resource; PUT /assignments/{target_type}/{target_id} - replace a resource's tags (admin). Tags are typed agent/skill/scheduled_task/recommended_question/light_app and may only tag resources of their own type. |
Upload policy (/api/upload-policy) |
Database-backed global singleton. GET / is authenticated; PUT / and POST /reset are admin-only atomic replacements with revision/audit metadata. Startup seeds the real database row with 20 files, 100 MiB total, documents/images 10 MiB each, spreadsheets 5 MiB, and large-file budgets 12k/32k chars. The validated row is also published to the harness runtime registry without introducing an app.* import. |
Uploads (/api/threads/{id}/uploads) |
POST / reads the latest database policy, enforces category extensions/sizes/total/count, validates signatures/OOXML containers/text encoding, then commits the whole staged batch or rolls it back; optional document conversion uses <original-name>.<ext>.md. GET /limits returns the full effective policy plus compatibility fields; GET /list lists; DELETE /{filename} deletes. Uploaded raster images also receive a signed public HTML URL (/api/public/html-images/{token}) so generated pages can use them as background-image. |
Task Deep-link Threads (/api/task-deeplink-threads) |
Dedicated shared history index for the 3qfx / rwfx / xdfx single-agent task workspaces. GET /?task_id=&go_path=&limit= performs an indexed (task_id, go_path) lookup in task_deeplink_threads and joins threads_meta by thread_id for the current title/status/timestamps; LangGraph checkpoints remain the source of truth for messages. Thread metadata carrying taskRecordId + taskGoPath is dual-written atomically by both SQL and memory thread stores and deleted with the thread. 3qfx/rwfx use the task id as taskRecordId; xdfx uses the original action id while retaining the resolved real task id in metadata.taskId. thread_type=task_deeplink keeps these rows out of general recent chats; explicit taskId searches still opt out of that exclusion for compatibility. Alembic 20261019_01 is schema-only and intentionally does not backfill legacy task threads. Frontend hook: core/threads/use-task-deeplink-threads.ts. Tests: packages/harness/tests/test_task_deeplink_threads.py, packages/harness/tests/test_thread_meta_task_lookup.py. |
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 search (POST /threads/search, LangGraph-compat) |
Conversation listing. System threads are excluded by default: rows whose metadata.system is truthy (scheduler runs, roundtable workers) or whose thread_type is a known system type (task-analysis seat runs included; older seat rows are also matched by task_analysis_seat) never crowd the user's list; a caller whose metadata filter explicitly references thread_type/system (e.g. studio notebooks) opts out of the exclusion. New query field = case-insensitive title (display_name) substring search, used by the chats page's server-side pagination. POST /threads/count takes the same request body (limit/offset ignored) and returns {total} under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (ThreadMetaStore.count). ThreadMetaStore.search gained exclude_system/query params; JSON-blob filters batch-scan until the requested limit/offset window is full (so pagination over a heavily-filtered set never under-fills), with a thread_id tie-breaker on the updated_at DESC ordering for stable OFFSET paging. Tests: tests/test_thread_meta_search.py |
Artifacts (/api/threads/{id}/artifacts, /api/artifact-library, /api/strategy-project-artifacts/{id}/download) |
GET /{path} streams thread 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. GET /api/threads/{id}/artifact-preview?path= returns bounded text, using streaming statistics plus deterministic head/middle/tail samples above the database threshold. GET /api/artifact-library combines every current-user normal thread's authoritative outputs/ files with completed strategy-selection syntheses projected from the owner-scoped parallel_agent_tasks.messages snapshot. Strategy items use source_type=strategy, carry strategy_project_id / strategy_name / strategy_stale, and explicitly distinguish strategy_source_kind=strategy_selection from an analysis_project_mode=strategy thread (analysis_project); virtual items also expose download_url, so clients never infer routing from sentinel thread/path values. Search/source filters cover both. workflowStale=true keeps the last completed historical synthesis visible and downloadable. The dedicated strategy download route revalidates current-user ownership and snapshot completion and serves Markdown without pretending the virtual item is a thread artifact. Uploads and image/audio/video media remain excluded. Library metadata is persisted in artifact_library_items; list/search/filter/count/pagination read only this user-scoped SQL index. A generation-fenced artifact_library_sync_states lease schedules low-concurrency filesystem/task reconciliation in the background on first/stale reads or refresh=true, keeps the last successful snapshot visible during refresh/failure, and reports sync_status / syncing / indexed_at / sync_error to the client. Files themselves remain authoritative on disk and are never copied into the database. |
Word export (POST /api/writing/export/docx) |
Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. app/gateway/word_export.py builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering (一、 / (一) / 1.), two-character first-line indent on heading levels 1–3, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded app/gateway/assets/fonts/方正小标宋简体.ttf. Before numbering it removes complete manual heading prefixes (including 2.1 / 2.1.1) and drops Markdown horizontal rules from Word output. The ordinary-Q&A Lead Agent can emit the matching Markdown heading forms and avoid unordered bullet lists when the admin enables prompt_prefix.ordinary_qa_markdown_format_enabled in system_settings.json; the switch defaults off, while writing setup, writing mode, notebook, scheduled runs, and SOUL-driven custom agents keep their independent prompts. The font loader validates its internal family, SHA-256, and OpenType embedding permission (OS/2.fsType) before export. Tests: tests/test_word_export.py, tests/test_es_query_routing_prompt.py. |
Deep Research (/api/deep-research) |
Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. basic/quick use the compact runner; detailed creates bounded subtopics, searches/writes sections concurrently, and assembles a report; deep recursively searches follow-up questions under a deterministic breadth/depth query budget. multi_agent is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits awaiting_input, the recorder stores the interrupt and releases the lease, and POST /jobs/{id}/resume rebuilds the graph from the persisted approved/revised plan. A bounded custom_outline is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. The compact writer validates generated Markdown against that frozen selection: fixed requires every non-placeholder heading with the same level and order, while adaptive retries only when no core template heading survives. That correction shares the single existing retry budget (never more than two writer calls), and its deterministic fallback is outline-shaped as well. When custom_outline is empty (the workbench default — the user did not pick a report structure), chat collection first calls POST /sessions/{id}/intent-outline after the collector turn ends: the chosen model plans a Markdown skeleton from the topic plus collected materials, the outline is frozen into the session snapshot and shown in the「开始撰写」form for editing. If that step is skipped or fails, basic/quick still call generate_intent_outline at write time, emit plan_created (source: intent), then write through the same shared writer; generation failure is a recoverable intent_outline_fallback warning and does not abort the report. Completed reports support owner-scoped follow-up (GET /sessions/{id}/messages, POST /sessions/{id}/chat): context is only the current report, selected sources and recent messages; source-id citations are persisted, while allow_new_research=true is explicitly rejected. Owners may change that evidence set only after the job stops via PATCH /sessions/{id}/sources/{source_id}; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by config.yaml -> deep_research.image_generation: the Harness OpenAICompatibleImageProvider requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png |
| Gateway auth | Owner-scoped Deep Research routes await the platform's asynchronous get_current_user resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via /mnt/user-data/outputs/<name> before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. |
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 /../run-messages - cross-run event-store messages with feedback. The separate GET /api/threads/{id}/messages contract remains the chronological latest-checkpoint transcript; GET /../token-usage - aggregate tokens |
Answer feedback (/api/threads/{id}/messages/{mid}/feedback) |
Message-scoped PUT/DELETE, GET /feedback-context, and batched POST /api/threads/{id}/feedback-contexts; persists excellent/poor ratings, six stable poor-reason codes and optional detail idempotently per user/thread/message/agent. Legacy run-scoped feedback routes remain compatible. Admin-only GET /api/agents/{agent_id}/feedback-score uses the project-wide system_role == "admin" rule (and preserves auth-disabled mode) and returns a Bayesian user-feedback answer-quality score, raw task-success rate, weighted response-quality dimensions and reason counts; it is explicitly not a complete agent-capability evaluation. |
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 |
Open Chat (/api/open/chat) |
Unauthenticated open Q&A entry for external systems (the /api/open/ prefix is whitelisted in auth_middleware._PUBLIC_PATH_PREFIXES + CSRF-exempt in csrf_middleware.should_check_csrf). POST /chat {message | messages[], model_name?, agent_name?, agent_id?, thinking_enabled=false, strip_think=true, thread_id?, recursion_limit=100} → resolves the agent by Chinese display name (agent_name, matched against each .deer-flow/agents/*/config.yaml name via _resolve_agent_id; explicit agent_id wins if given), builds the lead agent via make_lead_agent (so the chosen agent's SOUL/tools/skills all apply), ainvokes it once (stateless — no checkpointer, no thread persistence; runs under the "default" user bucket), and returns only the final {answer, model_name, agent_name, agent_id, thinking_enabled, thread_id} (no intermediate tool-call/process). POST /chat/stream is the SSE streaming variant (same request body) for long tasks — the blocking /chat produces no downstream bytes for the whole run, so a front nginx proxy_read_timeout (default 60s) cuts it off with a 504; the stream keeps the connection warm (a : keepalive SSE comment every _HEARTBEAT_SECONDS=15s via a background producer-task + queue, plus event: delta AI-text increments and event: progress), and ends with event: result carrying the same final {answer, …} JSON (or event: error). Clients only need to read event: result; intermediate events are ignorable. Both endpoints share _build_run_context (config/agent/thread). GET /agents lists {name, id} so callers know what agent_name to send. Thinking off is reliable: when thinking_enabled=false the route also sets thinking_force_disabled=true so the lead agent calls create_chat_model(force_disable_thinking=True), which injects three OpenAI-compatible "thinking off" shapes into extra_body — top-level enable_thinking=false (SiliconFlow / DashScope hybrid models like deepseek-ai/DeepSeek-V4-* on api.siliconflow.cn — the knob the nested chat_template_kwargs shape does NOT reach), chat_template_kwargs.enable_thinking=false (vLLM/Qwen native), and thinking.type="disabled" (Zhipu GLM) — even for models that only declare supports_thinking: true. For the web-UI path (which sends thinking_enabled=false but NOT force_disable), SiliconFlow models need when_thinking_disabled: {extra_body: {enable_thinking: false}} in their config.yaml entry (added to deepseek-ai/DeepSeek-V4-Flash) for thinking-off to take effect. Sets is_scheduled_run=true internally to skip the auto-title LLM call. Generated-file return (include_files=true, default on): an agent that produces its report as a file (writes to /mnt/user-data/outputs/ + present_files) returns only a short summary in answer; to recover the real deliverable both endpoints now also return `files: [{name, virtual_path, encoding(text |
Open 3qfx Ask (/api/open/3qfx/ask) |
Unauthenticated open entry that, given just a 任务id, directly kicks off the 3qfx(三情分析)「第一次问答」as a real persisted conversation (sibling of /api/open/chat; same /api/open/ auth-whitelist + CSRF-exempt). POST /ask {task_id, message?, model_name?, thinking_enabled=false} → (1) resolves the 3Q agent from app.state.business_mapping_store.get_mapping("3Q") (the same agent the frontend deep-link goPath=3qfx uses; 503 if unconfigured); (2) fetches the task detail server-side (consumer cop-task-detail) and builds the opening text identical to the frontend buildOpeningText 3qfx branch — 任务名称:…\n任务内容:…\n任务类型:…\n任务id(task_id)为{task_id}(taskType 1/2/3 → 打击/塑势/防御,无法识别则省略) (an explicit message overrides and skips the fetch); (3) creates a fresh thread tagged metadata.{taskId, agent_id} (the exact filter useTaskThreads uses, and metadata.taskId makes it task-deeplink-shared / creator-path-resolved) and starts a background persisted run via start_run(..., enforce_access=False) (writes the checkpointer, runs under the "default" bucket); (4) returns immediately {thread_id, run_id, agent_id, status, opening_text} — the caller just needs "started", then watches the run in the 3qfx 任务工作区 conversation list. start_run gained a keyword-only enforce_access (default True); the open entry passes False so the admin-configured agent isn't rejected by the per-(missing)-login visibility check. Consumer fetch config lives in config.yaml → task_deeplink: cop_task_detail_url (full URL, appends ?taskId=), cop_task_detail_authorization (default Bearer admin), cop_task_detail_test + mock.cop_task_detail (offline fake when the upstream is unreachable — on in this deployment). Router app/gateway/routers/open_3qfx.py; tests tests/test_open_3qfx.py. |
Thread Shares (/api/thread-shares) |
Per-conversation sharing with two modes (thread_shares.mode). import: POST / {thread_id, mode} - create (or reuse) a revocable share code; GET /{code}/preview + POST /{code}/import - copy that conversation into the caller's account (fresh thread_id via app/gateway/thread_copy.py + new threads_meta row + user-data copy; dedup on metadata.shared_origin). view: a public, read-only static page ? see Public Shares below. Both modes: GET / - list my codes; DELETE /{code} - revoke. |
Public Shares (/api/public/shares) |
Unauthenticated (the /api/public/ prefix is whitelisted in auth_middleware._PUBLIC_PATH_PREFIXES). GET /{code} - read-only conversation snapshot (title + serialized messages + artifacts) for a view-mode share; messages are read straight from the latest checkpoint. GET /{code}/files/{path} - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (text/html, application/xhtml+xml, image/svg+xml) is forced to download (_public_share_disposition); everything else is served inline. Missing/revoked/non-view codes all return 404. |
Roundtable Drafts (/api/roundtable-drafts) |
Strictly per-user persistence for the roundtable-planning page (replaces the old browser localStorage). GET / - list drafts (lightweight meta — incl. chain_title, the business-chain name used this session, cheaply regex-extracted from the in-memory step2 blob in _draft_to_meta/_extract_chain_title; null = no chain / 自由模式; surfaced on the 第一步「分析记录」history card + sidebar list instead of the old「第 X 步」); POST / - create; GET/PUT/DELETE /{id} - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in PortableLongText. Recommendation history bound to a draft: GET /{id}/recommendations - list newest-first; POST /{id}/recommendations - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are not here — they live in the separate /api/roundtable-task-drafts store (see below) so personal history never mixes with task records. Backed by deerflow.persistence.roundtable_drafts (tables roundtable_drafts, roundtable_recommend_history); store wired in deps.py as app.state.roundtable_draft_store. Tests: tests/test_roundtable_task_drafts.py. |
Roundtable Task Drafts (/api/roundtable-task-drafts) |
Task-scoped, unpartitioned store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed only by task_id (one task → many records), never partitioned by user — any authenticated caller may read/write/delete a task's records (created_by is audit-only). Physically separate from roundtable_drafts so the two never mix. GET /by-task/{task_id} - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via RoundtableJobRepository.status_map_by_drafts (each meta also carries chain_title, same _extract_chain_title regex as the personal store); GET /by-task/{task_id}/latest - newest draft (回显最新一条); POST / - create (task_id required); GET/PUT/DELETE /{id} - full read / partial update / delete (all user-agnostic). Backed by deerflow.persistence.roundtable_task_drafts (table roundtable_task_drafts, auto-created by create_all; migration 20260618_01); store wired in deps.py as app.state.roundtable_task_draft_store. Existing task_id-bearing roundtable_drafts rows are migrated over by scripts/migrate_roundtable_task_drafts.py. Tests: tests/test_roundtable_task_drafts.py, tests/test_roundtable_drafts_by_task.py. |
Roundtable Diagnostics (/api/roundtable-diagnostics) |
会商全流程诊断日志 (报错 + 关键事件). One row per event in roundtable_diagnostics: {scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (warning/error — **info 级运行日志不再收集**:record_diagnostic在写库前丢弃level=="info",只留报错;begin/consensus/gather/job_started 等纯进度事件即使仍调 .diag(level="info") 也成 no-op,故诊断页 = 报错日志页), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}. Reads are admin-only: GET / - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; scope accepts a comma-separated set so 前端「前台调用」= foreground,client) + paginate (newest-first), each item enriched with userName (user_id → 邮箱 @-prefix via get_local_provider().get_user, best-effort batched cache); GET /facets - distinct values for filter dropdowns; POST /admin/cleanup - retention purge. POST /report is NOT admin-gated — any authenticated user's frontend posts the errors the user actually hit (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as scope="client". Backed by deerflow.persistence.roundtable_diagnostics (table roundtable_diagnostics, auto-created by create_all; migration 20260619_01); store wired in deps.py as app.state.roundtable_diagnostic_store + a process-level default store (register_default_store) so the harness-side engine/gateway can write across the app/harness boundary. Writers (all best-effort — diagnostics never break a run): background path uses DiagnosticsRecorder (bound to job ctx) threaded through RoundtableJobExecutor → JobProgressWriter.diag(...) (engine transitions/step3 failures/empty-delivery) + InProcessRoundtableGateway (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses app/gateway/roundtable_diag.py::record_foreground_diag in multi_agent.py/intent.py/recommend.py error frames. Bug-fix shipped alongside: InProcessRoundtableGateway._run_agent_turn guards each agent run with an inactivity (idle) timeout (_await_run_with_idle_guard, ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS default 1200s = 20min, tuned high for unstable intranet models) — not a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits zero StreamBridge events for idle_timeout seconds straight (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a seat_turn_timeout diagnostic. Activity is observed via app.state.stream_bridge (the worker publishes every chunk there). Optional absolute cap ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS (default 0 = off). Frontend: admin page pages/RoundtableDiagnosticsPage.tsx (route /page/strategy/admin/roundtable-diagnostics), opened from the 会商页右上角设置按钮 (admin only — handleSettingsClick in roundtable-planning/pages/RoundtablePlanningPage.tsx); admin read client strategy-components/api/roundtable-diagnostics.ts. Client error reporter roundtable-planning/api/diagnostics-report.ts (reportRoundtableError fire-and-forget + keepalive, setRoundtableErrorContext for draft/task attribution set by the page, stageForRoundtableAgent) is called at every client-observable failure: multi-agent.ts (streamMultiAgent — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE {status:"error"} frame + worker event:error frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a reported flag; initMultiAgent HTTP/error-frame/incomplete/mid-stream), intent.ts (Step1 HTTP+error-frame), recommend.ts (Step2 recommend HTTP+error-frame), roundtable-jobs.ts (job api/stream). The admin page's first filter is a 后台挂起 / 前台调用 toggle (frontend = foreground,client); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: tests/test_roundtable_diagnostics.py, tests/test_roundtable_inprocess_timeout.py. Seat skill-recognition (弱模型补偿): a seat = one custom agent run whose configured skills (agent config.yaml → skills) are injected only via the system prompt's <skill_system> block (model must read_file the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task, *, chain_enabled=False) appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") to the seat's per-turn task (human turn) — far stronger pull on weak models than the system prompt. Wired into both seat paths: foreground multi_agent._special_run (only role in {seat, seat_parallel}; Step3 singletons report/summary/dashboard/ingest excluded) + background InProcessRoundtableGateway.run_seat. No-op (returns task unchanged) when the seat has no configured skills. 2026-06-20: default-off + two opt-in switches (OR semantics) — was global-on, now injects only when either the seat agent's own switch (AgentConfig.seat_skill_directive, edited in the agent edit dialog agent-card.tsx + agent-detail-panel.tsx) or the per-seat switch on that agent within the selected business chain is on. The per-seat chain switch lives in the chain's seats JSON (ChainSeatSchema.seat_skill_directive, no DB column — rides the existing JSON like the per-seat model override), edited via a small ✨ icon → dropdown (Switch + description) on each agent card in BusinessChainEditorPage. It is threaded per-seat to each seat run: foreground multi_agent per-call field seat_skill_directive (orchestration looks it up per agentId from a {agentId: bool} session map) → _special_run → chain_enabled; background as the job field seat_skill_directives: dict[str,bool] (StartJobRequest → StartParams → deps factory → InProcessRoundtableGateway._seat_skill_directives) → run_seat looks up agent_id → chain_enabled. ROUNDTABLE_SEAT_SKILL_DIRECTIVE is only a master kill-switch (default allow; set 0/false to hard-disable regardless of the switches — never force-enables). Tests: tests/test_roundtable_seat_skills.py, tests/test_agents_backend.py. 2026-06-20 Step2 提速三件套:(1) 每席位推理深度覆盖 —— 业务链条 seats JSON 里逐席位可配 seat_mode(flash/thinking/pro/ultra,覆盖链条级 seatMode;编辑器席位卡右侧 ⏱Gauge 图标下拉,null=跟随链条默认)。前台经 useStep2Orchestration 三处 dispatch 的 seatModeToRequestFields(seatModes[id] ?? selectedMode) 透传;后台经 job seat_modes: dict[str,str] → StartParams → InProcessRoundtableGateway._seat_modes,run_seat 用 _SEAT_MODE_PARAMS(与前端 seatModeToRunParams 逐字对齐)解析覆盖作业级 seat_*(未命中→跟随作业级→policy 默认)。无 DB 列(ride seats JSON,但 ChainSeatSchema.seat_mode 须显式声明否则 Pydantic 剥掉)。Tests: tests/test_roundtable_seat_modes.py。(2) recommend 批内并行 —— 后端 engine._run_recommend 由 for-await 串行改 asyncio.gather(对齐前端 2026-06-13 已并行;总控一轮派多席位=判定相互独立→并发;批内用派活前 deliveries 快照、批后统一并入,语义不变)。(3) 研讨前并行『取数』两段式(业务链条 gather_first 列,逐条 opt-in,migration 20260620_02,editor「研讨前并行取数」开关)—— gather_first=True 时 run_orchestration 在分发前先 engine._run_gather_phase(全席位 asyncio.gather 跑 _GATHER_TASK「只取数不研讨」),产出 gathered: dict 经 _seat_message(gathered=...) 作为「已收集资料」块注入 recommend/chain/dag 各研讨轮席位任务(与「前序交付」块分开),把最慢的技能延迟前置并行、给(尤其 chain 串行的)研讨段提速;研讨阶段不限制再调技能。前台对称实现:useStep2Orchestration.runGatherPhase 在 runActiveOrchestration 分发前跑一次(gatherPhaseDoneRef 闸门,resetInitGate 复位),产出存 gatheredDeliveriesRef 经 peerDeliveries/初始 priorDeliveries 注入研讨席位。链条级 gatherFirst(camel)↔gather_first(snake) 经 api/chains.ts 映射;job 经 StartJobRequest.gather_first→StartParams→run_orchestration(gather_first=…)。Tests: tests/test_roundtable_gather_phase.py。(4) 削减重复广播(前端 only,多智能体 foreground 路径)—— suppress_pre_broadcast(仅 leader:抑制「总控本轮输入→各席位 thread」预广播 + 「总控给X派活」→兄弟席位的 post 广播,二者一个 flag)此前只在 DAG 派活轮开;现扩到 recommend(safetyCounter > 1,即第 2 轮起的续轮/综合)+ chain 逐席位(i > 0,第 2 个席位起)+ chain 收口轮(恒 true)。保留每种模式的首轮预广播(它为席位打底任务背景),只 suppress 重复的续轮广播(N 次读写巨大席位 thread 的纯浪费 + 弱模型模仿派活);席位的前序交付另经「席位交付广播」broadcastSeatDeliveryToOthers 送达,不受影响。后台引擎路径无此广播(_seat_message 直接拼上下文),故 #4 仅前端。2026-06-20 深链接业务链抽取规范:深链接会商(taskId+goPath,businessCode 如 rwfx→6BF)下,结构化抽取(flow-json)与方案总结报告都按所选业务链完整层级强制校正。结构化抽取实为 roundtable-structure 智能体(flow_extract.py 的 _system_prompt()=load_agent_soul(STRUCTURE_AGENT_ID),抽取→校验两段式 model.astream);其 SOUL 全局一份,旧实现把某业务层级写死进 SOUL→所有业务都按「目的→行为体」抽(bug)。修法:单份领域知识、两角色用法的 deerflow.agents.roundtable_orchestrator.extraction_spec(放 harness 层,app+harness 都可 import;build_structure_directive/build_summary_directive 按 businessCode 取;rwfx=6BF 写实「任务→目的→行为体→预期行为→驱动因素→叙事,预期行为↔驱动因素 1:1、余 1:N、label 不含步骤/阶段名、优先报告回退席位」,其余业务通用规范=强约束完整性+命名+取数且明令不套固定模板;business_code 空→""零副作用)。注入:(1) 结构化抽取 flow_extract._hierarchy_hint+_validate_messages 用 build_structure_directive 权威覆盖默认层级(前端 streamFlowExtract 传 business_code,源 ingestBusinessCode);(2) 方案总结报告 build_summary_directive——前台 multi_agent._special_run(role==summary 且 business_code) 追加、后台 engine.build_summary_prompt(business_code=…)(StartParams/StartJobRequest 透传)。仅深链接生效。种子 roundtable-structure/SOUL.md 层级顺序改 spec-driven、连接示例去 rwfx 写死(存量 live SOUL 不被 seed 覆盖,但注入规范权威可纠正)。Tests: tests/test_roundtable_extraction_spec.py。 |
Roundtable Draft Shares (/api/roundtable-draft-shares) |
Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a roundtable_drafts row. Two modes (roundtable_draft_shares.mode). import: POST / {draft_id, mode} - create (or reuse) a revocable code; GET /{code}/preview + POST /{code}/import - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (imp_<sha1> ? re-import is a no-op). view: a public, read-only result page ? see Public Roundtable Shares. Both: GET / - list my codes; DELETE /{code} - revoke. Backed by deerflow.persistence.roundtable_draft_shares (table roundtable_draft_shares, migration 20260606_01); store wired in deps.py as app.state.roundtable_draft_share_store. Tests: tests/test_roundtable_draft_shares.py. |
Public Roundtable Shares (/api/public/roundtable-shares) |
Unauthenticated (/api/public/ whitelisted). GET /{code} - read-only snapshot for a view-mode draft share: returns the Step 3??????report HTML (step3.html, a self-contained document) + title/summary/has_report. The frontend renders it in a sandboxed iframe (no allow-scripts) at #/roundtable-share/<code>. Missing/revoked/non-view codes all return 404. |
Scheduled Tasks (/api/scheduled-tasks) |
Per-user cron-like agent tasks. GET/POST / - list/create owned tasks; PATCH/DELETE /{id} - update/delete (owner); POST /{id}/{pause,resume,trigger}; GET /{id}/runs, GET /runs/{rid}, run-file preview/edit/download; GET /runs/{rid}/messages - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via _load_run); GET /runs/{rid}/html - generated HTML document for an html_page run; DELETE /runs/{rid} - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under /{id}/{publish,unpublish,subscribe} and GET /published. Admin (cross-user): GET /admin/all - every task; PATCH/DELETE /admin/{id} - edit/delete any task; POST /admin/{id}/trigger; GET /admin/{id}/runs. Admin routes require system_role == "admin". |
HTML Page Favorites (/api/html-page-favorites) |
Bookmarked generated HTML pages, owner-scoped. GET / - list; POST / {page_id, title?, description?, tags?} - bookmark a scheduled_html_pages row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (DuplicateFavoriteError ? HTTP 409). GET/DELETE /{id} - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new html_page task. |
Public Scheduled Tasks (/api/public/scheduled-tasks) |
Unauthenticated mirror for embedding. Adds GET /runs/{rid}/html and GET /{task_id}/latest-html for the html_page preview; the run result keeps result.html (only file paths are redacted). |
Light Apps (/api/light-apps) |
Light-app registry (应用管理 / 应用列表), global/shared (no user scoping; created_by/updated_by audit-only). Two kinds: window_open (launched in a new tab from the 应用列表 card grid) and iframe (embedded under a parent sidebar menu via mount_location and dynamically injected into the sidebar). Both carry route_params — a list of `{key, paramType: login |
Menu Overrides (/api/menu-overrides) |
Sidebar-menu overrides (菜单管理). menu_overrides is the administrator-maintained shared default; user_menu_override_configs stores one complete personal snapshot per user. GET / returns overrides (effective), default_overrides, user_overrides, has_user_override, and editable_scope. PUT / is scope-aware: administrators replace the sparse default overlay, while ordinary users replace only their own complete snapshot; an empty personal payload (or DELETE /) restores defaults. Personal rows win for known node_ids and default rows absent from the snapshot flow through, so newly-added menus follow administrator configuration without overwriting existing personal choices. The static tree (icons/routes/adminOnly) remains frontend-owned; iframe light-app nodes are supported; re-parenting is validated against cycles and the three-level cap. Both tables are auto-created by create_all. Frontend: core/page-layout/menu-overrides.ts, use-sidebar-menu.ts, strategy-components/api/menu-overrides.ts, and pages/MenuManagementPage.tsx; the settings entry is available to every user and labels the administrator view as default management. Tests: tests/test_menu_overrides.py, frontend-web/src/core/page-layout/menu-overrides.test.ts. |
Compact Menu Overrides (/api/compact-menu-overrides) |
Compact/sidebar-summary menu overrides (简介模式菜单). compact_menu_overrides remains the administrator default for independent type A / B layouts; user_compact_menu_override_configs stores a complete snapshot per (user_id, menu_type). Scope-aware GET/PUT/DELETE use the same effective/default/personal contract as the full menu: existing snapshot nodes stay personal and unseen new default nodes flow through. Type B default rows retain the historical B:: storage prefix; public node ids never contain it. Frontend editing is fixed to the deployment's runtime-config.js VITE_COMPACT_MENU_TYPE. Tests: tests/test_compact_menu_overrides.py, frontend-web/src/core/page-layout/compact-menu-overrides.test.ts. |
Sensitive Words (/api/sensitive-words) |
Sensitive-word / desensitize map (敏感词管理 / 脱敏词), global/shared (no user scoping; updated_by audit-only). Each row maps an original word → replacement, with enabled (停用 parks a word without deleting) + sort_order. The frontend ships a seed map in code (src/lib/desensitize.ts SEED_DESENSITIZE_MAP) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (DesensitizeWordsLoader → replaceDesensitizeMap, enabled rows only). GET / - list all words (open to any authenticated user — every page desensitizes); PUT / - admin OR the lqq account (_require_manager: system_role == "admin" or email @-prefix == "lqq"), atomically replaces the whole set ({words: [...]}, de-duped by word, last wins). Backed by deerflow.persistence.sensitive_words (table sensitive_words, PK word String(191), auto-created by create_all); store wired in deps.py as app.state.sensitive_word_store. Frontend: client strategy-components/api/sensitive-words.ts, page pages/SensitiveWordsPage.tsx (route /page/strategy/admin/sensitive-words, entry in the bottom-left settings dropdown — gated on username lqq, not admin role, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: tests/test_sensitive_words.py. |
Sentiment Agent Proxy (/api/sentiment-agent) |
BFF for the frontend 舆情分析 virtual agent. POST /stream accepts the AG-UI body plus apiUrl/authUsername/authPassword from runtime-config.js, forwards to that URL with TLS verify off, and byte-pipes upstream SSE. Connection/HTTP failures return the full error in detail; mid-stream failures emit AG-UI RUN_ERROR. Distinct from skill-packages/sentiment-analysis.skill. Tests: tests/test_sentiment_agent_proxy.py. |
Dashboard Sessions (/api/dashboard-sessions) |
「大屏绘制智能体」独立页面会话,按外部 task_id 存取,不按 user 分权(open read + shared writes;user_id audit-only)。一个 task 一行(upsert):transcript(聊天记录 JSON)+ report_json(最新结构化数据,可解析 = 「有效结果」)。GET /by-task/{task_id} - 取该 task 会话(无则 404);PUT /by-task/{task_id} - upsert({transcript?, report_json?},只覆盖传入项)。Backed by deerflow.persistence.dashboard_sessions(table dashboard_sessions, auto-created by create_all,无迁移);store wired in deps.py as app.state.dashboard_session_store。前端独立页 roundtable-planning/pages/DashboardAgentPage.tsx(路由 /page/strategy/dashboard-agent?taskId=,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 DashboardApp。Tests: tests/test_dashboard_sessions.py。 |
Task Reports (/api/task-reports) |
任务级研判情报报告详情,按 task_id 共享、不按 user 分权(写入者只留 updated_by 审计)。GET /by-task/{task_id} 始终返回原 Consumer 兼容外壳 {state:"200", msg:"操作成功!", data:[...]}(未保存为 data: []);POST /by-task/{task_id} 首次保存,已有报告返回 409;PUT /by-task/{task_id} 原子替换完整 data 数组。POST /import/by-task/{task_id} 面向 TaskCOP 导入页,接收 sq-report-mock.json 兼容的 data/records 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 taskId,避免误写。每一项严格是 {id, taskId, createTime, updateTime, sessionId, contentJson, categoryType};contentJson 以 JSON 字符串原样落在 records_json,避免破坏大屏按类目解析的输入格式。保存或导入非空 data 后,router 会通过 app.state.taskcop_task_store.mark_analysis_completed 将同 id 的 TaskCOP 任务升级至状态 25,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by deerflow.persistence.task_reports(table task_reports, auto-created by create_all);store wired in deps.py as app.state.task_report_store。前端客户端 strategy-components/api/task-reports.ts,core/auth/dashboard-input.ts 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: tests/test_task_reports.py。 |
Task Buttons (/api/task-buttons) |
Task-workspace configurable jump buttons (按钮管理), global/shared (no user scoping; updated_by audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: {id, business (rwfx/3qfx/xdfx), label, link_type (url/business/purpose/host), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, append_auth, auth_token_param, auth_name_param, enabled, sort_order}. Two independent, coexisting login-append mechanisms for url buttons — both resolved frontend-side: (1) login_params (JSON column login_params_json, PortableJSON, migration 20260625_01) = a list of {key, source, custom_value} resolved at click time (source = a userInfo field or other→literal custom_value) and appended to the target URL — mirrors the light-app login params; empty = nothing appended. (2) append_auth + auth_token_param + auth_name_param (columns, migration 20260625_02, default off) — appends ?{auth_token_param}={token1}&{auth_name_param}={username} so the target system can identify the current user; gated on token login — the frontend only stashes token1 (the ?authToken=) + the raw getTokenInfo username (login.token1/login.username) when login went through /login/token, so a non-token-login session appends neither. The frontend holds code-seed defaults (rwfx's 4 historical buttons built from the live task_deeplink config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. GET / - list all (open to any authenticated user — the workspace renders them); PUT / - admin only (_require_admin), atomically replaces the whole set ({buttons: [...]}, de-duped by id, last wins). Backed by deerflow.persistence.task_buttons (table task_buttons, auto-created by create_all); store wired in deps.py as app.state.task_button_store. Frontend: helpers core/auth/task-buttons.ts, client strategy-components/api/task-buttons.ts, admin page pages/TaskButtonsPage.tsx (route /page/strategy/admin/task-buttons, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: tests/test_task_buttons.py. |
Fixed Questions (/api/fixed-questions) |
Administrator-managed exact-match fixed Q&A (问题配置), global/shared. Each row is {id, question, answer, enabled, tokens_per_second, sort_order}; stream speed is configurable per row from 1–1000 token/s and defaults to 100. GET / and whole-set atomic PUT / are both admin-only. At the start of an external chat run, services._find_fixed_answer checks the latest eligible plain-text human message with exact, case/space/punctuation-sensitive equality. Enabled matches switch the run to make_fixed_answer_agent: a local BaseChatModel uses a lossless standard-library-only word/CJK/punctuation splitter for rate-limited chunks. It must never initialize a model tokenizer such as tiktoken.get_encoding, because an empty tokenizer cache can trigger a public vocabulary download and break offline deployments. The local graph keeps the existing LangGraph SSE messages/values protocol, run journal, checkpoint history, deterministic local title, and refresh behavior intact while no provider/model request or model-concurrency slot is used. File/multimodal/context-prefixed turns, canvas_agent, ai_writing, writing mode, resume commands, and internal orchestration/background child runs bypass matching. Storage failures fail open to the normal model path. Backed by deerflow.persistence.fixed_questions (table fixed_questions, SHA-256 indexed lookup plus exact original-string collision check, migrations 20260821_01 + 20260821_02), wired as app.state.fixed_question_store. Frontend: strategy-components/api/fixed-questions.ts, admin page pages/FixedQuestionsPage.tsx, route /page/strategy/admin/fixed-questions, entries in both full-layout and compact-workspace settings menus. Tests: tests/test_fixed_questions.py. |
Knowledge (/api/knowledge) |
Product knowledge base, global/shared (no user isolation; writes record created_by/updated_by for audit). POST /ingest/thread/{thread_id} - sediment one thread into a note (reads messages from the checkpointer, threads:read owner-checked); POST /ingest/threads/batch - batch-sediment the caller's recent threads (skip_existing via .manifest.json dedup). Defaults to background=true: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns {queued:true, processed:N} immediately (each thread's transcript is truncated to max_input_chars before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's parseResponse surfaces as a generic "Request failed"; background=false keeps the blocking path (full per-thread created/skipped/failed tally) for small batches/scripts. Shared loop: _run_batch_ingest. Optional model selection: both ingest endpoints accept model_name (overrides knowledge.extract_model_name for that capture; threaded through KnowledgeService.capture_thread ? extract_thread_llm_multi). Model-error surfacing: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still falls back to rule-based extraction and lands a draft (llm_fallback:true); the single-thread (non-background) response and the batch response (llm_fallback count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes model_name, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. POST /ingest/file - import an uploaded file (multipart: file + optional title/tags/status/model_name) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via convert_file_to_markdown), distilled by the same wiki-capture LLM pipeline as thread capture (source_type="file"), and falls back to a verbatim draft note when the model returns nothing usable (llm_fallback:true); POST /ingest/search - sediment search/tool results as a reference note; POST /notes - manual note; GET /notes (keyword/tag/source_type/status + pagination); GET/PUT/DELETE /notes/{id} (DELETE is soft = archive, ?hard=true removes the vault file); POST /search - mode=keyword|vector|hybrid (vector?keyword fallback when no embedding endpoint); GET /notes/{id}/versions - edit history; GET /duplicates + POST /merge - dedup; POST /reindex - rebuild vector index; GET /graph - product graph data (notes + entities, graph-blacklist filtered); GET/POST /graph/blacklist + DELETE /graph/blacklist/{id} - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from /graph and the wiki-export artifacts; table knowledge_graph_blacklist, auto-created); POST /export/graph + GET /export/files/{graph.json|graph.html|graph.graphml|cypher.txt} - obsidian-wiki wiki-export artifacts (self-contained graph.html for iframe); POST /resolve {targets:[?]} + GET /notes/{id}/wikilinks - resolve [[wikilink]] targets to a note id / vault page / missing (powers clickable wikilinks); GET /vault-page?path= - read a raw vault meta/hub Markdown page (no DB note). GET/POST /folders + PUT/DELETE /folders/{id} - user-managed directories (notes carry a folder path; GET /notes?folder= filters a dir + sub-dirs); GET/POST /extract-templates + GET/PUT/DELETE /extract-templates/{id} - editable extraction-prompt templates (thread/document ingest accept template_id; capture/PUT-note accept folder). Backed by the deerflow.knowledge module (see below). |
Proxied through nginx: /api/langgraph/* ? LangGraph, all other /api/* ? Gateway.
TaskCOP Situation-Overview Compatibility
The standalone legacy Vue page calls the Gateway URL in its runtime b1ConsumerUrl configuration directly (no Vite /api reverse proxy). The Gateway CORS middleware merges standard local Vite origins (localhost/127.0.0.1 on ports 5173, 5174, 3000, and 8080) even when GATEWAY_CORS_ORIGINS was set for another frontend. This avoids a restart regression when the existing Vite server is on 5173 but the launcher sets 5174. Deployments must set GATEWAY_CORS_ORIGINS to any additional exact frontend origins that are allowed to call the Gateway.
app/gateway/routers/taskcop_tasks.py is a temporary compatibility layer for the separately deployed legacy Vue page at /situation-overview/action/sentiment and its task import page at /situation-overview/action/task/import. It preserves the old Consumer paths and response envelopes: POST /cop/saveSuperiorTask creates a row with a sequential four-digit string id (0001, 0002, ...) and returns {state: 201, msg, data}; POST /api/taskcop/import/tasks accepts a JSON array or tasks/records/legacy list envelope, uses each supplied id or taskId as the primary key, and completely replaces duplicate IDs; DELETE /cop/tasks/{task_id} removes that task and its shared report document; GET /taskAnalyseSearch/cop-task-three-list (all), GET /taskAnalyseSearch/cop-my-tasks (creator-scoped), and GET /taskAnalyseSearch/cop-task-list return {state: 200, data: {total, records}}; GET /taskAnalyseSearch/cop-task-detail?taskId= returns one task. Existing UUID-style task rows are retained, and do not affect the new numeric sequence. An empty TaskCOP store remains empty—there are no built-in task or report seed records. It supports the page's existing content, taskStatus, taskDirection, startTime, endTime, pageNum, and pageSize query fields. Tasks are stored in taskcop_tasks through deerflow.persistence.taskcop_tasks, registered by persistence.models, and initialized as app.state.taskcop_task_store in deps.py. A non-empty save/update/import through /api/task-reports/* calls mark_analysis_completed(task_id): it changes only pre-completion task states to 25, so the list renders and filters the task as “已完成” without downgrading later workflow states. trendSucc is derived from the same status for the legacy sentiment list. Zero-padded task ids stay strings in the report API, so 0001 is never converted to 1. The Vue router calls DeerFlow's /api/v1/auth/login/username directly with an incoming username/userId/yUserId parameter; it does not depend on the retired Consumer login service, and sends the returned DeerFlow JWT to every TaskCOP call. The TaskCOP routes are no longer public or CSRF-exempt. The Gateway fallback CORS allow-list includes TaskCOP Vite origins http://localhost:8080 and http://127.0.0.1:8080; deployments must use explicit GATEWAY_CORS_ORIGINS. Tests: tests/test_taskcop_tasks.py and tests/test_taskcop_cors.py.
AI Writing Intent Router (对话驱动写作台意图路由)
Backs the frontend「AI 写作·对话版」page's unified bottom composer. POST /api/ai-writing/sessions/{session_id}/intent (owner-checked; in routers/ai_writing.py) reads the session's graph checkpoint via the shared app.state.checkpointer — the pending interrupt is the authoritative pause point (the frontend's claimed pause_point is only a fallback when the checkpoint can't be read) — then routes the user's free text through deerflow/agents/ai_writing/intent_router.py::parse_user_intent: an LLM call (session's model_name + fallback_chain model fault-tolerance) that returns {intent, pause_point, action, payload, need_research, research_query, answer, confidence, clarify}. Safety boundaries in normalize_intent_result: per-pause action whitelist strictly aligned to graph.py's real resume actions (ALLOWED_ACTIONS), no pause → no action, high-cost actions (finalize / force_finalize / query-less覆盖式 re_search) require confidence ≥ 0.85 else downgrade to clarify, payload key-whitelist cleaning (material-id filtering, review-issue index bounds, section-decision back-fill with the majority decision). QA intents return answer directly from the context snapshot (build_context_snapshot — stage-tailored: materials with ids / outline / draft body capped / review issues with indices). Model failures never 5xx — they degrade to clarify. Prompts in agents/ai_writing/prompts/intent_router_prompts.py; tests tests/test_ai_writing_intent.py.
AI Writing Built-in Functional Agents (????????)
The AI-writing graph (packages/harness/deerflow/agents/ai_writing/) has four stage "brains" ? ?????? / ????? / ?? / ?? ? that can each run in one of two modes, switched per-stage by config.yaml ? ai_writing.use_builtin_agents.{researcher,outliner,writer,editor} (default false, mtime hot-reloaded, new sessions pick up changes immediately):
- Legacy (flag off): hard-coded prompts in
prompts/*.py, direct LLM call (unchanged behavior). - Builtin-agent (flag on): the node loads the corresponding functional agent from
.deer-flow/agents/ai-writing-<stage>/?SOUL.mdbecomes the system prompt andconfig.yaml'smodeloverrides the stage model (priority:agent.model > node explicit fallback (keyword_model/rank_model) > state.model_name). Admins edit SOUL/model from the agent management page. Runner:nodes/_agent_runner.py(run_writing_agent/stream_writing_agent/parse_marker_json). Output contracts (marker +jsonblocks, intent.py-style): researcher[KEYWORDS_READY]/[MATERIALS_RANKED], outliner[OUTLINE_READY], editor (per-dimension)[REVIEW_READY]; writer emits raw section Markdown (keeps per-section streaming) with the legacy[[SECTION_BLOCKED]]sentinel in strict mode. Orchestration stays in the nodes (search fan-out, per-section loop, 3-dimension review loop, all pause points) ? the agent only does single structured generations, and a missing/empty SOUL silently falls back to the legacy path (WritingAgentUnavailable).
Seeding mirrors the roundtable pattern: verified config.yaml + SOUL.md ship in app/gateway/routers/_ai_writing_seed_assets/<id>/; _ai_writing_seed.py::ensure_ai_writing_functional_agents() copies them to .deer-flow/agents/ when missing (never overwrites local edits), called from the startup lifespan (before _sync_legacy_agents, so the agents appear in /api/agents on first boot) and self-heals from GET /api/ai-writing/sessions + GET /api/ai-writing/article-types. Tests: tests/test_ai_writing_seed.py, tests/test_ai_writing_agent_runner.py.
Sample imitation mode (样文仿写). The initial writing form supports writing_mode_type=imitate: the user can paste sample text or upload Word/PDF/Markdown/TXT. POST /api/ai-writing/sample/extract converts supported document files to Markdown via deerflow.utils.file_conversion and returns capped plain text to the frontend. The LangGraph entry point now starts at sample_analyzer before intent_parser; non-imitation sessions return immediately, while imitation sessions analyze up to 12k chars into sample_style_profile (SampleStyleProfile in state.py) and emit sample_profile_ready. writer_outline and writer_draft call render_sample_style_section(state) so both outline planning and section drafting receive the same style profile, imitation strength (light|medium|strong), optional structure-preservation preference, and guardrails: learn structure/tone/paragraph rhythm/sentence style, but do not copy sample sentences, proprietary facts, people, organizations, cases, or data. Frontend state mirrors this with analyzing_sample, uploaded/pasted sample controls, history/timeline restoration of sampleStyleProfile, and the setup-chat direct-start path preserves the same fields. Tests live with the knowledge-base AI-writing tests (tests/test_ai_writing_knowledge_base.py).
Skill-driven retrieval (检索类型). material_source has three values: general (通用检索, default), knowledge_base (知识库检索), and notebook (我的空间); legacy skill is normalized to general by the researcher node. knowledge_base 直连内网 ES 接口(= 旧版「内网直连检索」) — it bypasses skill orchestration entirely and calls IntranetSearchProvider over ai_writing.intranet_search_url (concurrent over the generated keywords; intranet_knowledge_base/intranet_search_timeout apply). An empty/unconfigured intranet_search_url does NOT disable it — the empty string is passed straight to IntranetSearchProvider, which falls back to its built-in default endpoint (intranet_stub._DEFAULT_ENDPOINT), exactly like the legacy get_search_provider(search_provider=intranet) path. So 内网直连检索 works out of the box without configuring an address (configure intranet_search_url only to point at a different endpoint). This is the "弱模型识别不了技能" escape hatch — the old no-skill retrieval path restored as an explicit option (weak models like deepseek-chat often fail to invoke configured skills). Tests: tests/test_ai_writing_knowledge_base.py. General retrieval is orchestrated from the skills configured on the ai-writing-researcher agent (config.yaml → skills, editable from the agent management page): the built-in knowledge-base-search skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a description-targeted query; skills run in configured order with early-stop at max_materials, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from _ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md into skills/public/ (never overwrites), and a pre-existing researcher config.yaml lacking the skills key is patched in place with the default (explicit skills: [] is respected). PAUSE-1 input append flow: when the user types natural language into the material-confirm input (re_search/legacy enable_skill_search + user_query), the node matches the most relevant configured skill via SkillPicker(candidates=…) (skipped when only one skill is configured), searches with the user's words, and appends the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). Skill-failure fault tolerance: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a skill_search_step of type skill_error (carrying the error reason), and if the configured skills collected no new materials at all it runs a fallback pass over other available skills (_fallback_candidate_skills: the built-in knowledge-base-search native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend SkillStepsCard renders skill_error (rose) and fallback (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: tests/test_ai_writing_material_source.py.
The intranet client uses trust_env=False and defaults intranet_verify_ssl=false, so internal HTTPS endpoints with self-signed/internal-CA certificates do not fail on certificate verification; set ai_writing.intranet_verify_ssl: true only for environments that require strict certificate checks.
Skill-call timeout is hardcoded 300s (5 min) — researcher.py _SKILL_SEARCH_TIMEOUT_SECONDS (per-skill wrapper) and _skill_agent.py _REAL_AGENT_TIMEOUT_SECONDS (whole real-agent skill run). The intranet ES fallback (IntranetSearchProvider) gets a longer 600s (10 min) default (intranet_stub.py; overridable via ai_writing.intranet_search_timeout) — that interface is the slow one, so our own fallback timeout is generous to avoid being blamed for "检不出".
Parallel intranet fallback (技能 0 条 → 直接用并行预跑的内网结果). When ai_writing.intranet_search_url is configured, the researcher node launches the intranet ES search (IntranetSearchProvider over the generated keywords) as a concurrent asyncio.Task the moment the skill loop starts (not sequentially after all skills fail). If the configured + fallback skills collect nothing, the node awaits that already-running task and uses its results (_collect) — no extra wait. If the skills did get materials, the task is cancelled (_cancel_intranet_task, suppresses CancelledError) so the intranet call isn't wasted. URL empty → no task, behavior unchanged. Tests: tests/test_ai_writing_intranet_parallel.py.
Partial-result harvest + query simplification (检不出/超时的容错). When researcher_skill_agent: true runs a non-native skill via run_skill_via_real_agent (the deployment's real path — the skill, e.g. knowledge-search-v2, is invoked by the real agent through bash python3 scripts/search.py "<query>"), one writing run issues many queries and some time out (the skill script prints {"success":false,"error":"请求超时…","count":0}) while others succeed ({"success":true,"count":30,"results":[…]}). Two fixes so successful data is never dropped: (1) _drive() captures every ToolMessage stdout and _harvest_tool_results() parses the skill's JSON by reusing the lead-agent 参考文献 mode's proven extractor (deerflow.runtime.references: _parse_json_like + _first_list + _normalize_reference_item) — the same logic that already extracts skill JSON into citations 100% reliably on ChatPage/AgentChatPage, with its huge field-name union (title/m_title/name, content/page_content/content_preview/snippet/…, recUuid/uuid/docId/…) and per-skill display-config support — plus skips success:false outputs — so even if the model's final {materials:[]} summary gives up (confused by the timeouts), the successful queries' real results are still collected; the function merges model materials + harvested results (dedup by url/recUuid/title, capped at max_results). The harvest+merge (_finalize) runs on EVERY exit path — normal end, overall timeout, AND await task exceptions (e.g. GraphRecursionError) — so when the run is cut off before the model writes its summary (observed: 10 queries × ~2 graph steps > the old recursion_limit, so the agent errored out with the real 30+ results already in tool_outputs), those results are still salvaged instead of returning []. recursion_limit was also raised from _REAL_AGENT_MAX_TURNS to _REAL_AGENT_MAX_TURNS*2+4 (it counts graph super-steps ≈ 2 per model→tool round, not model turns) so the model normally has room to finish its summary. Early-stop on max_results: _drive re-harvests after every tool output and, once the deduped harvested count reaches max_results (= per_skill = max_materials), breaks the nested astream and emits a 「已获取 N 条素材,达到上限,停止本技能检索」 step — without it a weak model keeps issuing new queries forever (observed: 100+ step-bar entries, materials far over the limit). Because the skill now returns exactly max_materials, the researcher loop's own collect_limit early-stop fires right after, so later configured skills don't run either. (2) The model is instructed (in _MATERIAL_OUTPUT_CONTRACT) that a 0-result/timeout query must be retried with a simpler, core query (drop topic suffixes like 竞选/诉讼/政策, keep the core entity: 「特朗普竞选」→「特朗普」) and that any data already retrieved must always be kept. The same deterministic simplifier search/base.py::simplify_query is wired into IntranetSearchProvider.search: an empty first attempt retries once with the simplified query. (3) Root cause of the instability — bash output middle-truncation. The skill prints its results as JSON to stdout, and the bash tool middle-truncates any output over sandbox.bash_output_max_chars (default 20000, inserting a ... [middle truncated …] ... marker). A count:30 skill response (~30 records × ~600 chars) exceeds 20000 → the JSON is cut in the middle → unparseable as a whole by both the model and _try_load_json → all materials lost (while a small count:9 response parses fine — hence the flapping). Fix: when whole-JSON parse yields no items, _harvest_tool_results falls back to _scan_balanced_json_objects — a brace-depth/string-aware scanner that recovers every individually-complete {…} record object from the truncated text (the records on each side of the truncation marker are still valid JSON), so ~16+16 records survive a 20000-char cut, far above max_materials. This is why the lead-agent chat pages (/page/workspace/chats/new, agent chat) call the same skill with 100% success — they only need the model to summarize prose from whatever it sees, never to parse a structured list, so truncation doesn't hurt them; ai-writing needs the structured records, so it must salvage them itself. Tests: tests/test_ai_writing_skill_real_agent.py, tests/test_ai_writing_query_simplify.py.
Agentic skill execution (ai_writing.researcher_skill_agent). When this flag is on (the deployment config.yaml enables it), the directed-skill branch runs nodes/_skill_agent.py::run_skill_via_real_agent: it reuses SubagentExecutor with the full tool stack, sandbox middleware, and a one-skill whitelist so the skill can read_file its SKILL.md, call bash, MCP tools, or whatever the skill instructions require. The nested run now passes agent_id=ai-writing-researcher in both config.configurable and runtime context, matching normal AgentChat skill visibility/whitelist behavior. It also appends a human-turn hard directive listing the exact SKILL.md path and skill directory, requiring weak models to first read_file the SKILL.md and then cd <skill-dir> && python/python3 scripts/*.py ... when the skill is script-backed; the model is explicitly forbidden from returning materials before executing the skill's required tools. If the real-agent path fails or yields no materials, _execute_skill still falls back to the legacy description→query search so materials are never empty. The built-in knowledge-base-search native concurrent path is unchanged. Tests: tests/test_ai_writing_skill_real_agent.py.
Targeted user revision (局部修改 — 只改命中章节). When the user submits a free-text 修改意见 on the draft-confirm card (action == "user_revise"), writer_draft_node no longer always rewrites the whole article section-by-section. Before the full-rewrite loop it tries _targeted_user_revision (nodes/writer_draft.py): _split_draft_sections parses the previous current_draft.full_markdown into (article_title, [(section_title, body)], references_block) (level-1 # title, level-2 ## sections, trailing ## 参考文献 pulled out and kept verbatim), then _detect_revision_scope runs a small llm_json classifier (prompts DRAFT_REVISE_SCOPE_{SYSTEM,USER}) returning per-section targets [{index, new_title, rewrite_body}]: it maps the note to the section indices it targets ("第一段/开头/引言"→0, "结尾/结论"→last, by title/topic) and distinguishes a 章节标题改名 (new_title set, rewrite_body=false → rename only, body untouched) from a 正文修改 (rewrite_body=true). Title-only renames apply directly to the assembled ## {new_title} heading and are written back into current_outline.sections[i].section_title (so the renamed title persists through confirm/finalize/history — this fixes "改章节标题不生效"). Body rewrites run _stream_revise_section (prompt DRAFT_REVISE_SECTION_USER, streamed as draft_chunk). Only hit sections are touched; unhit sections are preserved byte-for-byte and are NOT re-emitted to the stream — the frontend clears the right canvas to its initial placeholder on entering revising and shows only the live rewrite of the changed section(s), so the user sees「清空 → 流式重写 → 终稿」rather than the whole article rebuilding. The classifier returns whole_article=true (整体性意见 like 「整体语气更正式」/「全文都改」, structure changes 加章/删章/调整顺序, or a 文章总标题 change) → _detect_revision_scope yields None → _targeted_user_revision returns None → the node falls back to the whole-article rewrite (which still runs _revise_outline_with_notes for title/structure changes). Also falls back when there is no prior draft, an empty note, or scope-detection errors. Editor-review revisions (accept_review) keep the holistic rewrite. Reassembly preserves the article title + structure and re-appends the original references block; word count is computed on the body (excluding references). Frontend: isRevising in AIWritingState (types/ai-writing.ts) is set in useAIWritingStream.ts's intervention_submitted (ns === 'revising') and cleared on draft_ready; AIWritingDraftPanel.tsx shows the initial DraftWaitingPlaceholder while isRevising && streamingDraftSections.length === 0, then the streaming preview once chunks arrive. Tests: tests/test_ai_writing_targeted_revision.py.
History draft editing (历史回看可编辑成稿). The right-side draft in 历史回看 is no longer read-only — the user can edit it like the live 写作完成态 (Tiptap + 目录 + 格式工具栏 + AI 润色) and persist the change back to the session. PUT /api/ai-writing/sessions/{id}/draft {draft_markdown, draft_title?} (owner-checked → 404 otherwise; draft_title omitted ⇒ keep原标题) updates the row via the existing AIWritingSessionRepository.update. Frontend: AIWritingDraftPanel now passes readOnly={false} + an onSaveDraft handler (only in history mode) → TextArtifactRenderer → WritingToolbar 「保存」button (loading + toast); the handler is AIWritingHistoryContext.saveHistoryDraft(markdown, title?) → updateAIWritingDraft (api/ai-writing-sessions.ts), which write-backs the returned summary to historySession. Edits to the live completed draft stay ephemeral (export-only) as before — only history mode shows 保存. Tests: tests/test_ai_writing_draft_update.py.
Background suspend (后台挂起). A writing session can be handed off to the server to auto-drive the remaining flow to finalize, surviving page leave / refresh / account switch. POST /api/ai-writing/sessions/{id}/background (owner-checked, idempotent) starts AIWritingJobExecutor (app/gateway/ai_writing_job_executor.py), wired in deps.py as app.state.ai_writing_job_executor. The executor drives the same ai_writing graph in-process — it builds make_ai_writing_graph(), attaches the shared app.state.checkpointer (the very saver the runtime worker uses, so it continues the real thread state the frontend already wrote), sets the owning user via set_current_user, and loops aget_state → ainvoke(Command(resume=…)) until status==done. Auto-action per interrupt: material/outline confirm → confirm; section_help → every blocked section loose (continue with general knowledge, never re-loop to research); draft_confirm → to_editor until reviewed, then finalize (on pass, or when revision_count >= max_revisions); review_confirm → accept_review (revise & re-review until pass or budget exhausted, then forced finalize). To avoid racing a still-running runtime worker (suspend mid-node), the driver waits for an actionable state (_wait_for_actionable): it only acts when the graph is at an interrupt / done, or when the checkpoint stops advancing (no worker → abandoned/restart). No new table — the graph checkpoint + ai_writing_sessions already hold all state; status="background" marks 挂起中 (surfaced in the history dropdown), nodes write draft/review_result/status=done as usual, so the user reopens the finished article from history. Per-user concurrency cap AI_WRITING_BACKGROUND_SLOTS_PER_USER (default 2, same SQLite-lock rationale as the roundtable executor). Startup reconcile (in deps.py) relaunches drivers for sessions left background after a restart (AIWritingSessionRepository.list_by_status). Progress flowchart popup: GET /api/ai-writing/sessions/{id}/progress (owner-checked) reads the graph checkpoint via executor.get_progress and derives a canonical pipeline stage key (intent/research/material_confirm/outline/outline_confirm/draft/draft_confirm/review/done, from the pending interrupt's pause_point or snapshot.next node) + note (素材不足求助 / 编辑打回) + review progress + isRunning. Frontend: 「后台挂起」 button + auto-suspend on route unmount / beforeunload (AIWritingPanel.tsx, suspendAIWritingToBackground[Keepalive] in api/ai-writing-sessions.ts); 挂起中 badge in AIWritingHistoryDropdown.tsx, and clicking a 挂起中 history record opens a flowchart dialog (AIWritingFlowDialog.tsx polls getAIWritingProgress every 2.5s, renders the fixed vertical pipeline AIWritingFlowChart.tsx lit by the live stage) so the user sees how far the background run has gotten without reopening/taking over the session. Transcript consistency: the rich history timeline (AIWritingHistoryView → AIWritingTimeline) replays transcript = {progressEvents, completedInterventions}, normally assembled+saved client-side. A background run has no client, so the executor rebuilds it at the end (_save_transcript/_build_transcript_from_checkpoint) entirely from the final checkpoint — both progressEvents and completedInterventions are derived from the same set of resumed events (every pause-resume emits exactly one), so they are always 1:1 aligned. For each resumed: classify its message → (pausePoint, action) (_classify_resumed, matched against graph.py's resume messages), insert an await_user (with the pause prompt) before it, and build a rich intervention card enriched from final state (material_confirm→materials, outline_confirm→outline, draft_confirm→draftTitle/draftWordCount/reviewResult, review_confirm→reviewResult); materials_ready is inserted before the first material-confirm. (Earlier code merged the frontend's pre-suspend completedInterventions with auto ones and aligned by index — but the frontend's last save can predate its most recent decision, so the counts drifted and cards rendered off-by-one with empty data; deriving everything from the checkpoint root-fixes both.) Raw (snake_case) events and intervention data are normalized on load by the exported normalizeProgressDict / normalize{Material,Outline,ReviewResult} (idempotent on already-camelCase live transcripts — one normalize path for both sources), so reopening a background-completed session shows the same cards as a frontend-driven one. Tests: tests/test_ai_writing_background.py.
Admin Leaderboard Snapshots (/api/admin/users/leaderboard*)
The admin ????? is served from precomputed per-day snapshots in admin_leaderboard_daily_stats (keyed by (stat_date, settings_hash)), not by aggregating raw rows on every request. A background scheduler inside the Gateway (_leaderboard_snapshot_scheduler_loop in app/gateway/app.py, singleton file-locked) ticks every ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS (default 300s): it prewarms recent days, then builds any queued/error/stale-running day. GET /leaderboard merges the per-day snapshots for the requested range. Today is rebuilt whenever its snapshot is older than ADMIN_LEADERBOARD_TODAY_TTL_SECONDS (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once ready, is treated as fresh forever.
Recent-only admin audit policy. The server enforces a rolling 30-calendar-day window for administrator leaderboard, user analytics, user Q&A/thread audit, LLM/tool metric list/summary/export, and the user-list Q&A counter; URL parameters cannot widen it. The Q&A fallback that reads checkpoints is additionally capped, while indexed run summaries remain the primary audit source. Gateway startup also launches the singleton admin_audit_retention sweep: it deletes audit metric rows, terminal runs summaries (which contain compact question/answer audit fields and were also last updated before the cutoff), and leaderboard snapshots older than 90 days. Cleanup is deliberately low-priority: after the default 5-minute startup delay, it runs every 15 minutes using 100-row committed transactions, round-robins each store (at most four batches per store), yields between commits, and stops starting new batches after an 8-second sweep budget; operators may tune these bounds with ADMIN_AUDIT_RETENTION_* environment variables. It deliberately does not delete conversation threads, checkpoints, or message history; those are user-content records governed by their own lifecycle.
Two on-demand/automatic refresh paths layer on top:
- Manual "????" ?
POST /leaderboard/refresh-todayforce-rebuilds today's snapshot synchronously (bounded by the same semaphore/timeout the scheduler uses), then returns the merged range. Lets an admin pull the latest real-time records into the stats without waiting for the TTL. Frontend: a "????" button onAdminUserLeaderboardPage?useRefreshTodayLeaderboard(core/admin/hooks.ts), which primes + invalidates the["admin-activity","leaderboard"]query. - Daily finalize of the previous day ? once per day, on/after
ADMIN_LEADERBOARD_FINALIZE_HOUR(local Beijing, default1= ?? 1 ?), the scheduler tick force-recomputes yesterday a single time so runs/tool-calls that only landed near midnight are fully captured. The decision is the puredeerflow.persistence.admin_stats.finalize.should_finalize_previous_day(status/generated_at/now/hour) ? the rebuild stampsgenerated_atto today, which self-debounces it for the rest of the day (no extra state file). Tests:tests/test_leaderboard_finalize.py.
Admin Concurrency Monitor (实时并发监控 / 系统压力)
A live admin monitor embedded at the top of AdminUserLeaderboardPage (components/workspace/admin/concurrency-monitor.tsx, ConcurrencyMonitorPanel) that answers "当前有几个并发的大模型调用、是谁、能不能直接停掉" and charts system pressure over time. Built on the existing in-memory RunManager (no new run plumbing).
- Live concurrency + 真正停止 —
RunManager.list_active()returns every run whose status ispending/running(the registry keeps finished records around untilcleanup, so it filters by status).len(list_active())is the current concurrent-call count. Routerapp/gateway/routers/admin_active_runs.py(/api/admin/active-runs, admin only via the sharedadmin_users._require_admin):GET /returns{count, server_time, runs:[{run_id, thread_id, status, created_at, elapsed_seconds, user_id, user_name, agent_id, agent_name, thread_title, kind, multitask_strategy}]}— each run best-effort enriched (batched DB) with the owning user's email (threads_meta.user_id→users.email) and the agent's display name (agents.name);kindis derived from thread metadata (圆桌会商 / 系统/后台 / 任务对话 / 对话).POST /{run_id}/cancelcallsRunManager.cancel(run_id, action="interrupt"), which sets the abort event and cancels the run's asyncio task — i.e. it truly aborts the in-flight model stream, freeing capacity.POST /cancel-allstops every in-flight run. - 并发量折线图 (按分钟/小时) — a lightweight background sampler (
_concurrency_sampler_loopinapp/gateway/app.py, singleton file-locked, started in the lifespan) recordslen(list_active())everyCONCURRENCY_SAMPLE_INTERVAL_SECONDS(default 15s) into the newconcurrency_samplestable (deerflow.persistence.concurrency,ConcurrencySampleRow{sampled_at, active_count}, auto-created bycreate_all, no migration; store wired asapp.state.concurrency_sample_store). ~Hourly it purges rows older thanCONCURRENCY_SAMPLE_RETENTION_HOURS(default 168h).GET /concurrency?granularity=minute|hour&hours=Naggregates samples into per-bucket peak + avg (ConcurrencySampleStore.aggregate, bucketed in Python so it's identical across sqlite/mysql/postgres; fetch capped at 60k rows). Default lookback: minute→3h, hour→48h. - 大模型出字速度折线图 (tokens/sec, 按分钟/小时) —
GET /token-speed?granularity=&hours=aggregates the persistedllm_call_metrics(status="success") into per-bucket avg/min/max tokens/sec (deriving tokens/sec fromoutput_tokens÷duration_mswhen the stored value is null). A dip pinpoints the model/provider as the bottleneck for that window — "知道具体原因出现在哪里". - On/off + 不影响线上 — the whole feature is designed to never impact production: sampling is one tiny INSERT, retention-purged, all paths best-effort/guarded, queries bounded. A runtime switch lives in
system_settings.json → concurrency_monitor.enabled(ConcurrencyMonitorSettings, default false / opt-in, mtime-hot-reloaded): the background loop always runs (just sleeps) but only samples when an admin turns it on, and it re-reads the flag every tick so toggling it on/off takes effect immediately without a restart (the on-demand live snapshot + 停止 stay available regardless; only the historical chart needs sampling on).GET|PUT /api/admin/active-runs/settingsreads/writes it (admin only); the panel's 「后台采样」 Switch drives it. EnvCONCURRENCY_SAMPLE_ENABLED=0is a hard master kill (never starts the loop). Frontend client/hooks:core/admin/active-runs.ts+core/admin/hooks.ts(useActiveRunspolls 4s while the panel is open,useConcurrencySeries/useTokenSpeedSeriespoll 15s,useCancelActiveRun/useCancelAllActiveRuns,useMonitorSettings/useUpdateMonitorSettings). Tests:tests/test_concurrency_monitor.py.
Scheduled Task Runtime
ScheduledTaskService (deerflow/runtime/scheduler/service.py) polls due tasks every poll_interval_seconds and executes each in its own throwaway thread, serialized by a shared _agent_run_lock.
Stuck-run recovery: each execution attempt is bounded by _RUN_ATTEMPT_TIMEOUT_SECONDS (30 min) via asyncio.wait_for. On timeout the run is retried up to _MAX_RUN_RETRIES (3) times, then marked failed. A watchdog (_reconcile_stuck_runs, run every poll) recovers runs left in running with no in-process owner ? e.g. orphaned by a crash or restart ? re-driving them through the same retry budget so a task can never stay perpetually "executing". The scheduled_task_runs.retry_count column tracks consumed retries.
Unique task names: task names are unique per user (uq_scheduled_tasks_user_name). create_task/update_task/admin_update_task proactively check via _assert_name_available() and raise DuplicateTaskNameError on a collision; the router translates that into HTTP 409 instead of letting the raw DB IntegrityError surface as a 500.
HTML page tasks (task_kind == "html_page"): a second task kind that renders a full HTML document each run instead of a Markdown report. The kind + its config (html_config: page_prompt, negative_prompt, reference_favorite_id, model_name) live inside execution_context ? no schema change to scheduled_tasks. Data gathering is delegated to the agent's own search skills (driven by page_prompt), so there is no explicit search-query/data-source field; the render model is user-selectable (model_name, default zai-org/GLM-5-FP8). _run_task_attempt() dispatches by kind; _run_html_page_attempt() runs the pipeline: gather real data via the lead agent (its search tools) ? optionally load a reference favorite's reusable style/layout ? render with the chosen model (thinking off) ? fault-tolerant JSON parse ? sanitize (BeautifulSoup, strips <script>/<iframe>/on*/javascript:) ? structural validate ? one GLM5 repair pass if invalid ? completeness gate: if the HTML is still not a full valid document (missing doctype/html/head/body, empty body, or residual scripts) after the repair pass, raise HtmlGenerationError so the run is marked failed (a page only counts as successful when it is a complete, previewable HTML document) ? best-effort Playwright screenshot (no-op when Playwright absent) ? archive index.html ? persist a scheduled_html_pages row ? return result with format: "html" and the full HTML inline. Pure helpers live in deerflow/runtime/scheduler/html_page.py. The frontend previews the HTML in a sandboxed iframe (no allow-scripts) on the run-detail and public pages; a user can bookmark a page (style summary extracted via GLM5) and later pick it as a style reference. New tables scheduled_html_pages + html_page_favorites (deerflow/persistence/html_pages/, wired as app.state.html_page_store) are created by Base.metadata.create_all with no migration. Tests: tests/test_html_page_tasks.py.
Unified output switches (require_html + require_markdown). The task form no longer splits "???? / HTML ??" into separate kinds. execution_context now carries two independent booleans that may both be on: require_html (???? HTML) and require_markdown (???? Markdown). _task_requires_html() returns true for require_html or legacy task_kind == "html_page" (so old tasks need no data migration), and _run_task_attempt() dispatches to _run_html_page_attempt when it is true, else the regular agent attempt. When both switches are on, _gather_html_data() returns (text, files) ? the gather agent run is additionally told to save a .md file ? and _run_html_page_attempt validates it (MarkdownRequirementError on failure) and surfaces it alongside the generated page, so the run delivers both artifacts. The single main prompt doubles as the HTML page requirement (html_config.page_prompt); there is no separate page-prompt field. Tests: tests/test_scheduled_task_require_html.py + the markdown-coexistence cases in tests/test_html_page_tasks.py.
Template-fill mode (JSON → 固定 HTML 模板填充). A third execution mode, distinct from the free-form require_html render pipeline: convert a user-supplied JSON into a fixed HTML template's data structure and fill it. Triggered when execution_context.template_config.template_id is set (chosen in the task form after selecting the built-in 「模板数据转换器」 agent template-json-builder). _task_template_id() reads it and _run_task_attempt() dispatches to _run_template_fill_attempt before the require_html check. Pipeline: take the source (the task prompt, pasted or uploaded .json) — which may be pure JSON, plain text, or text-with-embedded-JSON (template_fill.extract_embedded_json pre-extracts any embedded {…}/[…] with a string-aware balanced scan and surfaces it as a labeled block while keeping the full original text; plain text → no block, the model reads it semantically) → load the selected agent's SOUL.md+model and run a direct _invoke_conversion_model call (system=SOUL, human=conversion prompt carrying the template's compact schema skeleton + the recognized-JSON block + source) → extract_json_object → template_fill.validate_data (top-level keys + container types) → on failure feed the structure errors + field_coverage_gaps back and retry up to _MAX_TEMPLATE_FILL_RETRIES=2 → template_fill.fill_template replaces the template's const DATA={…} line (and refreshes MANIFEST.系统.更新时间) → archive index.html + persist a scheduled_html_pages row → return format:"html" (reusing the existing HTML preview at /runs/{id}/html). Any terminal failure raises TemplateFillError → run marked failed (no retry, message shown). Pure engine deerflow/runtime/scheduler/template_fill.py (harness, no app.*): the template registry (TEMPLATES: id=no1 name=no.1 台湾滨海防卫大屏, and id=no2 name=no.2 台湾飞行部队平台 with convertible=False — a 「只展示」 template that renders its existing HTML as-is, no model/validation/fill, until conversion is wired in later; _run_template_fill_attempt short-circuits to read_template_text for it, and list_templates/is_convertible expose the flag so the form hides the JSON input), list_templates/reference_data/schema_skeleton_json/validate_data/field_coverage_gaps/fill_template — the template's own embedded DATA is the schema source of truth (parsed at load, 1-sample-per-array skeleton fed to the LLM, top-level structure as the validation basis); template HTML files live under runtime/scheduler/templates/<id>/, add a TemplateSpec to register a no.2. The agent is seeded like the AI-writing ones: app/gateway/routers/_template_builder_seed.py + _template_builder_seed_assets/template-json-builder/{config.yaml,SOUL.md}, called from the lifespan before _sync_legacy_agents (which upserts it into the agents table as built-in/published so it shows in /api/agents → the task form's 执行智能体 dropdown). Template list API: GET /api/scheduled-tasks/html-templates → {templates:[{id,name,description,convertible}]} (open). Debug-render API POST /api/scheduled-tasks/template-fill/render {template_id, text?|data?} → {ok, display_only?, errors, gaps, html} (open): no model call — extract_embedded_json(text) (or data) → validate_data → fill_template, returning the filled HTML plus the validation errors/field-gaps (fills even when invalid so you can see the effect). Powers the chat-page debug button below; non-convertible templates return their raw HTML. Templates as require_html 参考收藏页面 (use the REAL template, all sub-pages): the 参考收藏页面 list in the task form also offers the templates (value tpl:<id>, e.g. tpl:no1). These templates are JS-driven multi-page SPAs (sub-pages/tabs are rendered by script from DATA), so a free-form model render would only ever produce the main page and drop every sub-page. Therefore, when _run_html_page_attempt sees a tpl:<id> reference (template_fill.reference_template_id parses it; distinguishes a template marker from a real favorite id), it short-circuits before the free-form pipeline to _run_html_template_reference, which renders the actual template file (all sub-pages intact): convertible templates gather real data (_gather_html_data) → convert to the template DATA (_convert_to_template_data, shared with template-fill mode) → merge_with_reference (per top-level key = per sub-page: any sub-page/field with no extracted content falls back to the template's original data, so sub-pages never render blank) → fill_template; non-convertible templates (no.2) output the raw template as-is; if the task prompt and description are both empty, or gather/conversion yields nothing, it uses the template's original data outright. 数据抽取开关 html_config.template_extract (default off, shown in the form only when a tpl: reference is picked): it only controls whether the HTML uses extracted data vs the template's default data — it does not affect anything else. Off → HTML fills the template's default data (no convert); on → gather+convert+merge. Markdown is independent: when require_markdown is also on, the gather agent run still runs and the .md is produced/validated/surfaced regardless of the extract switch (so turning extraction off never blocks the Markdown). Display-only templates (no.2) never extract; they still honor require_markdown. _run_html_template_reference runs gather once when do_extract (extract on + has prompt/desc + convertible) or require_markdown, and threads any .md through _finalize_template_html(extra_files=…). merge_with_reference is also applied in template-fill mode (_run_template_fill_attempt) so empty sub-pages there fall back too. Archive/persist/result are shared via _finalize_template_html. (The earlier style_reference CSS-extraction approach was removed — it lost the sub-pages.) Frontend: when the agent dropdown = template-json-builder the form hides the HTML/Markdown switches and shows a 「HTML模板」 select (useHtmlTemplates) + a .json upload (reads file text into the prompt box). Tests: tests/test_template_fill.py, tests/test_template_fill_attempt.py, tests/test_template_builder_seed.py.
Per-user Prompts & UI Preferences
Custom prompts can be scoped to one agent. user_custom_prompts gains a nullable agent_id column (migration 20260603_02): NULL = global (the chip shows in every chat), otherwise the prompt only surfaces when that agent is selected. The visibility filter is applied in the frontend CustomPromptBar (it receives the current agent id from ChatPage's selector or AgentChatPage's assistantId); the backend (/api/user-prompts CRUD) just stores/returns/clears agent_id (update accepts an explicit null to clear). Tests: tests/test_user_prompts_agent_id.py.
Per-user UI preferences (deerflow/persistence/user_preferences/, wired as app.state.user_preferences_store; migration 20260603_03, also auto-created by create_all): one JSON-blob row per user so new toggles need no schema change. Router /api/user-preferences (GET/PUT, account-scoped). Current key: hide_admin_prompt_prefix ? when true the chat input hides the admin-configured prompt-prefix switch and never applies the prefix (the user uses only their own prompts; enforced in ChatPage for both shouldApplyPrefix and promptPrefixProp). Toggled from the "???????" dialog. Tests: tests/test_user_preferences.py.
HTML skills (tools the render step uses). Two offline skills under skills/public/ shape the generated HTML and are toggled in the skills settings page (enabled by default): html-page-builder (a self-contained inline-CSS design system ? colors/cards/tables/KPI/responsive) and html-charts (inline-SVG charts that render in the no-allow-scripts preview iframe; optional local ECharts documented in its assets/README.md). A skill opts into HTML injection via metadata.kind: html_page in its SKILL.md frontmatter; _collect_html_skill_instructions() reads the bodies of enabled such skills and build_generation_prompt(skill_instructions=?) injects them, so the (raw-model) render step honors the user's skill selection. The data-gathering step and the render/repair/style passes all use the task's selected model_name (no silent fallback to the default model ? and there is no hardcoded GLM-5; an unset selection falls back to config models[0]). To stop auxiliary LLM calls from leaking onto the default model, TitleMiddleware is skipped when is_scheduled_run is set (_build_middlewares in lead_agent/agent.py) ? scheduled/HTML runs use throwaway threads whose titles are never shown, and config.title.model_name is usually unset (? would call models[0], not the chosen model). The completeness gate (validate_html) also requires a non-empty <title> and forbids external stylesheets, on top of the doctype/html/head/body + non-empty-body + no-script checks.
Knowledge Base (packages/harness/deerflow/knowledge/)
Self-contained product knowledge base. All knowledge capability lives in this one module (harness layer, deerflow.* only ? no app.* imports). Wired as app.state.knowledge_service in deps.py via make_knowledge_service(sf, config.knowledge) (returns None on the memory backend or when knowledge.enabled=false ? router 503). Surfaced by app/gateway/routers/knowledge.py (/api/knowledge). Config: config.yaml ? knowledge (deerflow/config/knowledge_config.py).
Two stores, kept in sync on every write:
- DB mirror ? tables
knowledge_notes/knowledge_sources/knowledge_tags/knowledge_note_tags(knowledge/models.py, registered inpersistence/models/__init__.py, auto-created byBase.metadata.create_all; portable typesPortableLongText/BeijingDateTime). Powers listing/filter/search/audit. Global/shared ? nouser_idcolumn;created_by/updated_byare audit-only (resolved viaget_effective_user_id()). - Markdown vault ? obsidian-wiki compatible, default
{$DEER_FLOW_HOME}/data/knowledge/obsidian-vault.ObsidianWikiAdapter(obsidian_adapter.py) writes wiki-capture frontmatter+body, and after every write syncsindex.md/log.md(CAPTURE/INGEST/UPDATE/ARCHIVE/EXPORT) /hot.md/.manifest.json(source-hash dedup forskip_existing).markdown_vault.pyhandles frontmatter build/parse, slugging, wikilink extraction and path-traversal-proof resolution.
Module layout: models.py (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) ? repository.py (DB only, no LLM) ? schemas.py (NoteDraft/SourceDraft DTOs) ? skill_templates.py ? markdown_vault.py ? manifest.py ? extractor.py (rule-based fallback) ? llm_extractor.py (default: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to extractor.py) ? sources.py ? exporter.py (graph.json/graph.graphml/cypher.txt + self-contained vanilla-JS graph.html; entity nodes + relation edges. graph.html is theme-aware ? it reads ?theme=light|dark from its own URL since the embedding iframe can't inherit the app's CSS; KnowledgeGraphPage.tsx appends the active day/night mode and re-skins on toggle. The layout cools down and freezes so dense graphs stay clickable, node radius scales with degree, and hovering/clicking a node focuses it + its direct neighbours while dimming the rest + a detail panel ? taming heavy node/edge clutter) ? chunking.py + embeddings.py (OpenAI-compatible embedding client, keyword fallback) + search.py (keyword/vector/hybrid) ? sensitive.py (secret/PII redaction) ? dedup.py (Jaccard + hash dedup) ? auto_ingest.py (background queue/worker) ? obsidian_adapter.py ? service.py (KnowledgeService orchestrator ? the only thing the router touches; capture entry points capture_thread / capture_search / capture_file / create_manual_note, all converging on _persist_draft). Tests: tests/test_knowledge.py (20 cases). Frontend: frontend-web/src/core/knowledge/* + pages/KnowledgeBasePage.tsx / KnowledgeGraphPage.tsx + chat knowledge-save-trigger.tsx / knowledge-rag-toggle.tsx; spec at frontend-web/docs/product-knowledge-base-dev.md.
Wikilinks & multi-note capture (knowledge.{related_notes_enabled,related_notes_limit,multi_note_enabled}, all default-on; project_name defaults to zncm):
- Navigable
[[wikilinks]]?service.resolve_wikilink(s)maps a[[target]]to a knowledge note (by id ?vault_path? title) or a vault meta page (read_vault_page, path-traversal-proof), elsemissing.markdown_vault.normalize_wikilink_targetcanonicalizes targets (drops|alias, slashes,.md). The frontendMarkdownRendererrenders non-numeric[[...]]as clickable links from the resolver map (numeric[[123]]stay citation badges); note links navigate, vault links open aVaultPageDialog, missing links are greyed. - Real related notes only (no structural meta-links) ?
extractor.related_links()now returns[]: notes no longer auto-linkindex/_meta/taxonomy/ a project hub (they were noise in every## Related). On capture,service._find_related_notescross-links only by shared distinctive tags (project/knowledge-basedefaults excluded; free-text title/summary matching was removed because CJK tokenization linked unrelated notes via generic words like ??/????) and_inject_related_noteswrites real[[Title]]links into## Related(plus note?noterelatedgraph edges); no distinctive tags ? no related notes. The renderers omit the## Relatedsection entirely when there are no related notes and no sources. The vault's ownindex.md/_meta/taxonomy.mdstill exist (obsidian scaffold) and remain resolvable for manually-written links. The router enrichesGET /notes/{id}withcreated_by_name/updated_by_name(auth-provider email lookup) so the UI shows a person, not a UUID. The frontendMarkdownRendererrenders wiki-capture provenance markers^[inferred]/^[ambiguous]as small "??"/"??" badges. - LLM-extraction outcomes ? capture distinguishes three LLM outcomes: (1) success ? declarative note(s); (2) deliberate rejection ? the model returns
should_ingest:falsewith no notes (greeting / meta chatter) ?extract_thread_llm_multiraisesLLMRejectedIngestandcapture_threadraisesEmptyThreadError(skip ? nothing persisted, so worthless chats never enter the KB); (3) genuine failure ? API error / non-JSON ? returns[], capture falls back to the rule-based verbatim extractor and lands the note as adraft(notapproved), logs a WARNING, and returnsllm_fallback:true(surfaced by the router).llm_extractorlogs an actionable WARNING/INFO for each non-success path so extraction issues are diagnosable. - Multi-note split ?
llm_extractor.extract_thread_llm_multilets one conversation distill into 1?4 cross-linked notes (notes:[?]JSON array; legacy single-object output still accepted).capture_threadpersists each, sibling-cross-links them by title, and returns{note, created, notes:[?]}. Gated bymulti_note_enabled(false ? first note only). Tests:tests/test_knowledge_wikilinks.py.
Phases 2?4 (all config-gated, defaults safe):
- Phase 2 ? vector RAG:
knowledge.embedding.{enabled,base_url,api_key,model,...}enables an OpenAI-compatible embedding endpoint; notes are chunked + embedded on write (knowledge_embeddings),POST /searchsupportsmode=keyword|vector|hybrid(vector degrades to keyword when unconfigured).KnowledgeRagMiddleware(lead-agent chain) injects a transient<knowledge-context>recall before the model call, gated per-conversation by theknowledge_rag_enabledruntime flag (frontend chat toggle) falling back to globalknowledge.rag_enabled.POST /reindexrebuilds the index. - Phase 3 ? governance:
sensitive.pyredacts secrets/PII before persistence (sensitive_redaction_enabled); every update snapshotsknowledge_note_versions(GET /notes/{id}/versions);GET /duplicates+POST /mergefor dedup; backgroundAutoIngestQueue(wired indeps.py, started/stopped in the gateway lifespan) auto-sediments finished conversations off the chat path whenauto_ingest_enabled? enqueued fromKnowledgeRagMiddleware.after_agent, value-judged by min-chars +skip_existing, landing asdraft. - Phase 4 ? graph: LLM extraction also yields entities/relations ?
knowledge_entities/knowledge_relations;exporter.build_graphadds entity nodes + note?entity/entity?entity edges (reflected ingraph.html/graph.json);GET /graphreturns product graph data.
User directories + editable extraction templates + display polish (knowledge UX overhaul):
- Custom directories (
knowledge_notes.folder+knowledge_folders) ? notes carry a free-form, slash-joinedfolderpath (e.g.投研/行业) independent of the wiki-capturecategoryand ofvault_path.knowledge_foldersis the manageable directory list (supports empty folders + rename/move that re-points descendant folders and filed notes). Router CRUD under/api/knowledge/folders(GET/POST/PUT/{id}/DELETE/{id}?reassign_to=);GET /notes?folder=filters a directory + its sub-folders (folder=""? unfiled). Capture (capture_thread/capture_file/create_manual_note) andPUT /notes/{id}acceptfolder(the note PUT usesmodel_fields_setso an absent key leaves the folder untouched, a presentnullclears it). FrontendbuildKnowledgeTree(notes, folderPaths)builds the left tree fromfolder(no longer fromvault_path); folder management dialog +FolderSelectin the create/import dialogs and the note detail header. - Editable extraction prompts (
knowledge_extract_templates) ? the hard-coded distillation prompts are seeded as editable templates on startup (KnowledgeService.seed_default_templates, idempotent —默认 · 对话提炼/默认 · 文档提炼, eachis_defaultfor its scope).scope ∈ {thread, document, both}; capture resolves an explicittemplate_id→ the scope default → the built-in constant, and passes it tollm_extractor.extract_thread_llm_multi/extract_document_llm_multiassystem_prompt(used verbatim). Router CRUD under/api/knowledge/extract-templates(409 on duplicate name). Frontend: extraction-template management dialog +TemplateSelectin the batch/file import dialogs. Editing a template is the supported way to fix "提炼太精简" (loosen the prompt to keep more detail) and to add alternate extraction logics. - Single source list ?
extractor._related_section_mdno longer emits a### Sourcessub-section into the body; the note detail shows sources once via the structured来源block (note.sources).## Relatedstays pure wikilinks so the frontend extracts it cleanly into the related-docs module. - Chinese section headings (display-only) ? the frontend
localizeHeadings(wikiUtils) mapsContext/Finding / Decision/Reasoning/Implications/Related/Sourcesto 中文 at render time (applied to the body fed to bothextractTocand the viewer, so anchors stay consistent). No data migration — historical English-heading notes render in Chinese too. - Historical-data cleanup ?
scripts/cleanup_knowledge_related.py(one-off, idempotent,--dry-run/--note-id) strips legacy## Relatedstructural links (vault scaffold:index/_meta/taxonomy/projects/…, any slash-bearing target) and the old### Sourcessub-section from existing notes (DBcontent_md+ vault files, viaKnowledgeService.update_note). - Migration
20260616_02+ idempotentscripts/sql/column_additions_mysql.sqlcoverknowledge_notes.folder(+ index); the two new tables are auto-created bycreate_all/_ensure_orm_columns_syncon startup. Tests:tests/test_knowledge.py.
Custom Agent Persistence
Custom agent metadata lives in the agents ORM table (deerflow.persistence.agents). agents.id is the stable runtime id and the filesystem directory name under .deer-flow/agents/{id}/; agents.name is display-only, can contain Chinese characters, and is not unique. user_id IS NULL marks built-in agents, user-owned agents are visible only to their owner unless published = true, and update/delete operations require ownership. The runtime accepts agent_id in context/configurable, and non-default assistant_id values are treated as agent ids after visibility checks.
Strategy-selection fan-out (routers/parallel_agents.py) must keep hidden reasoning separate per agent: provider reasoning blocks/fields and inline <think>...</think> text emit reasoning_delta, while visible answer text emits delta and only visible text may enter agent_done.content. Never concatenate reasoning_delta into a selectable strategy or generated deliverable. Inspect the final AI message with the shared is_llm_error_message detector and require substantive visible text before constructing a success result. A middleware fallback, empty/thinking-only response, or punctuation-only provider degeneration is a failed seat run: raise so the stream emits agent_error; never wrap it in agent_done. Regression coverage: tests/test_roundtable_model_fallback.py.
Listing cache + pagination ? to keep GET /api/agents fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's config.yaml on each request: agent_skills (relational, one row per (agent, skill) with position) and agent_extras (one row per agent: tool_groups JSON + model + skills_synced_at). config.yaml remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by _sync_legacy_agents (mtime-vs-skills_synced_at drift check). The list endpoint enriches via AgentStore.get_extras_for([ids]) (two batched IN queries). Both tables are auto-created by Base.metadata.create_all (no migration needed). GET /api/agents supports opt-in server-side pagination: scope (mine = owned + favorited / square = built-in/published public), page, page_size, returning {agents, total, page, page_size}; omitting scope/page preserves the legacy "return everything" behavior. Name search matches the raw OR desensitized name (the frontend's static term?abbrev map is mirrored server-side in deerflow/config/agent_name_desensitize.py); tag_ids is an SQL OR-filter; owner keeps only agents published by that user id (the card's ??? ? built-in agents never match), powering the gallery's click-???-to-filter. POST /api/agents/private-square/list takes the same pagination params (incl. owner). Repository entry point: AgentStore.list_paginated(...). Tests: tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher.
Homepage selector (agent picker on the new-chat page) ? single admin-curated list backed by agents.featured_order (nullable BIGINT). NULL = not featured. Only published = true agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with order: null. The list rendered to end users is WHERE featured_order IS NOT NULL + visibility-filter, sorted by featured_order ASC. Admins reorder by sending the desired ids to PUT /api/agents/featured/order (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from Date.now() (overflows INT on MySQL).
Publish Squares (????)
Admin-configurable, access-control-free categories that published agents/skills are filed under (distinct from the password-gated ????). The square list lives in system_settings.json ? publish_squares (PublishSquaresSettings: ordered squares: [{id,name,description}] + default_square_id); helpers list_publish_squares / resolve_default_square_id / normalize_square_id always yield a valid square (built-in "????" fallback when none configured). Admin CRUD: GET/PUT /api/system-settings/publish-squares (GET readable by any user ? the gallery sub-tabs + publish picker need it; PUT admin-only, auto-slugs missing ids and re-anchors a dangling default).
Each agent/skill carries a single square_id column (AgentRow / SkillRow, String(64) NOT NULL default '', migration 20260611_01, idempotent SQL in column_additions_mysql.sql). One column = at most one square, so an item can never be "published twice". '' = unassigned ? resolved to the default square at read/filter time. Writes normalize via normalize_square_id: agents on create/update (square_id field), skills on publish (PUT /api/skills/custom/{name}/published accepts optional square_id). Listing filters: GET /api/agents?square= and GET /api/skills?square= keep only that square's items; the default square additionally sweeps in legacy '' rows (square_is_default). Tests: tests/test_publish_squares.py. Frontend: core/system-settings (usePublishSquares/useUpdatePublishSquares), admin PublishSquaresSettingsPage (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries.
Reference-mode Entry Switch
The 参考文献/RAG citation-mode composer entry is system-controlled through
system_settings.json → citation_display.reference_mode_enabled (default
false). GET /api/system-settings/citation-display remains readable so the
frontend can hide or show the entry consistently; PUT is admin-only and
partially updates reference_mode_enabled and/or cleanup_words. The normal
workspace input box, iframe chat toolbar, send context, right reference panel,
and appearance settings all use this flag, while already locked citation
threads still render the generic reference panel. A non-empty selected
LLMWiki/assistant knowledge-space set instead carries an explicit Wiki reference
contract: the frontend filters every batch to those selected server-authored ids
and renders a flat Wiki table of contents plus inline Wiki reader, never the
generic reference-card component. Projects enable this only when their project
or bound-agent knowledge ids are actually non-empty. Tests:
tests/test_prompt_prefix.py and
frontend-web/src/pages/iframe-chat-mode-buttons.test.ts.
Agent Module Tag Config (zzJC / zzXD 按类型聚合)
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent tags belong to a module. The module's sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in system_settings.json → agent_modules (AgentModuleSettings: modules: {module_key → [tag_id, ...]}); pure helpers get_module_tag_ids / set_module_tag_ids (de-dupe + strip). Endpoints (in app/gateway/routers/system_settings.py): GET /api/system-settings/agent-modules/{key} (readable by any user — the sidebar needs it to filter), PUT /api/system-settings/agent-modules/{key} (full replacement; admin only). Module keys are frontend-defined (AGENT_MODULES in components/workspace/agents/agent-module-sidebar.tsx). Tests: tests/test_agent_modules.py. Frontend: core/system-settings (useAgentModuleConfig/useUpdateAgentModuleConfig) and AgentModuleSettingsDialog.
wx common-agent category
The fixed-deep-blue wx full-chat route (#/page/workspace/wx/chats/new) hides the global header and page sidebar while retaining its workspace common-agent panel. When its optional category is unset, the panel preserves the original recommendation logic (pinned → mine → square, deduplicated). A saved category selects one Agent Gallery category: mine, sentiment, or square:<square_id> (for example the configured 默认广场). It is stored in system_settings.json → wx_common_agents (WxCommonAgentsSettings), validated against the effective publish-square list, and exposed by GET/PUT /api/system-settings/wx-common-agents. The wx account and administrators can update or clear it; all users can read it. The frontend uses useWxCommonAgents and displays the settings gear only to wx. Tests: tests/test_wx_common_agents.py.
Business-mapping authorization
/api/business-mapping remains globally readable. Seeded rows include the main codes (3Q / 6BF / 7BF / 8BF / AFX) plus independent slots 3q-0…3q-6, 6-1…6-4, and 7-1…7-4 (empty agent/chain until configured on the 配置管理 page). 3q-1…3q-6 seed as is_custom=true / mapping_type=自定义类型 so goPath=3q-1 resolves as a custom deep link; the configuration page hides those six rows and defaults the type filter to 自定义类型. Updates to 3Q, 6BF, 7BF, and those independent slots require system_role == "admin"; the 8BF public business-chain mapping is intentionally different and can be updated only by the trusted authenticated account whose email prefix is lqq. Do not rely on a username supplied in an external login URL. The frontend follows the same email-prefix rule when deciding whether to render the 8BF configuration button, while the router (app/gateway/routers/business_mapping.py) remains the enforcement point. Tests: tests/test_business_mapping.py.
Position-collaboration foundation
Roundtable business-chain enable/disable
roundtable_chains.enabled (BOOLEAN NOT NULL DEFAULT 1, migration 20260819_02)
is the per-chain 启停 switch. New and existing chains default to enabled.
Owner or admin can flip it via PUT /api/roundtable-chains/{id} {enabled}.
Disabled chains stay visible in the management list (with an 启用/停用 filter)
and still show updated_at; the frontend hides them from the picker / public
entry rail so they cannot be selected into a roundtable. Tests:
tests/test_roundtable_chain_stages.py (test_create_defaults_enabled_then_toggle).
Roundtable business-chain human validation
The existing /api/roundtable-chains seats JSON also accepts the optional
boolean human_validation field on each seat (ChainSeatSchema). It is a
frontend Step 2 checkpoint, not a server-side approval task: after the marked
seat completes (or, in a DAG, after its stage completes), the client pauses and
uses the existing resume button or intervention composer to continue. No new
database column, migration, or validation card is required.
The position-collaboration workspace is a separate frontend route and must not
use the original roundtable coordinator or jobs. Its shared configuration
integration point is the existing /api/roundtable-chains store: each seats JSON item
may carry an optional position_id (validated by ChainSeatSchema, no schema
migration). The original roundtable only reads its established seat fields and
therefore remains unchanged. The separate /api/position-roundtable router
(also mounted at /api/multi-agent/position-roundtable for intranet gateway
compatibility)
persists task/intent/frozen-chain snapshots in position_roundtable_sessions
and direct-agent node indexes in position_roundtable_nodes (migration
20260721_01). Message history remains in the established LangGraph thread
store; a node completion unlocks its next chain stage and a later upstream
revision marks downstream records stale for refresh.
POST /api/position-roundtable/sessions/{id}/nodes/{node_key}/reject uses the
same cascade: its reason is intentionally optional, the selected node becomes
rejected, and downstream material becomes stale (or locked when no
material exists) with invalidated_by set. Clients must replace their complete
session snapshot from this response so the flow, delivery list, and active
node state stay consistent. A rejected/stale node returns to done only after
a fresh Markdown delivery is recorded.
For direct-agent handoff, the router resolves artifact paths only inside the
completed upstream node's own thread, then includes bounded UTF-8 excerpts for
text outputs; binary artifacts stay as metadata-only references. Because each
seat runs on an isolated LangGraph thread, a downstream read_file on the
upstream artifact path would fail with "File not found"; the handoff therefore
also mirrors every upstream outputs/ deliverable into the current seat's
own workspace/upstream/ directory (_mirror_upstream_artifacts, bytes from
the upstream sandbox file with the SQL recovery copy as fallback, idempotent
per turn via size comparison, capped at 8 files / 2 MB each / 8 MB total) and
rewrites each handoff artifact ref's path to the mirror (upstream_path
keeps the original; mirrored_to_sandbox: true), with a matching note in the
hidden instructions telling the agent it may read_file the full text. The
mirror deliberately stays outside outputs/ so it never appears in the seat's
own delivery manifest.
POST /sessions/{id}/archive turns the session into a read-only historical
record: task/intent updates, node binding, hidden-context construction, and
turn completion are rejected, while read endpoints remain available.
Built-in summary and action-plan completion also preserves a visible report
answer when the model misses its final write_file call: the router writes
that exact answer as the required Markdown artifact in the owning thread's
outputs/ directory, so users receive both chat text and a downloadable file.
Per-turn outputs write cap (弱模型防重复产物): ordinary position-node chats
and the summary builtin send run-context position_artifact_cap: 1 (whitelisted
in services.merge_run_context_overrides). PositionArtifactCapMiddleware
then keeps at most one successful /mnt/user-data/outputs/ write_file and
one present_files per user turn — truncates excess tool calls in the same
model response (and syncs raw provider additional_kwargs.tool_calls), rejects
further writes/presents after an OK / Successfully presented files or an
earlier sibling in the same batch, ends the turn when only excess calls remain,
and reminds the model to present once (or wrap up if already presented).
Action-plan does not send the flag (it must write report .md +
action-plan-subtasks.json). Tests: tests/test_position_artifact_cap.py.
岗位 (Position) Subscription System
An admin-managed aggregation layer that decides, per user, which agents / skills
/ scheduled tasks / recommended questions they are subscribed to or can see.
A user has at most one position (users.position_id, 1:1); unassigned users
inherit the single is_default position. Note: the admin-configured prompts
shown to users ARE the recommended questions (recommended_questions table)
— there is no separate prompt-template store; the per-user user_custom_prompts
(personal 常用提示词 bar) is untouched by this system.
Persistence (deerflow/persistence/positions/, wired as
app.state.position_store; tables auto-created by create_all, migration
20260616_01):
positions(PositionRow):id,name(unique),description,is_default(only one at a time — the store clears the flag on others).position_tag_bindings(PositionTagBindingRow): one row per(position, resource_type, tag). No binding for a resource_type = "use the default set".POSITION_RESOURCE_TYPES = (agent, skill, scheduled_task, recommended_question)— the same set drives the tag types (TAG_TYPESwas extended withrecommended_question).
Sync engine (deerflow/services/position_sync.py, wired as
app.state.position_sync). sync_user(user) materializes three resource
types into the existing "我的" tables, idempotently:
- agents →
agent_favoritesrows withorigin='position' - skills →
skill_favoritesrows withorigin='position'(tableSkillFavoriteRow, migration20260616_04, auto-created bycreate_all) - scheduled tasks →
scheduled_task_subscriptionsrows withorigin='position'
Skills behave exactly like agents: a position only adds its tagged skills
to the member's 我的技能 — the browse-all 广场 is never restricted. (This replaced
the earlier query-time visibility filter, which wrongly shrank the square to the
bound skill.) PositionSyncService.resolve_visible(user, type) returns
set | None (None = unrestricted) and is now used only for the one remaining
query-time type, recommended_question.
Sync only ever adds/removes origin='position' rows, so a user's own
favorites/subscriptions are never clobbered. Default sets (no binding for a
type): agents = built-ins (user_id IS NULL); skills = empty (square stays
full, nothing added to 我的); scheduled tasks = empty; questions = untagged
only (默认岗位 / 无标签绑定 只看到未打标的题目;打标的题目只在绑定了对应标签的
岗位展示 — the router interprets resolve_visible → None as "show only untagged",
not "show everything").
origin/position_id columns were added to agent_favorites and
scheduled_task_subscriptions — see migration 20260616_01 +
scripts/sql/column_additions_mysql.sql. The running app also auto-adds these
via _ensure_orm_columns_sync on startup.
Triggers: assigning/removing members, changing tag bindings (re-syncs all
members), becoming the default position (sync_default_members), and login
(auth.py::_sync_user_position, best-effort so a brand-new user inheriting the
default gets their grants materialized).
API (app/gateway/routers/positions.py, /api/positions, admin only):
position CRUD; GET/PUT /{id}/tag-bindings; GET/POST/DELETE /{id}/members
(batch assign/unassign → triggers sync); POST /{id}/resync;
GET /users (all users + their position); PUT /users/{user_id} (set one
user's position). GET /me is the one non-admin route — it returns the
caller's own effective position (explicit assignment, else the default position
unassigned users inherit, else null); powers the top-bar 岗位 label
(strategy-components/Header.tsx user cluster, via useMyPosition). Related
changes: GET /api/recommended-questions is now
filtered by the caller's position (?all=1 bypasses for admin management) and
admin can tag questions via /api/tags/assignments/recommended_question/{id}.
Default-position rule: tags are bulk-attached to all questions first,
then the visibility filter applies — a position binding recommended_question 标签
sees only those tagged questions; a 默认岗位 / 无绑定 (resolve_visible → None)
sees only the untagged questions (打标的题目不再泄漏到默认岗位). ?all=1
(admin management) skips this filter and returns every question with its tags.
The list also accepts a repeatable tag_ids
OR-filter — so the 推荐问题配置 page can display per-question 标签 and search by
them (frontend RecommendedQuestionsPage + TagSearchBar). Tests:
tests/test_recommended_questions_tags.py.
GET /api/skills no longer restricts by position; each skill carries a
favorited flag (true = in the caller's 我的技能, e.g. position-granted) so the
skills page's 我的 tab includes owned ∪ position-granted skills while the 广场
stays complete. Frontend: core/positions
- admin
PositionManagementPage(侧边栏「综合管理 → 岗位管理」 + 右下角设置菜单). Recommended questions stay rendered above the chat input (their original position), just filtered by the caller's position. Tests:tests/test_positions.py.
Portable Column Types (deerflow/persistence/types.py)
Two TypeDecorators keep ORM columns correct across the sqlite / postgres / mysql backends. All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.
PortableJSON? nativeJSONon SQLite/Postgres;TEXT(with manualjson.dumps/loads) on MySQL, where older servers lack JSON support.PortableLongText? a large text column: maps to MySQLLONGTEXT(4 GB) and plainTEXTon SQLite/Postgres (already unbounded). Use for big JSON/markdown blobs that would overflow MySQL's 64 KBTEXTcap ? e.g.ai_writing_sessions.transcript.BeijingDateTime? stores timestamps as Beijing (UTC+8) wall-clock time. The app records time withdatetime.now(UTC), but MySQLDATETIME/ SQLite have no timezone and persist whatever wall clock the driver serializes ? so a rawDateTimecolumn would store UTC and read 8 hours behind Beijing.BeijingDateTimeconverts UTC?Beijing on bind and re-attaches+08:00on result, so the database holds Beijing time while Python still gets timezone-aware datetimes (instant comparisons stay correct, including in WHERE clauses). PostgreSQLtimestamptzstores the instant unambiguously, so values pass through unchanged there. Uses a fixed UTC+8 offset (China has no DST), so notzdatadependency.
Schema Migrations / Manual Column-Add SQL (scripts/sql/)
Gateway startup runs Alembic through app.gateway.app._run_db_migrations after ORM table creation. The migration graph must always have unique revision ids and exactly one head; tests/test_migration_graph.py enforces both. Historical duplicate revision 20260821_01 is repaired by the 20260821_03 / 20260821_04 fixed-question chain and merge revision 20260825_01. The duplicate Wiki revisions 20260916_01 / 20260920_01 are normalized by merge revision 20260916_03 and final reconciliation revision 20260920_02; the latter must inspect the actual schema and idempotently ensure Wiki edges plus retrieval/incremental-sync columns so old databases stamped by either duplicate variant converge safely. Startup DDL has a bounded timeout and failures are logged without preventing the app from serving.
Convention (required): whenever code adds a field to an existing table (a new mapped_column on an ORM model), you must, in the same change:
- Keep the matching alembic version file under
persistence/migrations/versions/(guarded with_has_table/_has_column), and - Append the idempotent
ADD COLUMNstatement toscripts/sql/column_additions_mysql.sql, with a definition matching the ORM column exactly (type, length, nullability).
Operators upgrading an existing DB may run the whole file (USE <db>; then execute) as a recovery path; it is idempotent (existing columns are skipped via information_schema.columns). Index-only changes go in the sibling scripts/sql/leaderboard_indexes_mysql.sql (same idempotent pattern) and stay out of startup migrations because online index creation on large metric tables can delay service boot.
Leaderboard scheduled-run exclusion uses correlated NOT EXISTS against the
indexed scheduled_task_runs.agent_run_id; do not restore NOT IN. Legacy
scheduler detection for tool metrics is also a correlated existence check and
must not outer-join the complete runs table into metric aggregations. Keep the
created-at + dimension compound indexes in ORM metadata and the idempotent
MySQL index script synchronized.
Sandbox System (packages/harness/deerflow/sandbox/)
Runtime performance: AppConfig.runtime_performance owns four independent
flags. skill_view_reuse_enabled and skill_view_cache_enabled default to true
when the keys are absent; set either to false and restart to restore a full
skill-view refresh on every tool call. model_timing_nonblocking_enabled and
asyncio_thread_pool_enabled stay false unless set. If application configuration
cannot be loaded, every switch stays off. sandbox/skill_view_scope.py wraps synchronous sandbox tools with a
per-invocation ContextVar snapshot, reset in finally. Reuse covers provider
initialization, path authorization from the discovered enabled_roots, command
translation and output masking; failed refreshes must not be retried by error
masking. A new tool call must validate again, including after skill offlining.
The optional skill_view.py cache validates fresh extensions bytes plus known
canonical/view file and directory metadata without the refresh lock on warm hits
of refresh_skill_sandbox_view itself. Sandbox tools do not use that full check
on the hot path: a bash command that does not name /mnt/skills does not stat
the catalogue; a command or read that names a skill reconciles only that skill
and any support library it declares. Skill-management file saves, deletions,
online/offline changes, skill deletion, and skill-address apply/rollback sync
that skill into the view immediately and publish the new extensions hash, so the
next command stays on the narrow path. Agent skill_manage create, edit, patch,
and delete do the same for the sandbox view and, when the enabled-skills prompt
cache is already warm, replace only that skill in the cache. A cold prompt cache
still reloads the full catalogue. Creating a skill stats custom/<name>/SKILL.md
instead of parsing every skill. If the model omits content, create reads a
write_file path under the thread workspace instead of failing the turn.
write_file and remove_file sync that skill's view and leave the prompt cache unchanged.
Creating an agent calls skill_list / search_skills; when this cache is warm those
tools reuse it instead of parsing every SKILL.md again, and skill_view reads only
the named skill. A cold cache still loads the catalogue, then checks the canonical
directory before a full walk.
A repeated-tool warning is appended to the assistant tool-call message so the
tool result still follows it; a user message between them makes the next model
request fail. A missing view or an extensions hash the
sync did not publish still falls back to the full rebuild. Direct edits outside
those writes stay stale until the next full rebuild. Offline skills are removed
from the view at disable time, which is what keeps a hidden shell path from
running them. Diagnostic samples are process-local and are appended only
after the sandbox tool call returns: each row stores the command text and the
skill names invoked by that call. The admin page is /page/workspace/admin/skill-timing
(GET /api/admin/skill-view-diagnostics). Recording never runs while the model
is still streaming, and a failed write cannot change the tool result.
Overlapping warm validations with the same cache entry and fresh extensions hash
are process-local single-flight; the flight is removed before followers wake, so a
later call validates again and a different extensions hash never joins. It is not
a TTL: mutations, support-library changes and target tampering invalidate the
snapshot; only rebuilds take the cross-process lock and publish the marker and
metadata manifest. Keep fixed mount paths, fail closed on invalid availability,
and never follow a target symlink while repairing or clearing the capability.
SKILL_VIEW_CACHE provides lock-wait/refresh/total timing and entry counts, not
skill contents. The full metadata check is O(number of known entries), not O(1);
the tool hot path above does not run it.
Model timing callback opt-in uses run_inline=True only for lightweight timing
and bounded enqueue; slow logging handlers run on a dedicated daemon, never on
the event loop or default tool executor. Queue overflow is best-effort diagnostic
loss with a LLM_TIMING_LOG_DROPPED summary. Other callbacks remain independent.
Regressions live in packages/harness/tests/test_runtime_performance_config.py,
test_skill_view_tool_reuse.py, test_skill_view_cache.py, and
test_model_timing_nonblocking.py. The first three flags do not increase worker
or thread counts. The fourth enables Gateway default-executor tuning in
app/gateway/runtime_executor.py, called from lifespan after config/logging setup
and before langgraph_runtime. Resolve nonempty DEER_FLOW_ASYNCIO_THREAD_POOL_SIZE
first, DEERFLOW_IO_THREADS second, and YAML asyncio_thread_pool_size last
(default 100, integer range 1..1024). Disabled means do not touch the executor,
even if an environment override is present. An invalid selected environment
value fails startup rather than silently reverting to a small pool. Log the
effective value/source/pid through GATEWAY_ASYNCIO_EXECUTOR; the AppConfig log
shows only the YAML value, not the final environment override. Uvicorn's owning
event loop cleans up the executor after runtime teardown: do not prematurely
shutdown it in lifespan, and never schedule its shutdown on itself with to_thread.
This does not modify AnyIO's separate pool, subagent event loops/executors,
Gateway worker count or upstream model quotas. Tests:
packages/harness/tests/test_gateway_runtime_executor.py. Restart after thread
pool changes; model timing instances also need restart for consistent behavior.
Interface: Abstract Sandbox with execute_command, read_file, write_file, list_dir
Provider Pattern: SandboxProvider with acquire, get, release lifecycle
Implementations:
LocalSandboxProvider- Singleton local filesystem execution with path mappingsAioSandboxProvider(packages/harness/deerflow/community/) - Docker-based isolation
Virtual Path System:
-
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/ -
Translation:
replace_virtual_path()/replace_virtual_paths_in_command() -
Detection:
is_local_sandbox()checkssandbox_id == "local" -
Configured mount portability:
AppConfig._resolve_relative_mount_pathsresolves each relativesandbox.mounts[*].host_pathagainst the loadedconfig.yamldirectory before validation/provider construction, never against the process cwd. Keep repository-owned knowledge mounts relative (for exampleknowledge/knowledge_base); absolute paths remain unchanged, and sandbox providers always receive an absolute path. -
{user_id}for path resolution =resolve_path_user_id(thread_id), using the persisted creator for every tracked thread. The route's regular owner check still authorizes access first; creator resolution only selects the stable physical directory (threads_meta.user_id) and avoids a lost request context falling back tousers/default. This also preserves cross-account task-deeplink conversations (rwfx/3qfx/xdfx) and roundtable system threads. Positive creator mappings are cached process-wide and DB/memory/missing-row failures fall back to the current login. It is wired at the thread-path consumers includingThreadDataMiddleware.before_agent,present_file_tool, uploads, artifact serving, and embeddedclient.get_artifact. Rewrite-history recovery is metadata-only and job-owner scoped (list_by_session(..., user_id=current_user)), so its GET/list endpoints intentionally skip redundant thread-owner checks and return an empty history instead of a history-only 404; the start/commit endpoints retain strict thread write authorization. Tests:tests/test_thread_paths_owner.py,tests/test_thread_paths_roundtable.py. -
Durable document-rewrite commit:
document_rewrite_pipeline.atomic_write_textwrites through a deliberately short sibling temporary filename so long Windows artifact paths stay below the path-length limit, and guarantees the targetoutputsparent exists. If the sandbox recreates that directory in the narrow commit window, it repairs the directory and retries; the original file is otherwise only changed throughos.replace. -
Deep Research structural regeneration:
POST /api/deep-research/sessions/{id}/report-variantscreates a distinct completed session from the source report and its selected evidence. It rebases both saved[来源:source_id]and legacy streamed[[source:source_id]]markers to cloned ids, and removes inherited markers with no selected clone. The frontend uses this endpoint only for “选择结构再生成”, then queues the durable writer withoperation=generate; generation receives the confirmed outline and complete selected evidence set but never receives the inherited report as text to edit. The new report's completed card can subsequently start a separate in-placeAI 全文改写. Requirement plans are deterministic and operation-specific, model reasoning is forwarded as live SSE before the first Markdown token, and generation prompts emit display-ready[来源:id]citations. Citation validation repairs only a unique near-completedrs_src_*prefix (provider-truncated suffix) and still rejects genuinely unknown ids. The report-only stream requests 8192 output tokens (except Codex Responses models, which reject token-limit kwargs) and rejects a malformed trailing citation instead of committing a provider-truncated report. Never restore the former second planning-model call unless its latency and streaming contract are redesigned together.
Sandbox Tools (in packages/harness/deerflow/sandbox/tools.py):
bash- Execute commands with path translation and error handlingls- Directory listing (tree format, max 2 levels)read_file- Read file contents with optional line rangewrite_file- Write/append to files, creates directoriesstr_replace- Substring replacement (single or all occurrences); same-path serialization is scoped to(sandbox.id, path)so isolated sandboxes do not contend on identical virtual paths inside one process
Subagent System (packages/harness/deerflow/subagents/)
Built-in Agents: general-purpose (all tools except task) and bash (command specialist)
Execution: Dual thread pool - _scheduler_pool (3 workers) + _execution_pool (3 workers)
Concurrency: MAX_CONCURRENT_SUBAGENTS = 3 enforced by SubagentLimitMiddleware (truncates excess tool calls in after_model), 15-minute timeout
Flow: task() tool ? SubagentExecutor ? background thread ? poll 5s ? SSE events ? result
Events: task_started, task_running, task_completed/task_failed/task_timed_out
Tool System (packages/harness/deerflow/tools/)
get_available_tools(groups, include_mcp, model_name, subagent_enabled) assembles:
- Config-defined tools - Enabled entries are resolved from
config.yamlviaresolve_variable(); entries withenabled: falseare omitted before import and are not registered with the agent - MCP tools - From enabled MCP servers (lazy initialized, cached with mtime invalidation)
- Built-in tools:
present_files- Make output files visible to user (only/mnt/user-data/outputs)ask_clarification- Request clarification (intercepted by ClarificationMiddleware ? interrupts)view_image- Read image as base64 (added only if model supports vision)
- Subagent tool (if enabled):
task- Delegate to subagent (description, prompt, subagent_type, max_turns)
Community tools (packages/harness/deerflow/community/):
tavily/- Web search (5 results default) and web fetch (4KB limit)jina_ai/- Web fetch via Jina reader API with readability extractionfirecrawl/- Web scraping via Firecrawl APIconfigurable_search/- Config-drivenPOSTJSONweb_searchreplacement. It deep-copies the configured payload and overwrites onlyquery, supports HTTP/HTTPS plus configurableverify_ssl, timeout and result limit, normalizes nullablerecUuid/content/title/urlfields, and rewrites a result URL throughresult_url_templateonly whenrecUuidis non-empty. The required response envelope is{"results": [...]}. Tests usehttpx.MockTransportand never require the deployment endpoint.
ACP agent tools:
invoke_acp_agent- Invokes external ACP-compatible agents fromconfig.yaml- ACP launchers must be real ACP adapters. The standard
codexCLI is not ACP-compatible by itself; configure a wrapper such asnpx -y @zed-industries/codex-acpor an installedcodex-acpbinary - 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 bytools.py image_search/- Image search via DuckDuckGo
MCP System (packages/harness/deerflow/mcp/)
- Uses
langchain-mcp-adaptersMultiServerMCPClientfor multi-server management - Lazy initialization: Tools loaded on first use via
get_cached_mcp_tools() - Cache invalidation: Detects config file changes via mtime comparison
- Transports: stdio (command-based), SSE, HTTP
- OAuth (HTTP/SSE): Supports token endpoint flows (
client_credentials,refresh_token) with automatic token refresh + Authorization header injection - Runtime updates: Gateway API saves to extensions_config.json; LangGraph detects via mtime
Skills System (packages/harness/deerflow/skills/)
-
Cognitive situation-map runtime parity:
cognitive-situation-maphas a source runtime underskills/public/cognitive-situation-map/assets/runtime/and a Gateway-served copy underpackages/harness/deerflow/html_runtime/assets/cognitive-situation-map/. Keep their JavaScript, schema, and map atlas assets in sync.template.aspectlocks a new page to16:9(1920×1080, the default when unspecified) or32:9(2560×720, explicit ultra-wide only); pages without the field keep the older viewport-adaptive layout. Built-in regions include Taiwan Strait, South China Sea, the Philippines-centred surrounding region (with its southern Sulu/Celebes approaches), First Island Chain, and the U.S.--Pacific connection; every region is a four-quadrant atlas for the terrain / maritime / tactical / briefing variants.scripts/render.mjspermits a direct inputscenario.jsonas the sole item in a fresh output directory; all other non-empty output directories need a new path or explicit overwrite authorization. -
Location:
deer-flow/skills/{public,custom}/ -
Format: Directory with
SKILL.md(YAML frontmatter: name, description, license, allowed-tools).nameanddescriptionare the minimal metadata contract; the frontend install dialog renders the sharedMINIMAL_SKILL_MD_EXAMPLEcontaining only those two keys so optional fields are not mistaken for requirements. Keepfrontend-web/src/core/skill-example.test.tsaligned with backend validation. -
Loading:
load_skills()recursively scansskills/{public,custom}forSKILL.md, parses metadata, and reads enabled state from extensions_config.json. A directory containingSKILL.mdis a self-contained leaf — discovery does not descend into its subdirectories, so a skill package that bundles its ownexamples//references/sub-skills (each with a SKILL.md) surfaces as one skill, not a swarm of phantom entries. -
Built-in seed source: Product-owned defaults live under
app/gateway/routers/_*_seed_assets/skills/and must be versioned even though runtimeskills/trees are ignored. Seeders copy only when a target directory is absent and never overwrite administrator changes. Internal support packages such astask-report-libdeliberately omitSKILL.mdand declare dependents in.deerflow-skill-support.json; keeptests/test_seed_assets.pyandtests/test_task_report_convert.pysynchronized with the shipped inventory and contracts. -
Online/offline lifecycle: reuse
Skill.enabled; do not add a parallel lifecycle flag and do not substitutepublishedor curatorstate.PUT /api/skills/{name}is a global switch: administrators may toggle every skill, while an ordinary user may toggle only a non-built-incustomskill whose persistedowner_user_idmatches that user. It patches onlyskills.<name>.enabledin the rawextensions_config.json, preserving MCP placeholders and unrelated/unknown fields, then reloads configuration and refreshes the lead prompt cache. Writes use atomic replacement on ordinary files and anfsyncin-place fallback for writable Docker single-file bind mounts; supported launchers instead keep the runtime file under a writable persisted config directory. Offline skills remain on disk and in the full catalogue (load_skills(enabled_only=False)), and existing agent/workflow references are deliberately retained so bringing the skill online restores them. The per-skill incremental view sync must treat a missing destination as stale even when its target-cache entry is also absent, so re-enabling recopies the latest canonical package (includingSKILL.mdand nested helpers edited while offline) after the published copy was removed. Every model-facing path must use the enabled catalogue: prompt injection,search_skills,skill_list,skill_view, subagents, AI-writing/deep-research/roundtable discovery, and Workflow run-start resource validation. Direct sandbox access is enforced by a copied online-only view under writableDEER_FLOW_HOME;/mnt/skillsin LocalSandbox and AIO/remote sandboxes must point only to that view, never to the canonical tree. AIO creation/reuse attests the view marker and fails closed when an older provisioner ignores the requested mount; explicitly declared internal support libraries (such astask-report-lib) enter the view only while a dependent skill is online. Path-sensitiveDEER_FLOW_*variables andPYTHONPATHare removed from LocalSandbox bash while non-path settings such asDEER_FLOW_GATEWAY_PORTremain. LocalSandbox host bash still shares the backend UID and is not an OS isolation boundary; production requiring hostile-code isolation must use an attested AIO sandbox. Frontend controls are in the administrator 技能管理 list and, for owners, 「技能 → 技能列表 → 我的」; offline rows and retained agent references remain visible with an 已下线 badge and can be removed, while new selectors exclude them. -
Discovery↔delete consistency: a skill is keyed for discovery by its frontmatter
name, but its on-disk directory name may differ (hand-dropped / mis-extracted package).LocalSkillStorage._resolve_skill_dir(name, category)resolves the discovered directory (canonical<category>/<name>fast-path, else scan-by-frontmatter-name);custom_skill_exists/read_custom_skill/delete_custom_skillall go through it, so anything that gets listed can also be read and deleted regardless of directory name (the old code assumedcustom/<name>/and 404'd "Custom skill 'x' not found." on any non-canonical layout). Delete is path-guarded to never escape thecustom/root. Tests:tests/test_skill_storage_delete.py. -
Injection: Enabled skills listed in agent system prompt with container paths
-
Configurable
es_queryrouting prompt:config.yaml → skills.es_query_routingcontains anenabledswitch and editableprompt. When enabled,lead_agent.prompt._build_es_query_routing_sectioninjects the rule into the default lead-agent system prompt. Custom-agent-only prompts receive it only when their explicit skill allowlist containses_query; disabling the switch or leaving the prompt blank emits no block. Immediately before an ordinary Q&A graph is created,lead_agent.agent._log_es_query_routing_registrationchecks the exact configured block against the final system-prompt string and logsenabled, allowlist eligibility,registered, character count, and a short SHA-256 fingerprint (never the prompt body). Tests:tests/test_es_query_routing_prompt.py. -
Installation:
POST /api/skills/installextracts .skill ZIP archive to custom/ directory. Direct uploads use the ownership-awarePOST /api/skills/validate?target_category=...preflight plusPOST /api/skills/install-upload: a same-owner custom conflict may be uploaded again withoverwrite=true, while a different owner's conflict is non-overwritable and returns that owner's username (falling back to user id). The install endpoint repeats the ownership check, including for admins targetingcustom, so callers cannot bypass the preflight. Public-skill overwrite remains admin-only. -
Installable sentiment analysis package:
skill-packages/sentiment-analysis.skillis deliberately separate from the frontend virtual-agent chat page. The chat page goes through GatewayPOST /api/sentiment-agent/stream(runtime-config URL/Basic Auth in the body, TLS verify off, raw AG-UI SSE passthrough). The skill package's self-containedconfig/sentiment-agent.jsonowns the external AG-UI address for agent-skill callers; Basic Auth is resolved fromSENTIMENT_AGENT_AUTH_USERNAME/SENTIMENT_AGENT_AUTH_PASSWORDat runtime (not committed into the package).scripts/sentiment_stream.pyuses only the Python standard library and converts upstream SSE to stable NDJSON events (thinking_delta,answer_delta,done), with a Markdown convenience mode for normal agent calls. Installation/calling guide:skill-packages/sentiment-analysis/docs/调用与安装说明.md. -
Chinese name (
name_zh): a Chinese display name stored in theskillsDB table (SkillRow.name_zh,String(128), migration20260609_01) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded throughSkillStorecreate/ensure_legacy/update/update_admin exactly likedetail; surfaced onSkillResponse.name_zhfrom the ownership record in_skill_to_response. Authorization mirrorsdetail: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand viaensure_legacy). Endpoints (inapp/gateway/routers/skills.py):PUT /api/skills/{name}/name-zh(set/clear),POST /api/skills/{name}/name-zh/generate(LLM generates one),POST /api/skills/name-zh/generate-missing(????: admin-only bulk-generate for every skill that lacks one, bounded-concurrencyasyncio.gather). Optional model selection: both generate endpoints accept an optional JSON body{model_name}? an explicit name wins, else the curator model setting, else the main model (_resolve_skill_name_zh_model_name). Model-error surfacing:_generate_skill_name_zhraisesSkillNameZhModelErrorwhen the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (?None). The single endpoint maps that to502 ?????,?????????:?; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representativemodel_errorstring inSkillNameZhBulkResponseso the UI can say "N ?????????". The frontend (skill-settings-page.tsxlist +skill-detail-dialog.tsxeditor, plus the agent skill pickers/badges inNewAgentPage.tsx+agent-card.tsx) showsname_zh || name, offers per-skill "AI ??" plus an admin-only header "???????" button, and a compact model dropdown (components/workspace/model-name-select.tsx, default = configured model) beside each action.
Skill Address Management (技能地址管理 — URL/IP 批量替换)
Admin-only maintenance tool to find every http(s):// URL and IPv4 (ip[:port])
address in the running skills (skills/{public,custom}, all .md + .py,
recursing into scripts//references//templates/) and rewrite them in bulk —
e.g. swapping a UAT endpoint for production across all skills at once. The same
address commonly lives in both a skill's SKILL.md and its scripts/*.py, so
results are aggregated by unique address with every occurrence (file:line +
snippet) and replaced together.
- Pure engine —
deerflow/skills/address_scan.py(harness, noapp.*):find_spans/scan_occurrences(URL regex with trailing-punctuation strip; IPv4 with 0–255 octet check + optional port; an IP inside a URL is not separately surfaced),apply_replacements(substring-safe: re-detects exact token spans and only rewrites a span whose text equals a requestedfrom, so1.2.3.4is never rewritten inside11.2.3.40),infer_kind,is_valid_replacement. Tested intests/test_skill_addresses.py. - Router —
app/gateway/routers/skill_addresses.py(/api/skill-addresses, admin only). Availability at scale (150+ skills): every filesystem walk/read/write runs viaasyncio.to_threadso the event loop is never blocked. Routes:GET /scan(aggregated, one-shot) +GET /scan/stream(per-skill NDJSON progress{type:start|progress|result});POST /preview(dry-run diff + validation warnings);POST /apply+POST /apply/stream(per-file NDJSON progress);GET /backups;POST /rollback/{backup_id}. Apply safety: optimistic lock viaexpected_total(409 if files changed since the scan), path-guard (is_relative_tothe skills root), auto disk backup to{skills_root}/.address-edits/{timestamp}/(the.-prefixed dir is excluded from scans), thenrefresh_skills_system_prompt_cache_async()so edits take effect immediately. Registered inapp.pyaftersensitive_words.router. Per-edit history: each apply also writes a sibling manifest{skills_root}/.address-edits/{timestamp}.manifest.json(best-effort) capturing{created_at, replaced_total, replacements:[{from,to,kind}], changed_files}; the sibling-not-child placement keeps it out of both the restore set and the file count.GET /backupsreturns each record enriched withreplaced_total+replacements(from the manifest; falls back to dir mtime when absent), andPOST /rollback/{backup_id}restores a single record — so the UI shows a full modification history and rolls back any one entry independently. - Frontend — the panel is the reusable
SkillAddressesPanel(exported frompages/SkillAddressesPage.tsx; its default export keeps the standalone route/page/strategy/admin/skill-addressesas a direct-link fallback). It is embedded as a「技能地址管理」tab inside the admin「技能管理」pagepages/AdminSkillsPage.tsx(Tabs: 技能列表 + 技能地址管理; reached from the 「技能管理」item in the「设置和更多」dropdownworkspace-nav-menu.tsx→/page/workspace/admin/skills) — it is no longer a separate entry in thecomponents/page-sidebar.tsxsettings dropdown. The panel: auto-scan on open with a progress bar (扫描逐技能 / 应用逐文件 X/N), URL/IP type filter, search, a「片段批量套用」helper (e.g. alluat.4.cn→prod.4.cn), per-address「替换为」input with expandable occurrences, inline 预览 panel, 应用, multi-keyword search (space-separated, all-must-hit), and a 修改记录 (modification history) panel — each apply is one record (time / which addresses changed from→to / file & replacement counts) with a per-record 回退此次 (single-record rollback, with inline confirm). Client + NDJSON stream parsing instrategy-components/api/skill-addresses.ts. Scope is the running skills only; thedeploy/minimal/skills/copy is not touched (sync separately).
Skill Compression (技能压缩)
When the skill catalogue grows large, injecting every enabled skill full-text into the lead agent's system prompt bloats it and wastes tokens. Skill compression is an admin-gated mode that keeps only 常驻(免压缩) skills in the prompt body and surfaces the rest on demand via keyword retrieval.
- Master switch —
system_settings.json → skill_compression(SkillCompressionSettings:enableddefault false,search_top_ndefault 5,keep_index_in_promptdefault true). Read/written byGET/PUT /api/system-settings/skill-compression(GET open to any user — the skill detail page reads it; PUT admin-only, refreshes the prompt cache). - Per-skill 常驻 flag — stored in the DB:
SkillRow.always_on(Boolean, migration20260616_03, idempotent SQL incolumn_additions_mysql.sql), not inextensions_config.json(the source-tree config file is read-only / unsafe to rewrite in some deployments, and built-in skills carry no file entry). Threaded throughSkillStorecreate/ensure_legacy/update/update_admin +list_always_on_names()exactly likename_zh/detail; surfaced onSkillResponse.always_onfrom the ownership record in_skill_to_response. Toggled viaPUT /api/skills/{name}/always-on(custom skill → owner or admin; built-in / public → admin only, materializes a row on demand viaensure_legacy). The prompt builder stampsSkill.always_onfromlist_always_on_names()via a sync DB bridge (_apply_always_oninlead_agent/prompt.py). - Prompt split —
get_skills_prompt_section(lead_agent/prompt.py) reads the compression settings (skipped for restricted custom agents — they already have a curated allowlist). When off, behaviour is unchanged (all enabled skills full-text). When on,always_onskills render as full<skill>blocks; the rest are dropped from the body, replaced by asearch_skillsusage note plus (whenkeep_index_in_prompt) a name-only<compressed_skills>index. The_get_cached_skills_prompt_sectioncache key includes thealways_onflag + compression flags so changes invalidate correctly. - Retrieval tool —
search_skills(tools/builtins/skill_tools.py,search_skills_impl) keyword-scoresname + descriptionover enabled, visible, non-archived skills (same visibility filter asskill_list) and returns top-N{name, description, category, location}. It is registered inget_available_toolsonly when compression is enabled (offline, deterministic, zero external deps). The agent thenread_files the returnedlocation.
Tests: tests/test_skill_compression.py.
Skill Curator (packages/harness/deerflow/skills/curator.py)
Background skill lifecycle manager. The Gateway lifespan starts a resident
scheduler task (_curator_scheduler_loop in app/gateway/app.py) that ? after a
5-minute startup delay ? calls maybe_run_curator() every hour. A cross-process
file lock (.curator_scheduler.lock) elects a single leader worker so the
scheduler runs once per deployment. Per run:
- Master switch ?
skill_evolution.enabled(defaultfalse) gates the whole feature;curator.enabled(defaultfalse) gates the curator specifically. Both must betruefor the curator to act.should_run_now()and_curator_thread_target()both enforce this ? so manualPOST /api/curator/runalso respects the master switch. Config isconfig.yaml.skill_evolutiondeep-merged with per-userusers/{user_id}/skill_evolution.json. - Per-user run lock ?
.curator_statecarriesrunning/started_at;_try_acquire_run_lock()prevents the same user's curator from running concurrently (scheduler vs. manual trigger, or double manual clicks). A lock whosestarted_atis older than_CURATOR_STALE_LOCK_HOURS(6h) is treated as process-crash residue and can be taken over. - Concurrency cap ?
maybe_run_curator()collects all due users and hands them to_dispatch_curator_runs(), which runs them through aThreadPoolExecutorcapped at_CURATOR_MAX_CONCURRENCY(5) ? avoids a thundering herd of LLM calls when many users come due at once (e.g. first deploy). - Failure back-off ?
_release_run_lock()always stampslast_attempt_at. When a first run keeps crashing (last_run_atnever set),should_run_now()retries on_CURATOR_RETRY_HOURS(6h) instead of every hourly tick. should_run_now()is the real gate: the hourly tick only checks "is the per-userinterval_hours(default 168h) elapsed". Timestamp parsing tolerates naive (tz-less) strings ? a corruptlast_run_attriggers a re-run rather than a permanent deadlock.- Published-skill filter ?
SkillStore.list_published_names()is a batch query; the curator fetches the published-name set once per run instead of one DB round-trip per candidate skill. - LLM review timeout ?
_run_llm_review()runsagent.invoke()in a worker thread bounded bycurator.review_timeout_seconds(default 600s). The model's per-requestrequest_timeoutcan't bound a multi-turn React agent loop, so on timeout the run is abandoned (a warning is written tocurator_log, visible in the Web UI) and the curator finishes cleanly + releases the run lock. The worker context is copied viacontextvars.copy_context()so tool calls still resolve the correctuser_id. - Review model ?
_run_llm_review()usescurator.model_name(aconfig.yamlmodels[]entry name) if set, else the primary model; it is editable from the Web UI evolution panel. The skill security scanner (scan_skill_contentinsecurity_scanner.py) reuses the sameskill_evolution.curator.model_namesetting ? review and moderation share one model. There is no separate moderation-model config field.
Model Factory (packages/harness/deerflow/models/factory.py)
create_chat_model(name, thinking_enabled)instantiates LLM from config via reflection- Supports
thinking_enabledflag with per-modelwhen_thinking_enabledoverrides force_disable_thinking=True(kwarg) hard-disables thinking for models that explicitly declaresupports_thinking: trueeven when the model config declares nowhen_thinking_*/thinkingshape — many internal vLLM/Qwen models only set that flag, sothinking_enabled=Falsealone injects nothing and they keep reasoning on. The flag injects the OpenAI-compatible disable shapes (extra_body.enable_thinking=false,extra_body.chat_template_kwargs.enable_thinking=false, andextra_body.thinking.type="disabled") only for classes that acceptextra_body. Models withsupports_thinking: false, especially generic OpenAI-compatible proxies such as LiteLLM, receive no vendor-specific thinking fields because those gateways may reject unknown parameters. MirrorsTitleMiddleware._force_disable_thinking. The lead agent reads the runtime flagthinking_force_disabledand forwards it (used by/api/open/chat)- Supports vLLM-style thinking toggles via
when_thinking_enabled.extra_body.chat_template_kwargs.enable_thinkingfor Qwen reasoning models, while normalizing legacythinkingconfigs for backward compatibility - Supports
supports_visionflag for image understanding models - Config values starting with
$resolved as environment variables - Missing provider modules surface actionable install hints from reflection resolvers (for example
uv add langchain-google-genai) - Generic
langchain_openai:ChatOpenAIentries are transparently wrapped byPatchedChatOpenAI. Besides preserving reasoning/tool-call metadata, the patch recognizes compatible gateways that encode failures inside HTTP 200 payloads (base_resp.status_code != 0with null/emptychoices) in both streaming and non-streaming paths. It raises the stableOpenAICompatibleUpstreamError;LLMErrorHandlingMiddlewareretries transient concurrency/inference failures, while genuine quota/balance failures are classified as non-retriable and surfaced immediately. Embedded vendor failures therefore cannot become empty successful assistant turns. The same adapter repairs only bounded, information-preserving compatibility deviations before LangChain conversion: a streamed fullmessage, choice-root tool calls, tool calls beside an emptydelta, singleton tool call objects, structuredfunction.arguments, and missing/string indexes. Tool argument assembly is invocation-local (never model-instance shared), assigns a stable canonical index to staggered id-bearing parallel calls, and distinguishes confirmed cumulative snapshots from standard OpenAI deltas; once a call is identified as delta mode, brace fragments and all later bytes pass through unchanged. Geminithought_signature/thoughtSignature(includingextra_content.googlespellings) is extracted before the SDK drops extension fields, stored as dedicated id/index metadata, and restored at the same wire location on the next request. Keep the concurrent-stream, nested-brace, signature round-trip, and real-SDK structured-argument tests inpackages/harness/tests/test_patched_openai_tool_args.pywhen changing this boundary. - Model observability is content-free.
ModelTimingCallbacklogs connection, first chunk, completion/failure/cancellation latency against a sanitized upstream origin and run id.PatchedChatOpenAIemits oneOPENAI_TOOL_STREAM_SUMMARYfor tool/repair/error streams with shape counts and first-tool-argument latency.LLMErrorHandlingMiddlewarelogs only stable retry fields (attempt,reason, error type, status and provider code); raw provider details remain available only for in-process classification and must never enter exception strings, logs, or stack traces. Prompts, response text, tool arguments, file contents, credentials and raw exception messages are forbidden in these records.GeneratorExit/CancelledErroris an INFO-levelstatus=cancelled, not a failed-call alarm. Diagnostic handlers must not replace a successful result, the original model exception or cancellation when they fail. Safe upstream exception strings retain private classification detail only in memory: context recovery must still recognize numeric vendor codes with context-length messages, and auth classification must check structured codes as well as human descriptions. - LangGraph run-creation streams must expose both
Content-Location(canonical run resource) andLocation(the/threads/{thread}/runs/{run}/streamreconnect path relative to the SDK's/apibase). The CORS middleware must continue exposing both headers. The frontend records each consumed SSE event id before yielding it to React. Cursor ownership must match exactly one SDK message accumulator: same-reader recovery supplies that cursor; a newly mounted hook or a real thread switch must reconstruct from-1, not inherit another reader's cursor. Shared API clients never rewrite that sentinel.useThreadStreamcounts message-tuple changes as business activity, probes a known active run only after 90 seconds without a business frame, and stops only the local reader before rejoining. Clean-EOF, transport-error, conflict and watchdog triggers share one single-flight reconciliation so they cannot double-join or double-finalize. - The default
MemoryStreamBridgeandRunManagerare process-local. Gateway streaming must run with one uvicorn worker:Location,/join, cancellation, andLast-Event-IDrecovery are separate HTTP requests and a different worker cannot see the originating worker's live task or buffered events. A durable checkpointer/RunStore stores history but is not a shared live stream/control plane.scripts/start_backend-online.shandoffline-backend-20260512/start_backend.shmust keepGATEWAY_WORKERS=1as their safe default and reject larger values unless an operator explicitly accepts the unsafe override. Multi-worker support requires shared implementations for both event transport and live-run ownership; sticky routing is not a fix.
Roundtable Model Fault Tolerance (会商模型自动容错 / 换下一个模型)
会商第二步(leader/seat)+ 第三步(summary/report/dashboard + 结构化抽取)单轮执行时,某模型出问题就自动切到下一个模型继续,并告知用户「『X』模型异常 → 已切换『Y』」。前台实时(multi_agent.py)与后台挂起(engine.py + InProcessRoundtableGateway)两条路径都覆盖。
- 可检测的模型失败:
LLMErrorHandlingMiddleware在同一模型重试 3 次后不抛异常,而是返回兜底AIMessage—— 现在给它打additional_kwargs[LLM_ERROR_MARKER="deerflow_llm_error"]=reason(model_dump 保留,经 checkpointer 序列化仍在)。is_llm_error_message(msg)据标记(回退按已知兜底文案正文)识别,返回 reason。 - 候选模型链:保留真正模型故障后的自动换模,但“仅有思考 / 暂时无正文 / 空交付”不再判作模型故障,绝不据此切换用户选择的模型。
app/gateway/roundtable_model_fallback.py::fallback_model_chain(current)按config.yaml models[]顺序给出[当前模型, …其余](去重保序)。reason_text(reason)翻译成简短中文。总开关 envROUNDTABLE_MODEL_FALLBACK(默认开,0/false/off关);ROUNDTABLE_MODEL_FALLBACK_MAX(0=试完全部)。 - 触发换模型的情形:① 单轮 run 抛异常(start_run 失败 / idle 卡死 →
timeout,其余exception);② 兜底错误文案(is_llm_error_message)。交付无效、仅有思考、空串以及普通总控轮既无正文又无派活均不触发换模;唯一例外是无工具的最终收口请求已经完整结束仍无正文,此时结果客观不可交付,才继续自动换下一个模型。席位仍只认剥掉<think>后的可见正文(deerflow.agents.roundtable_orchestrator.delivery:visible_seat_text/classify_seat_delivery):前台_special_run对无效交付 yieldstatus:"invalid_delivery"且不广播;GET /last-ai-message回{delivered, content, length, invalid_reason}(思考-only 标thinking_only,总控席位清单标 ⚠️)。后台引擎只把可见正文写入deliveries,无效席位继续走既有未交付处理,不切换模型。Step3 单例(report/summary/dashboard/ingest/结构化)的产物是落盘文件,同样只对真模型错误换模型。 - Step2 总控工具与可配置收口轮数:普通总控使用真正白名单,只允许
agent_orchestration/ask_clarification;write_file、present_files、技能目录与文件/命令工具均不可见。system_settings.json -> roundtable_room_limit.max_cycles(默认 8,范围 2..100;管理员在设置弹窗「会商并发与收口」中修改)控制 recommend 模式到第几轮切换为显式finalize_only:空工具白名单、注入已有有效席位全文、要求直接输出最终总结(失败席位如实标缺口),不能表现为“已停止”。一次会商启动时锁定当时值,运行中修改从下一次研讨生效;前台实时会商与后台作业读取同一设置。同一页的「会商文件流式兼容」(write_file_compatibility_enabled,默认关) 只作用于会商总控、席位、第三步和后台作业:全局文件流式兼容开着时会商一并使用;全局关着而本开关开着时,只有这些会商 run 改走buffered_gateway。普通对话不读这个开关。chain / dag 的最终总结仍在既定席位完成后走同一纯正文路径;LangGraph 单轮 recursion limit 仍为 1000。 - 后台:
InProcessRoundtableGateway._run_turn_with_fallback(...)包住_run_agent_turn,逐候选模型试;换模型落model_switch诊断、全失败落model_fallback_exhausted。run_leader/run_seat/run_report/run_summary/run_dashboard_data全走它(report/summary/dashboard 的产物自愈重试保留,并记住已切到的可用模型用于后续续写轮)。各 Turn/Result 带回switches: list[ModelSwitch],引擎_emit_switches→JobProgressWriter.model_switch(...)往研讨时间线插一条role="system"系统提示气泡。 - 前台:
_special_run/_leader_runstream 收尾后仅检测明确的模型错误标记;命中才 yield{status:"model_switch", role, agent_name, failed_model, next_model, reason}帧并用下一个模型重新流式,思考-only/空正文不进入该分支;终态帧带model_used。结构化抽取flow_extract.py的 round1model.astream也包了同款换模型循环(emitevent:"model_switch"帧)。 - Step2 单席位隔离:
engine._run_recommend/_run_chain/_run_dag的每个席位执行包 try/except —— 一个席位彻底失败(落seat_failed诊断 + 置空交付)不拖垮整批/整链/整个作业。 - 前端:
multi-agent.ts解析model_switch帧 →onModelSwitch回调(清空失败尝试的半截气泡 + 累计的 tool_call delta,phase 退回 connected);useStep2Orchestration弹 toast + 时间线插「系统提示」气泡;useStep3Summary/Report/Dashboard+flow-extract.ts重置对应气泡并在步骤卡留可见提示;后台路径经job-live.ts::toStep2Dialogue把role="system"渲染成 amber「系统提示」气泡。 - Tests:
tests/test_roundtable_model_fallback.py。
vLLM Provider (packages/harness/deerflow/models/vllm_provider.py)
VllmChatModelsubclasseslangchain_openai:ChatOpenAIfor vLLM OpenAI-compatible endpoints- Thinking text is accepted from either
reasoning(newer vLLM parsers) orreasoning_content(older vLLM / DeepSeek-style). The UI still reads canonicaladditional_kwargs.reasoning_content. Follow-up turns echo only the wire field that actually arrived, so other ChatOpenAI models are unchanged - Shared helpers live in
deerflow/models/reasoning_fields.pyand are also used byPatchedChatOpenAI(the defaultlangchain_openai:ChatOpenAIpatch) - Designed for configs that enable thinking through
extra_body.chat_template_kwargs.enable_thinkingon vLLM Qwen reasoning models, while accepting the olderthinkingalias - Per-model switch: set only that entry's
use: deerflow.models.vllm_provider:VllmChatModel. Do not change SiliconFlow / DeepSeek entries that already uselangchain_openai:ChatOpenAI
IM Channels System (app/channels/)
Bridges external messaging platforms (Feishu, Slack, Telegram, DingTalk) 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.
Components:
message_bus.py- Async pub/sub hub (InboundMessage? queue ? dispatcher;OutboundMessage? callbacks ? channels)store.py- JSON-file persistence mappingchannel_name:chat_id[:topic_id]?thread_id(keys arechannel:chatfor root conversations andchannel:chat:topicfor threaded conversations)manager.py- Core dispatcher: creates threads viaclient.threads.create(), routes commands, keeps Slack/Telegram onclient.runs.wait(), and usesclient.runs.stream(["messages-tuple", "values"])for Feishu incremental outbound updatesbase.py- AbstractChannelbase class (start/stop/send lifecycle)service.py- Manages lifecycle of all configured channels fromconfig.yamlslack.py/feishu.py/telegram.py/dingtalk.py- Platform-specific implementations (feishu.pytracks the running cardmessage_idin memory and patches the same card in place;dingtalk.pyoptionally uses AI Card streaming for in-place updates whencard_template_idis configured)
Message Flow:
- External platform -> Channel impl ->
MessageBus.publish_inbound() ChannelManager._dispatch_loop()consumes from queue- For chat: look up/create thread through Gateway's LangGraph-compatible API
- Feishu chat:
runs.stream()? accumulate AI text ? publish multiple outbound updates (is_final=False) ? publish final outbound (is_final=True) - Slack/Telegram chat:
runs.wait()? extract final response ? publish outbound - Feishu channel sends one running reply card up front, then patches the same card for each outbound update (card JSON sets
config.update_multi=truefor Feishu's patch API requirement) - DingTalk AI Card mode (when
card_template_idconfigured):runs.stream()? create card with initial text ? stream updates viaPUT /v1.0/card/streaming? finalize onis_final=True. Falls back tosampleMarkdownif card creation or streaming fails - For commands (
/new,/status,/models,/memory,/help): handle locally or query Gateway API - Outbound ? channel callbacks ? platform reply
Configuration (config.yaml -> channels):
langgraph_url- LangGraph-compatible Gateway API base URL (default:http://localhost:8001/api)gateway_url- Gateway API URL for auxiliary commands (default:http://localhost:8001)- In Docker Compose, IM channels run inside the
gatewaycontainer, solocalhostpoints back to that container. Usehttp://gateway:8001/apiforlanggraph_urlandhttp://gateway:8001forgateway_url, or setDEER_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, optionalcard_template_idfor AI Card streaming)
Memory System ? Memory V2 (packages/harness/deerflow/agents/memory/)
Pluggable memory architecture (Hermes-style). See docs/MEMORY_V2_DESIGN_ZH.md
and docs/HINDSIGHT_SETUP_ZH.md for the full design.
Components:
provider.py-MemoryProviderabstract base (12 lifecycle/hook methods)manager.py-MemoryManagerorchestrates builtin + ?1 external provider;build_memory_manager()is the construction entry pointproviders/builtin.py-BuiltinFileProvider: local USER.md / MEMORY.md, ? delimiter, char quotas, file locking, atomic writes, frozen snapshotproviders/hindsight.py-HindsightProvider: HTTP client to a self-hosted Hindsight Docker instance, dual-bank routingsecurity.py- injection/exfiltration scan + invisible-unicode block for memory contentmessage_processing.py-filter_messages_for_memory(last-turn extraction for Hindsight retain)
Two stores (binary classification):
USER.mdat{base_dir}/users/{user_id}/USER.md? user profile, shared across agentsMEMORY.mdat{base_dir}/users/{user_id}/agents/{agent_id}/MEMORY.md? agent-private working notesuser_idresolved viaget_effective_user_id();"default"in no-auth mode
Write paths:
- LLM actively calls the
memorytool (tools/builtins/memory_tool.py) ? writes USER.md/MEMORY.md, mirrors to Hindsight - LLM calls
hindsight_recall/reflect/retaintools (tools/builtins/hindsight_tools.py) ? only intools/hybridmemory_mode - Hindsight
auto_retain? every turn auto-stored to the work bank (viaMemoryMiddleware.after_agent)
Injection:
- builtin frozen snapshot ? injected into the system prompt via
lead_agent/prompt.py_get_memory_context - Hindsight dynamic recall ? injected per model call via
MemoryMiddleware.wrap_model_call(transient ? not persisted to state, so it never leaks to the frontend)
Hindsight dual-bank isolation (B-scheme):
- global user bank
deerflow-user-{user_id}? cross-agent - agent work bank
deerflow-work-{user_id}-{agent_id}? per-agent hindsight-clientis an optional dependency; when missing,HindsightProvider.is_available()returns False and the system falls back to builtin-only
Migration: PYTHONPATH=. python scripts/migrate_memory_to_v2.py [--all|--user-id X] [--dry-run] converts legacy memory.json into USER.md/MEMORY.md.
Configuration (config.yaml ? memory, nested): enabled, provider (builtin/hindsight),
builtin.{memory_char_limit,user_char_limit,...}, hindsight.{mode,api_url,api_key,bank_id_template,work_bank_id_template,memory_mode,...},
injection.{enabled,max_tokens,include_builtin,include_hindsight,context_tag}, security.{scan_content,block_invisible_unicode}.
Per-user runtime overrides (whitelisted fields) live at {base_dir}/users/{user_id}/memory_config.json ? see get_effective_memory_config().
Gateway API ? see the Memory router below (/api/memory/v2*).
Reflection System (packages/harness/deerflow/reflection/)
resolve_variable(path)- Import module and return variable (e.g.,module.path:variable_name)resolve_class(path, base_class)- Import and validate class against base class
Config Schema
config.yaml key sections:
models[]- LLM configs withuseclass path,supports_thinking,supports_vision, provider-specific fields- vLLM reasoning models should use
deerflow.models.vllm_provider:VllmChatModel; for Qwen-style parsers preferwhen_thinking_enabled.extra_body.chat_template_kwargs.enable_thinking, and DeerFlow will also normalize the olderthinkingalias tools[]- Tool configs withusevariable path andgrouptool_groups[]- Logical groupings for toolssandbox.use- Sandbox provider class pathskills.path/skills.container_path- Host and container paths to skills directorytitle- Auto-title generation (enabled, max_words, max_chars, prompt_template)summarization- Context summarization (enabled, trigger conditions, keep policy)subagents.enabled- Master switch for subagent delegationmemory- Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens)auth_login- Custom login modes layered on the base auth (deerflow/config/login_config.py):username_login.{require_password, password}- Shared password gate forPOST /api/v1/auth/login/username; blank configured secret falls back to123ewq. An account's own password/passphrase takes precedence. Account login, registration and SSO policy (2026-10-05):system_settings.json -> authenticationstorespassword_login_enabled=false,registration_enabled=false,single_session_enabled=false,sso_enabled=truedefaults; administrator GET/PUT/api/system-settings/authenticationedits them immediately, and anonymous/api/v1/auth/optionsexposes the switches plusneeds_setup. At least one login method must remain enabled; registration needs password login. Missing personal credentials use the existing shared passphrase (default123ewq), so disabling SSO does not require an administrator to first set a personal password./login/localaccepts username or email;/registeraccepts{username,password}(or historical{email,password}), always creates a regular user, and returns the full bearer login response. Names containingadminnever create or promote administrators; persisted roles remain authoritative. Passwords and URL passphrases share the same versioned hash inusers.password_hashandusers.login_password; legacy plaintext passphrases remain verifiable and take precedence over an old independent password hash until reset. User/change-passwordand administrator/users/{id}/login-password(alias/reset-password) update both columns and atomically incrementtoken_version; stale profile writes cannot overwrite credentials or revive sessions. Password removal is rejected./me.has_passworddenotes personal credentials;uses_default_passwordmarks shared-passphrase fallback, andrequires_current_password=truerequires the current shared passphrase even for the first password change. Self changes/resets return a replacement bearer and Cookie; administrator list items returnhas_login_password, never hashes or newly written plaintext (existing legacy plaintext remains admin-only for old link-copy compatibility). New link copies use only the reset input still in frontend memory. SSO policy also protects token/OAuth and the parallel panel/api/parallel-agents/auth/tokencompatibility entry, which now shares standard password/rate-limit/approval checks. Public first-admin initialization returns a full login response and inserts anauth_initializationsingleton claim plus the first administrator in one transaction; the ORM automatically creates that table plusauth_usernames, whose unique username claim prevents concurrent registrations across email domains without rewriting historical users. Username SSO rechecks the final account credential after a concurrent registration or reset. When password login is enabled, username and parallel compatibility login require a personal or shared/default passphrase regardless of the old require_password flag. All verified issuing paths explicitly set JWT auth_method=password/passphrase/sso; its default remains None for legacy/identity-only sessions. Gateway and LangGraph share session_policy_error, rejecting unmarked sessions under enabled password login and SSO sessions when SSO is disabled. DEER_FLOW_AUTH_DISABLED cannot bypass an enabled password-login or single-session policy; disabling password login preserves legacy compatibility behavior. CLIauth.reset_adminrotates both credential columns. Single-session login (one online location per account) is the separate single_session_enabled=false policy, not external SSO. Every successful login route, including the parallel alias, registration and initialization, calls _prepare_login_session: the SQL repository atomically increments token_version and returns that transaction's current user before minting the JWT. Ordinary competing logins advance versions independently so only the latest survives, while credential/approval changes after verification cause 409 rather than authenticating a stale password. Failed logins never advance the version; when disabled, logins keep existing sessions. Password changes still always rotate the version. Tests:tests/test_account_authentication.py,tests/test_username_login_domain.py; contracts:docs/AUTH_LOGIN.md.token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path, mock_tokens}- Token-exchange login (POST /api/v1/auth/login/token). The gateway POSTs totoken_info_urlwith the caller-supplied token placed directly in theAuthorization: Bearer <token>header (no cookie; the staticservice_authorizationfield is deprecated/unused, kept only for backward compat), readsdata.usernamefrom a{"state":"200",...}body, then signs the matching local user in (auto-registering if absent).token_info_urlis HTTPS ?verify_ssl: falseskips cert verification for self-signed/internal UAT hosts, or setca_cert_pathto a PEM bundle (takes precedence, keeps verification on).mock_tokens(offline test mapping{token: username}): when an incoming token matches a key,_fetch_username_from_tokenreturns the mapped username and skips thetoken_info_urlcall entirely — for environments where the upstreamgetTokenInfohost is unreachable (e.g. external networks). Empty in prod (no effect); seeded{"123ewq": "admin"}in this deployment so/login/任意?authToken=123ewqsigns in as admin.UsernameLoginResponsenow also returnsusername(the raw resolved login name, pre email-normalization) on both/login/tokenand/login/username, so the frontend can append a faithful?username=on jump links (按钮管理 追加登录态) instead of the lossy email local-part. See docs/AUTH_LOGIN.md for the full request/response contract.
Both endpoints are whitelisted in auth_middleware._PUBLIC_EXACT_PATHS. The shared _get_or_create_user_by_username() helper (in routers/auth.py) backs both /login/username and /login/token.
log_system- Admin-only external log-system entry (deerflow/config/log_system_config.py):{enabled, url, label}. Surfaced read-only byGET /api/system-settings/log-system(admin only); the frontend renders it as a "????" item in the bottom-right settings menu (workspace-nav-menu.tsx) that opensurlin a new tab. Disabled by default.
extensions_config.json:
mcpServers- Map of server name ? config (enabled, type, command, args, env, url, headers, oauth, description)skills- Map of skill name ? state (enabled). 常驻(免压缩)/always_on lives in theskillsDB table, not here.
Both can be modified at runtime via Gateway API endpoints or DeerFlowClient methods.
Conditional WeKnora LLMWiki
Selected knowledge-space policy: if either llmwiki_knowledge_base_ids or
assistant_knowledge_base_ids is non-empty, Gateway sets
knowledge_space_strict. This is server policy, not a caller preference:
after ordinary context merging, Gateway overwrites both final
configurable and context with the authorized knowledge ids,
knowledge_space_strict=true, and the merged exclusion list, so a request
cannot pre-seed false, forged ids, or excluded_tools=[] to reopen external
retrieval. The lead agent must fail closed: no Web/browser
tools, MCP, external retrieval skills, ACP, deep-research pipeline, or
subagents. Only the pre-retrieved selected-space context and the read-only
llmwiki_search compatibility tool may retrieve knowledge; that tool supports
both ordinary and assistant Wiki stores and cannot accept IDs from the model.
Ordinary Wiki retrieval runs vector and local lexical channels concurrently,
uses RRF, reports raw vector threshold diagnostics, and continues serving a
coherent last-successful snapshot after a later sync is cancelled/failed.
config.yaml defaults the Wiki encoder to SiliconFlow BAAI/bge-m3; keep
SILICONFLOW_API_KEY outside Git. The frontend derives four starter questions
from the selected space names and hides generic recommendations while these
questions are present. Design and remaining graph/profile phases are in
docs/KNOWLEDGE_SPACE_RETRIEVAL_AND_WIKI_EXPANSION_ZH.md.
Knowledge-base research bookshelf: /page/workspace/knowledge/:id/research
is ordinary Lead Agent Q&A constrained to one existing knowledge base; it does
not invoke or reuse the legacy Deep Research workflow. Any account with read
access may create a private session/draft, while only the existing KB writer
may publish or overwrite its Markdown Wiki article. Published snapshots follow
the KB's read permission, but the session/thread/draft and the continuation
link remain creator-only (administrators do not inherit another user's private
chat). Every run resolves the immutable thread binding from
knowledge_research_sessions (registered on deerflow.persistence.models so
startup create_all builds the table; get_by_thread treats a missing table
as no binding so ordinary /runs/stream is not aborted), reauthorizes KB read access, accepts exactly one
text-only human turn, forces classic single-KB retrieval and disables tools,
memory, prompt prefixes, planning and subagents; fixed-answer shortcuts are
bypassed. Saving manually upserts a stable
knowledge-research/{publication_id} Wiki slug under the reserved root
「深度研究」 without enabling automatic Wiki generation, uses a saved hash for
optimistic overwrite checks and idempotent retries, and keeps the editable
publication title separate from the Q&A title. REST endpoints live in
app/gateway/routers/llmwiki.py, runtime enforcement in
app/gateway/knowledge_research.py, persistence/migration in
deerflow.persistence.knowledge_research / 20261022_01, and regression
coverage in tests/test_knowledge_research.py.
Embedding ownership: WeKnora's per-knowledge-base embedding_model_id
indexes uploaded source documents; llmwiki.local_wiki_index.embedding indexes
the generated Wiki-page mirror inside DeerFlow. Do not couple these two model
choices. GET /api/llmwiki/embedding-models returns a strict credential-free
projection of WeKnora Embedding models, and KB creation must validate and pass
the selected active model id. When the caller omits a choice, retain WeKnora's
active-default behavior. Existing KBs cannot change this binding in place, and
cross-KB WeKnora searches should use model-compatible knowledge bases.
Wiki-enabled knowledge bases accept bounded user-owned content/extraction
instructions plus two independent optional generation inputs:
wiki_config.directory_paths (a one-to-three-level reference structure) and
wiki_config.focus_instructions (a strict extraction scope). The raw values
and normalized reference tree live in llmwiki_knowledge_bases; the effective
prompt tells WeKnora to use the directory names and hierarchy when they fit the
source, while allowing it to add, merge, rename, or omit nodes according to the
actual evidence. Directory guidance is never an allowlist and never a fact
source. WeKnoraClient merges edits into the remote wiki_config so synthesis
models and provider runtime knobs are retained.
Creation does not pre-create the reference skeleton. Mutation-settle and
periodic reconciliation run only for the strict focus scope: all page decisions
complete before any deletion, focus-outside generated pages are removed, and
the directory layout inferred by WeKnora is preserved. An incomplete or invalid
classifier result makes no destructive change and is retried. Directory-only
configuration therefore needs no post-generation reconciliation. The current
directory_paths field and the legacy wiki_parent_directories field have the
same prompt-only reference semantics; neither may trigger folder creation,
movement, deletion, or periodic directory reconciliation. Empty inputs keep
WeKnora's automatic behavior. Schema version 3 identifies this reference-only
prompt semantics. The background coordinator recompiles managed schema-2 rows
from their database-owned raw inputs and advances them only after the WeKnora
update succeeds; failures remain on schema 2 for retry.
Regression coverage:
tests/test_wiki_directory_constraints.py and
tests/test_llmwiki_parent_directory_routing.py.
Ordinary knowledge-base cards expose an owner/admin-only name/description/Wiki-instructions edit dialog in WeKnoraKnowledgeWorkspace.tsx, reusing useLlmWikiActions().update and PATCH /api/llmwiki/knowledge-bases/{id}. The route trims names, rejects blank or reserved conversation-deposit names, writes to WeKnora before updating local metadata, and retains existing write authorization. Default test questions are derived from the authoritative remote display name. The editor omits test_questions unless the user actually changes that field, and knowledge_profile.generated_test_questions_name recognizes only the exact legacy four-question template as automatic; genuinely edited lists remain custom. _mapping_view must resolve both questions and test_questions_customized from a profile row containing the current remote name, so a rename immediately replaces old-name defaults without a migration. A successful remote list is authoritative for every list scope, including personal: absent remote IDs are hidden without deleting mappings/references. Upstream failures remain errors. The grid also filters cached remote_status=missing rows and no longer renders stale-record cleanup cards. Regression coverage: tests/test_llmwiki_knowledge_management.py, tests/test_llmwiki_weknora.py.
llmwiki.weknora.api_base_url is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only api_base_url and optional web_base_url. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no default_embedding_model_id: WeKnoraClient queries /api/v1/models and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is app/gateway/routers/llmwiki.py; ownership/publication mappings live in deerflow.persistence.llmwiki; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (private -> published) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and system owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use cmzs branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only system_role === "admin" sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to Wiki -> documents -> graph; standard bases omit Wiki and render documents -> graph. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through /chunks/{knowledge_id}, and renders the Wiki page graph from /knowledgebase/{kb_id}/wiki/graph; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call /api/llmwiki/knowledge-bases/{id}/weknora/session through the normal DeerFlow API auth path, then load /platform/knowledge-bases/{weknora_id} with a signed deerflow_weknora_embed cookie for /platform, /assets, /locales, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe /api/v1/*, including colliding /api/v1/auth/*, through /api/llmwiki/weknora-embed/api/v1/*; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors X-Forwarded-Proto for HTTPS reverse proxies. llmwiki_search is conditionally registered in tools.py, is read-only, and rechecks the current DeerFlow user, agent llmwiki_knowledge_base_ids, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md. Tests: tests/test_llmwiki_weknora.py.
The embedded detail proxy hides WeKnora's outer .main > .aside_box application sidebar and expands the route outlet, but deliberately keeps the Wiki page's internal directory/index sidebar. Do not add an HTML sandbox attribute to this trusted, permission-filtered detail iframe: a sandboxed ancestor causes Chromium's built-in Blob PDF viewer to render the browser-blocked page. The Gateway follows upstream redirects only for /knowledge/{id}/preview and /knowledge-bases/{id}/files, keeping PDF bytes and protected Wiki images inside the signed proxy instead of sending the browser to WeKnora object storage.
Native/private and public Wiki readers must rewrite protected Markdown image
sources to their mapping-scoped DeerFlow /files?file_path= endpoints and keep
real HTTP URLs on rendered image elements. The embedded guard also rewrites
protected fetch/XHR values and hydrates late img[data-protected-src]
placeholders with the signed iframe HTTP proxy URL. Observe both
data-protected-src and src, because WeKnora may replace the URL after first
paint. Never create/revoke a temporary object URL here: its lightbox reuses the
thumbnail src after click. Never expose the service token or relax the
current-KB authorization to make images work.
Embedded native knowledge-base switching: The original WeKnora breadcrumb picker remains available inside the detail iframe. A signed 15-minute embed cookie carries only the current DeerFlow user's visible mappings (their own plus published bases); the proxied /api/v1/knowledge-bases response is filtered to that allowlist. Choosing another entry performs a full iframe navigation, rechecks the DeerFlow mapping and read/write permission, then reissues the cookie for the selected base. Never expose the upstream service-admin list directly or permit an arbitrary remote knowledge-base ID.
The independent enterprise-research workbench is temporarily disabled. Its source remains under app/gateway/routers/enterprise_research.py and deerflow.persistence.enterprise_research, but Gateway does not mount /api/enterprise-research, initialize its stores/executor, or register its ORM rows in Base.metadata. This ensures startup does not create or alter enterprise_research_tasks / enterprise_research_report_jobs on MySQL. It remains separate from /api/deep-research; do not re-enable it incidentally while changing the active Deep Research workflow. Regression coverage for its preserved implementation remains in tests/test_enterprise_research_tasks.py.
Embedded Client (packages/harness/deerflow/client.py)
DeerFlowClient provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes.
Architecture: Imports the same deerflow modules that Gateway API uses. Shares the same config files and data directories. No FastAPI dependency.
Agent Conversation:
chat(message, thread_id)? synchronous, accumulates streaming deltas per message-id and returns the final AI textstream(message, thread_id)? subscribes to LangGraphstream_mode=["values", "messages", "custom"]and yieldsStreamEvent:"values"? full state snapshot (title, messages, artifacts); AI text already delivered viamessagesmode is not re-synthesized here to avoid duplicate deliveries"messages-tuple"? per-chunk update: for AI text this is a delta (concat peridto rebuild the full message); tool calls and tool results are emitted once each"custom"? forwarded fromStreamWriter"end"? stream finished (carries cumulativeusagecounted once per message id)
- Agent created lazily via
create_agent()+_build_middlewares(), same asmake_lead_agent - Supports
checkpointerparameter for state persistence across turns reset_agent()forces agent recreation (e.g. after memory or skill changes)- See docs/STREAMING.md for the full design: why Gateway and DeerFlowClient are parallel paths, LangGraph's
stream_modesemantics, the per-id dedup invariants, and regression testing strategy
Gateway Equivalent Methods (replaces Gateway API):
| Category | Methods | Return format |
|---|---|---|
| Models | list_models(), get_model(name) |
{"models": [...]}, {name, display_name, ...} |
| MCP | get_mcp_config(), update_mcp_config(servers) |
{"mcp_servers": {...}} |
| Skills | list_skills(), get_skill(name), update_skill(name, enabled), install_skill(path) |
{"skills": [...]} |
| Memory | get_memory(), reload_memory(), get_memory_config(), get_memory_status() |
dict |
| Uploads | upload_files(thread_id, files), list_uploads(thread_id), delete_upload(thread_id, filename) |
{"success": true, "files": [...]}, {"files": [...], "count": N} |
| Artifacts | get_artifact(thread_id, path) ? (bytes, mime_type) |
tuple |
Key difference from Gateway: Upload accepts local Path objects instead of HTTP UploadFile, rejects directory paths before copying, and reuses a single worker when document conversion must run inside an active event loop. Artifact returns (bytes, mime_type) instead of HTTP Response. The new Gateway-only thread cleanup route deletes .deer-flow/threads/{thread_id} after LangGraph thread deletion; there is no matching DeerFlowClient method yet. update_mcp_config() and update_skill() automatically invalidate the cached agent.
Tests: tests/test_client.py (77 unit tests including TestGatewayConformance), tests/test_client_live.py (live integration tests, requires config.yaml)
Gateway Conformance Tests (TestGatewayConformance): Validate that every dict-returning client method conforms to the corresponding Gateway Pydantic response model. Each test parses the client output through the Gateway model ? if Gateway adds a required field that the client doesn't provide, Pydantic raises ValidationError and CI catches the drift. Covers: ModelsListResponse, ModelResponse, SkillsListResponse, SkillResponse, SkillInstallResponse, McpConfigResponse, UploadResponse, MemoryConfigResponse, MemoryStatusResponse.
Development Workflow
Test-Driven Development (TDD) ? MANDATORY
Every new feature or bug fix MUST be accompanied by unit tests. No exceptions.
- Write tests in
backend/tests/following the existing naming conventiontest_<feature>.py - Run the full suite before and after your change:
make test - Tests must pass before a feature is considered complete
- For lightweight config/utility modules, prefer pure unit tests with no external dependencies
- If a module causes circular import issues in tests, add a
sys.modulesmock intests/conftest.py(see existing example fordeerflow.subagents.executor)
# Run all tests
make test
# Run a specific test file
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
Running the Full Application
From the project root directory:
make dev
This starts all services and makes the application available at http://localhost:2026.
All startup modes:
| Local Foreground | Local Daemon | Docker Dev | Docker Prod | |
|---|---|---|---|---|
| Dev | ./scripts/serve.sh --devmake dev |
./scripts/serve.sh --dev --daemonmake dev-daemon |
./scripts/docker.sh startmake docker-start |
? |
| Prod | ./scripts/serve.sh --prodmake start |
./scripts/serve.sh --prod --daemonmake start-daemon |
? | ./scripts/deploy.shmake up |
| Action | Local | Docker Dev | Docker Prod |
|---|---|---|---|
| Stop | ./scripts/serve.sh --stopmake stop |
./scripts/docker.sh stopmake docker-stop |
./scripts/deploy.sh downmake down |
| Restart | ./scripts/serve.sh --restart [flags] |
./scripts/docker.sh restart |
? |
Nginx routing:
/api/langgraph/*? Gateway embedded runtime (8001), rewritten to/api/*/api/*(other) ? Gateway API (8001)/(non-API) ? Frontend (3000)
Running Backend Services Separately
From the backend directory:
# Gateway API
make gateway
Direct access (without nginx):
- Gateway:
http://localhost:8001
Browser CORS
The Gateway always permits local Vite origins on ports 5173, 5174, 3000, and
8080 for both localhost and 127.0.0.1 with credentials. A deployed
frontend on another origin must be listed exactly in GATEWAY_CORS_ORIGINS
(for example http://47.88.25.99:7010); explicit origins are merged with the
local development origins. Regression: tests/test_taskcop_cors.py.
Frontend Configuration
The frontend uses environment variables to connect to backend services:
NEXT_PUBLIC_LANGGRAPH_BASE_URL- Defaults to/api/langgraph(through nginx)NEXT_PUBLIC_BACKEND_BASE_URL- Defaults to empty string (through nginx)
When using make dev from root, the frontend automatically connects through nginx.
Key Features
Administrator-managed custom themes
system_settings.json → appearance now stores custom_themes alongside
the built-in default theme. A row is CustomThemePreset (id, name,
six-digit primary, optional primary-button text_color, mode, overrides, enabled,
sort_order). overrides is intentionally a short interaction-only list:
button_hover, navigation_gradient_start, navigation_gradient_end,
selected_background, border, muted, muted_text, destructive,
brand_title, and primary_foreground. The navigation gradient endpoints
default from primary and are only stored when an administrator overrides them.
All values must be six-digit hex strings; the frontend derives every remaining
semantic CSS/TDesign token so admins do not maintain a full raw token sheet.
Ids must be custom-theme-*, names and ids are unique, and the router limits
the list to 12. GET/PUT /api/system-settings/appearance is the single
read/write surface: reads are available to all users, writes require admin.
The router re-orders rows deterministically and only permits an enabled custom
theme as default_theme; removing or disabling the default safely falls back
to light. Regression coverage: tests/test_appearance_theme.py.
Compact top-bar shortcuts live in the same appearance setting. Their route
parameters support login, theme, and keyless path. A path parameter has
no query-key input; the operator selects its value source from the right-hand
dropdown and the frontend appends the resolved, URL-encoded value to the
external URL path (or Hash route) in configured order. Password stays an
ordinary login query parameter with the key password, so an external login
link can become #/login/<user>?password=…. Regression coverage:
tests/test_appearance_theme.py.
File Upload
Chat upload configuration is a database-backed global policy, not a static
config.yaml size tuple. deps.py creates and seeds the singleton row, the
Gateway loads it for every request, and deerflow.uploads.policy exposes only a
validated runtime copy to the harness. The administrator page under settings
edits or resets the same row. The built-in defaults are 20 files / 100 MiB total;
documents and images are 10 MiB per file, and spreadsheets are 5 MiB.
- Endpoint:
POST /api/threads/{thread_id}/uploads; policy endpoints:GET/PUT /api/upload-policy,POST /api/upload-policy/reset - Supported policy formats: TXT/Markdown, PDF, DOC/DOCX, PPT/PPTX, JPG/JPEG/PNG, and XLS/XLSX/CSV; the server restricts edits to parsers it can validate safely
- The whole batch is streamed to a private staging directory, checked for count, total size, per-category size, extension, signature/container and encoding, then atomically committed; validation or sandbox-sync failure restores old files
- Optional PDF/PPT/Excel/Word conversion always runs off the event loop with
bounded concurrency and a fixed extracted-text ceiling; companions are named
report.docx.md, with legacyreport.mdlookup retained for old uploads - Files stay in thread-isolated directories;
UploadsMiddlewareinjects bounded JSON metadata plus small content samples, never unbounded bodies or repeated converted companions - Range-free large local reads return streaming full-file statistics and
deterministic head/middle/tail samples. Explicit line reads,
grep, artifact previews, image dimensions/derivatives and final model context all have hard caps - Uploaded raster images also receive a signed public HTML URL (
/api/public/html-images/{token}) so generated pages can use them asbackground-image
See docs/FILE_UPLOAD.md for details.
Plan Mode
TodoList middleware for complex multi-step tasks:
- Controlled via runtime config:
config.configurable.is_plan_mode = True - Provides
write_todostool for task tracking - One task in_progress at a time, real-time updates
See docs/plan_mode_usage.md for details.
Context Summarization
Automatic conversation summarization when approaching token limits:
- Administrator recent-turn window:
system_settings.json→conversation_context.recent_turn_window_enabledis a deployment-wide, default-off alternative to summarization. Administrators manage it in 「设置 → 上下文管理」 throughGET/PUT /api/system-settings/conversation-context. The same block storesmax_conversation_rounds(default 20, range 1–50), whichChatPageuses as the maximum number of real user turns in one ordinary chat; missing values in older settings files retain the 20-turn default. When enabled, each lead agent model request retains only the current turn and the two preceding real user turns, together with their assistant/tool continuations and any hidden context attached to the retained boundary. The operation is request-only: checkpoints and the transcript shown in the UI remain complete. This mode takes priority over the per-chat 「上下文压缩」 toggle and never invokes the summary model. - On/off switch is per-request, not the config file. The chat input box has a
「上下文压缩」 toggle (below 参考文献, default off) → frontend sends
context.summarization_enabled→ whitelisted in_CONTEXT_CONFIGURABLE_KEYS(app/gateway/services.py) →_build_middlewaresreads it from the run config and calls_create_summarization_middleware(force_enabled=summarization_enabled).force_enabled=Truemounts the middleware even whenconfig.summarization.enabledis false; when the flag is absent (channels / scheduled runs) it falls back to the config flag.config.summarization.enablednow defaults to false. config.yamlsummarizationstill supplies the tuning: trigger types (tokens, messages, or fraction of max input) and the keep policy.- A compressed request must retain the latest non-empty
HumanMessage, even when it has already been folded into the hidden summary. This keeps strict OpenAI-compatible gateways from rejecting a tool-continuation call withNo user query found in message. - LangChain keeps the agent prompt in
ModelRequest.system_message, separate fromModelRequest.messages. Compression must merge its hidden summary into that dedicated slot and remove everySystemMessagefrom the ordinary message list. Strict Qwen-style adapters accept only one system message at index zero and otherwise return HTTP 400 (system message must be at the beginning). Keep this normalization adapter-independent; the payload-level OpenAI-compatible normalizer is a second line of defence and also merges adjacent leading system messages. - Keeps recent messages while summarizing older ones.
- The model input hard guard is independent and always on when configured.
Set
models[].max_input_tokens(orprofile.max_input_tokens) to the safe input ceiling;max_tokensis output-only.ContextWindowGuardMiddlewareis mounted at the innermost end of both lead-agent and subagent model-call chains, after memory/RAG/image/custom injections. It counts the complete request (system prompt, messages/reasoning, tool schemas and response format), then transiently removes old complete turns and truncates oversized bodies as a last resort without mutating checkpoint state. Provider context-length errors are non-retriable so a tokenizer mismatch cannot trigger repeated submission of the same oversized request.
Tests: tests/test_prompt_prefix.py (system-setting API/defaults) and
tests/test_summarization_multiturn.py (summarization plus transient recent-turn
window behavior).
See docs/summarization.md for details.
Vision Support / Image Recognition (??)
Two paths, selected by config.yaml ? vision:
Dedicated vision model (delegated recognition) ? set vision.model_name to a model from models[]. The view_image tool sends the image to that model and returns its text description as the tool result, so a text-only main chat model can still "see" images. view_image accepts an optional question argument for targeted queries; the default prompt is vision.prompt (overridable), and vision.max_tokens caps the recognition output. Resolution/gating lives in deerflow/models/vision.py (resolve_vision_model_name, vision_enabled, is_delegated_vision, recognize_image).
Native vision (fallback) ? when vision.model_name is unset, behavior falls back to the main chat model, which only works if it has supports_vision: true:
ViewImageMiddlewareinjects base64 image data into the conversation before the LLM callview_imagestores the image instate.viewed_imagesfor that middleware
Both the view_image tool and ViewImageMiddleware are registered whenever vision_enabled(model_name) is true (dedicated model configured or main model supports vision). Config schema: deerflow/config/vision_config.py (VisionConfig); tests: tests/test_vision.py.
Upload symlink-safety (cross-platform)
deerflow/uploads/manager.py:open_upload_file_no_symlink writes uploads without following a pre-existing destination symlink. O_NOFOLLOW is applied where available (POSIX) but is optional ? Windows lacks os.O_NOFOLLOW/O_NONBLOCK, so requiring it previously made every Windows upload fail with UnsafeUploadPathError. Pre-existing symlinks are still rejected via lstat, and the opened fd is re-checked with fstat. O_BINARY is added on Windows. Regression: tests/test_uploads_windows_safe.py.
Code Style
- Uses
rufffor linting and formatting - Line length: 240 characters
- Python 3.12+ with type hints
- Double quotes, space indentation
Documentation
WeKnora iframe behind /deerflow
The LLMWiki WeKnora detail iframe is publicly served through the Gateway at
/deerflow, independently of the frontend deployment prefix (for example
/magentweb). Register llmwiki.proxy_router with the /deerflow prefix
before its unprefixed fallback router, otherwise the unprefixed catch-all
captures /deerflow/*. HTML root-relative assets, API strings, and Vite's
__vite__mapDeps table are rewritten for /deerflow; generic JavaScript
regular-expression literals must not be rewritten. Auth and CSRF bypass only
applies when the short-lived signed WeKnora embed cookie is valid. Keep static
path recognition in app/gateway/weknora_embed.py synchronized with the
proxy's static fallback paths in app/gateway/routers/llmwiki.py.
Static assets use a separate signed-cookie-only path: never fetch an upstream
admin session or query per-KB ACLs for public JS/CSS/fonts. HTML and data APIs
still revalidate mapping permissions. app/gateway/weknora_static.py coalesces
concurrent reads and bounds its LRU by bytes and entries; cache keys include
the upstream web URL, public prefix, asset path and query. Bundle rewriting,
gzip and ETag generation run in a worker thread. Fingerprinted /assets/
files receive immutable browser caching; /config.js and upstream private,
no-store, no-cache or identity-varying responses must not enter the shared
cache. Explicit browser cache bypass refreshes the cached representation.
The injected iframe guard observes root theme changes separately and ignores
its own writes. Process only changed content branches, coalesced per batch;
ordinary loaded pages must have no recurring DOM-maintenance timer. Source
detail deep links retain a bounded nine-second drawer fallback. Preserve the
inner Wiki directory, protected images and theme repair. Regression tests:
tests/test_weknora_static.py, tests/test_weknora_embed_script.py.
Position-roundtable role directory
position_roles is the global shared directory for position-roundtable work
views. It is deliberately separate from organisation/RBAC positions.
app.gateway.deps.langgraph_runtime() seeds the four stable built-in ids from
app/gateway/position_role_defaults.py via ensure_seed() (missing rows only;
never overwrite operator edits). The API is GET/PUT /api/position-roles;
writes are admin-only. Exactly one enabled intent role is required and owns
the initial intent workspace plus the summary/action-plan closing agents. Do
not delete a built-in id because historical chain nodes persist those ids;
custom role ids may be added and removed.
Position-roundtable task-scoped session history
position_roundtable_sessions.external_task_id is optional. When populated from the frontend route taskId, new rows set task_scoped = true: a task can have multiple sessions and all users opening that task see the same sessions, nodes and persisted conversation snapshots. When it is NULL/empty, keep the original user_id-scoped personal workspace behavior. 20260808_01 adds task_scoped with default false (so all historical records remain personal) plus the task/update-time index; it must not backfill or make external_task_id non-null.
Roundtable artifact submission status
roundtable_artifact_submissions keeps task-level submission state for a
generated deliverable and is deliberately separate from task details and any
external task-system delivery. The API is
GET /api/roundtable/tasks/{taskId}/artifacts?submitted=true and
PUT /api/roundtable/tasks/{taskId}/artifacts/submission. All callers use the
normal Gateway Bearer authentication; records are shared by task while
created_by/submitted_by/cancelled_by are audit-only. The server derives a
SHA-256 idempotency key from taskId + sourceKey + path + revision: submitting
the same revision is an upsert, while cancellation only flips submitted and
sets cancelled_at (it never deletes history). Store only the sandbox file
reference (threadId, path, source/node/session fields, name/MIME and revision)
and timestamps; do not persist Markdown content or call a formal delivery
interface. Previews continue to read the DeerFlow artifact by threadId + path.
See docs/ directory for detailed documentation:
- ../README.md - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in
../offline-runtime-requirements.txtand../start_backend.shmust stay synchronized. Startup checkscroniter,hindsight-client, the aiomysql compatibility bundle, and Redis, installing missing or incompatible packages from wheelhouse only. The bundle installsaiomysql==0.3.2,PyMySQL==1.2.0, andSQLAlchemy==2.0.51together; the startup probe also verifies that SQLAlchemy's adaptedping(reconnect=False)signature is present, preventing the old-adapter missing-reconnectfailure. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only. - CONFIGURATION.md - Configuration options
- ARCHITECTURE.md - Architecture details
- API.md - API reference
- SETUP.md - Setup guide
- FILE_UPLOAD.md - File upload feature
- PATH_EXAMPLES.md - Path types and usage
- summarization.md - Context summarization
- plan_mode_usage.md - Plan mode with TodoList
- BACKEND_RUN_STOP_ZH.md - Windows-first backend start/stop and troubleshooting guide
Deep Research retrieval
deerflow.agents.deep_research.adapters.material_provider.DeerFlowMaterialProvider
must remain on the Q&A retrieval path. Enabled deep-search, web-research,
and knowledge-base skills run through the existing SubagentExecutor skill
runtime only when their required Q&A tool is registered; then the provider may
use configured web_search. A disabled placeholder endpoint is not a valid
search channel: use the maintained keyless DuckDuckGo provider and a relevant
real-web fallback as read-only sources, keep source provenance, and never
enable or call a placeholder just to make the Deep Research UI look configured. Regression coverage is in
tests/test_deep_research_material_provider.py.
When a queued Deep Research job is cancelled, the Gateway must also clear the
owning session's active_job_id and mark it cancelled; otherwise the stale
session incorrectly consumes that user's concurrency limit.
Report prose is the one Deep Research LLM path that must use
DeerFlowCompletionBackend.stream_complete() rather than complete(): it
drives model.astream(), sends native report_delta frames through
ReportChunkEmitter, and persists bounded report_chunk checkpoints. The
app-layer DeepResearchLiveHub fans out those live frames inside one Gateway
worker only; the database event log still owns ?after=<seq> reconnect replay
and multi-worker recovery. Inject the hub callback into PersistingEventSink
and never import app.* from deerflow.*. Keep structured plan/review calls
on complete(). Before the search fan-out, basic mode emits one
queries_planned event and detailed/multi-agent modes emit one per subtopic;
they are durable user-visible search terms, not chain-of-thought. On completion, retain
the terminal lastJobId in usage_snapshot so a refreshed conversation page
can replay its trace from the event log. Detailed mode uses stable targets (introduction,
section:N, conclusion) because section writers are concurrent;
multi-agent revisions must emit report_reset before their next report
version. Do not persist raw model reasoning or one persisted event per provider
token. Models that expose thinking inside ordinary <think>...</think> text
must be split statefully before a report delta, checkpoint, or Markdown
projection is created; an optional live-only report_thinking frame can drive
the message-list thought trace, while the sandbox receives answer Markdown
only. The post-report digest (summarize_report) must likewise forward
provider reasoning through live-only summary_thinking frames so the
chat timeline can render thought while waiting for the first summary_delta.
The Vite page starts Word downloads through
frontend-web/src/core/scheduled-tasks/markdown-docx.ts, but the Gateway owns
generation through POST /api/writing/export/docx. Keep the standard-library
OOXML builder and its embedded-font validation; do not add a Python document-
conversion dependency merely to export text.
All non-streaming Deep Research control-plane calls (complete() for query /
section / plan / review JSON) run through asyncio.wait_for using the frozen
DeepResearchConfig.control_plane_timeout_seconds (45 seconds by default,
clamped to 120). Do not apply that total-duration cap to stream_complete():
reports may legitimately be long but must continue emitting visible deltas. A
basic query-planning timeout/failure emits query_planning_fallback and searches
the original topic; detailed and multi-agent plan creation similarly fall back
to their original topic/section, rather than leaving a durable job stuck in
planning. Tests: tests/test_deep_research_llm_adapter.py and
tests/test_deep_research_runners.py.
The per-user Deep Research concurrency guard counts only session statuses
running and awaiting_input. A draft has no durable job and must never
consume a slot; otherwise saved drafts make POST /sessions/{id}/jobs return
429 permanently. Regression: test_saved_draft_sessions_do_not_consume_research_concurrency.
POST /api/deep-research/sessions/{id}/jobs is retry-safe under MySQL
read/write splitting. Deep Research session/job/event/source/message writes
must serialise their changed row while the write transaction is still open
(flush for inserts, select-after-update before commit), never through a
post-commit session.refresh() or a fresh pooled read. Replica lag otherwise
turns a committed job into a misleading 500, can make dispatcher claim_job()
lose its snapshot, or can fail report/follow-up persistence after the model has
already completed. Always nudge the dispatcher for both a newly created job and
an idempotently reused queued job; otherwise a retry after such an interrupted
response returns the old job but leaves it waiting for the periodic scan. The
SSE replay generator must catch temporary event/job-store reads, emit a
recoverable warning, and keep the connection open so the next poll can repair
the cursor instead of surfacing an ASGI ExceptionGroup. Regressions:
test_deep_research_writes_never_depend_on_post_commit_refresh,
test_job_create_never_reads_back_after_commit,
test_retry_of_existing_queued_job_nudges_dispatcher, and
test_stream_stays_open_when_durable_replay_has_a_transient_failure.
At ordinary report completion, persist and confirm the session's
report_markdown projection before marking the Job completed. Retry a
short transient projection failure; if it cannot be confirmed, let the job
become failed rather than reporting a report that vanishes on refresh.
test_report_is_persisted_before_job_is_marked_completed and
test_report_projection_failure_never_marks_job_completed cover the ordering.
The chat-collection frontend must keep report-writing progress in the existing
workspace MessageList / MessageGroup tool-step renderer: inject only
virtual deep_research_progress tool calls for the durable phases, keep the
completed phase visible after the job ends, and append the digest as a normal
assistant message. Do not recreate a second deep-research-only conversation
timeline. On summarizing, insert that same normal assistant message with its
streaming marker and update it for every SSE summary_delta; when the report
completes, replace its content in place with the final completion/digest
message rather than waiting to append a summary. Completed chat-collection
sessions also inject a normal present_files card into the transcript, named
from the report Markdown H1 rather than its internal report.md path.
Replaying a completed history session must not auto-open its
sandbox; opening that card selects its virtual write-file report instead, and
its file-card download action delegates to the Deep Research Markdown/Word
export dialog. The default in-job concurrency and per-user active-job cap are
both three. Before the user confirms the report configuration, derive its
displayed material count from unique collector tool-result rows because the
durable Deep Research source store is populated when the writing job harvests
that thread. The Deep Research composer model picker must use the installed
TDesign Select backed by /api/models; its explicit choice is sent as
model_name on collector turns and applied to fast_model, smart_model, and
strategic_model when creating a session or starting a report job. Disable it
while collection or report writing is active so one durable run cannot switch
models mid-stream.
Report structures (/api/report-structures) may store an optional
retrieval_directions list (检索方向) and an optional retrieval_skills
list (检索来源 / 指定技能). Empty / unset keeps today's collector
query-planning, default tools, and runner sub-query generation. A non-empty
directions list is frozen into DeepResearchConfig.retrieval_directions: the
chat collector's first user message gets a <retrieval-directions> block that
asks it to split concrete retrievable terms along those facets (never search
the direction labels themselves), and legacy basic/detailed/multi_agent/
deep runners pass the same constraint into query-planning instead of using
课题 + 方向 as the search string. A non-empty skills list is frozen
into DeepResearchConfig.retrieval_skills: the chat collector's first user
message gets a <retrieval-skills> block, the collector run context receives
extra_skills so skill_view/skill_list can see those names, and legacy
material collection runs those skills instead of the default Q&A research-skill
trio. Helpers live in deerflow.agents.deep_research.retrieval.
Completed-report Q&A has two endpoints: retain POST /sessions/{id}/chat as the
legacy JSON response for compatibility, and use POST /sessions/{id}/chat/stream
for report-grounded Q&A SSE. The workbench must send a strong scoped edit
instruction (for example “修改第一段” / “重新生成一下第一段” / “重写第二节”) to
the separate POST /sessions/{id}/rewrite/stream report-section writing pipeline.
That endpoint requires a deterministic current-report range (otherwise 422), so
its output is always a replacement candidate rather than a Q&A answer. The
candidate is streamed in the ordinary message row
while the sandbox preserves the current report. When generation ends, inject a
confirmation card. Only the owner’s explicit
POST /sessions/{id}/messages/{messageId}/rewrite-proposal action apply may
persist the assembled report_markdown; dismiss preserves the original. The
proposal is bound to a hash of the report it was generated from, so stale
candidates fail with 409 rather than overwrite a later edit. Ambiguous or
ordinary evidence questions must remain non-mutating, and cancellation or a
stream error must never save a partial report. The rewrite prompt receives only
the selected range plus the selected evidence set, never a new web search.
DeerFlow-local LLMWiki Wiki index
llmwiki.local_wiki_index is an opt-in local-vector subsystem. WeKnora is the
Wiki generation/sync source and the Wiki-only fallback while an entire mapping
has not completed vectorization. The readiness gate requires state=idle, a
successful full scan, matching embedding fingerprint, no failed pages,
ready_page_count == local_page_count, and vectors for a non-empty library.
Ready mappings read DeerFlow's llmwiki_wiki_pages /
llmwiki_wiki_vectors / llmwiki_wiki_sync_states tables; incomplete mappings
in the Wiki-page retrieval service and llmwiki_search tool call only WeKnora
/knowledgebase/{id}/wiki/pages?query=... plus Wiki-page detail and never use
raw chunks as a silent fallback. The pre-answer RAG orchestrator separately
runs the explicit, authorized /knowledge-search raw-chunk channel described
under Knowledge retrieval diagnostics below. Core code lives under
deerflow.integrations.weknora.local_index and
deerflow.persistence.llmwiki_index; preserve the harness → app import
firewall. Gateway composition injects the store/search/sync services through
local_index.runtime. enabled turns on local vector search and auto_sync
defaults to the lease-protected background scheduler. The scheduler performs
an immediate incremental scan at startup, then rescans every configured
interval so new WeKnora libraries and asynchronously generated Wiki pages are
picked up without a manual vectorization click. Unchanged content must reuse
existing vectors.
Vectors are normalized little-endian float32 BLOBs. A page mirror and all of
its vectors are replaced in one transaction only after strict batched
embedding succeeds. Never delete the old generation before a remote embedding
call. The local Wiki-vector service must not silently substitute WeKnora raw
chunks when embedding fails; the pre-answer orchestrator's separately labelled,
authorized raw-chunk channel is independent rather than that fallback. NumPy
matrices are per-mapping LRU snapshots invalidated by
index_revision; large decode/matrix work runs through asyncio.to_thread.
embedding.dimensions is a local response-size invariant and fingerprint
input, not an outbound API field: fixed-dimension BGE/OpenAI-compatible
endpoints can reject a dimensions request parameter with HTTP 400. When the
value is omitted, the embedding client probes and freezes the first response's
dimension before sync lease/rebuild decisions.
Embedding errors must retain the upstream status and bounded response body in
logs, without exposing API keys. SQL vector writes are split into statements
of at most database_batch_size rows (default 50) inside the same atomic page
replacement transaction.
During a fingerprint rebuild the mapping uses the Wiki-page API until every
page is successfully rebuilt. WikiRetrievalService partitions mixed requests
per mapping, so ready libraries stay local while incomplete libraries use that
Wiki-only fallback.
Library-level overview requests (for example, “此知识库都讲了什么” or “这个知识空间有哪些内容”) are
recognized once by local_index.overview and shared by local vector and remote Wiki-page retrieval. A completed
local index returns representative page bodies without embedding the broad query or applying min_similarity; an
incomplete index asks the remote page API for its directory rather than attempting a wording match that can return
zero. In both paths directory, index, and summary pages are ineligible, and a page summary must never substitute
for its body. AssistantKnowledgeRepository implements the same user-facing overview semantics independently for
assistant knowledge bases.
Knowledge retrieval diagnostics. app.gateway.llmwiki_rag.build_llmwiki_rag_context is the single
pre-answer retrieval path for both normal Q&A and POST /api/llmwiki/retrieval-debug; do not build a second
debug-only ranking implementation. The debug endpoint accepts explicit ordinary and assistant knowledge-base ids
and returns the bounded knowledge_retrieval_trace: selected/authorized spaces, actual retrieval mode, channel
candidate counts, vector threshold diagnostics, warnings/fallback, zero-result reason, timings, and the exact
bounded evidence made available to the model. A normal run stores the same trace on the latest HumanMessage,
publishes it in the initial SSE metadata event, and exposes durable pre-retrieval plus model-driven
llmwiki_search tool traces through GET /api/threads/{thread_id}/reference-batches under retrieval_traces.
Keep trace results bounded to the same eight sources and 1400 characters per source as the answer prompt; never
expose provider credentials or unrelated library contents. If a client omits selection keys on a later turn,
recover the persisted thread selection; an explicitly present empty list must continue to clear the scope.
Ordinary pre-retrieval searches both generated Wiki page bodies/sections and authorized WeKnora raw document
chunks. The raw channel must be called only with remote ids derived from already-authorized DeerFlow mappings,
must reject every returned row whose remote id is not in that authorized map, and must allowlist/bound source
fields before adding them to prompts or traces. Run raw-chunk search concurrently with Wiki retrieval; one channel
failing marks the trace partial but must not discard successful evidence from the other. A ready local Wiki snapshot
uses vector + lexical channels with RRF; incomplete mappings use lexical Wiki recall and the remote Wiki-page API;
Wiki and raw result lists are fused without comparing provider score scales. Raw provider scores are channel scores,
not cosine similarity. Assistant knowledge runs vector and keyword/raw-chunk recall together and fuses both ranked
lists with weighted RRF; exact literal identifiers such as device or incident codes take priority, so imported raw
chunks remain searchable even when compatible Wiki vectors exist. This is not guaranteed pure-vector retrieval on
every turn. The same pre-answer orchestrator performs bounded graph expansion for ordinary Wiki links and imported
assistant entity relations whenever the selected repository exposes get_adjacent_pages. It uses up to four directly
recalled Wiki pages as
seeds (two per authorized mapping), traverses one hop by default and at most two hops for explicit multi-hop
wording or low-confidence vector diagnostics, inspects at most 24 adjacency rows, and accepts at most six pages
(three per mapping). Every returned page is rechecked against the authorized local mapping ids, must contain a
real eligible Wiki body, and carries a local-only slug/title path plus source/chunk provenance. Decayed walk scores
rank graph candidates; fixed graph slots merge them with Wiki/raw evidence without comparing unrelated score
scales. Trace fields include graph seeds/candidates/accepted/used, requested/executed hops, paths, depth reason and
timings_ms.graph_expansion; capability is dynamic. Graph failure marks the trace partial and must never discard
successful Wiki, raw-chunk, or assistant evidence. Assistant relation traversal resolves both endpoints through the
entities' wiki_page_id, and requires the relation, both entities, and both pages to be active and inside the same
selected assistant base; it must never follow cross-base relations.
The frontend must keep Wiki citations visibly distinct from raw-document
citations while hiding provider-brand wording from user-facing labels, status
text, and errors. Any click on a Wiki source card opens the shared right-side
source drawer, which renders the complete Markdown page. When the local index
is disabled, the drawer reads the remote WeKnora Wiki-page endpoint directly
and must not surface the local-index-disabled response. A Wiki target must not
also load or render the legacy original-document preview, even when its citation
still carries a document id. The drawer supports single-page Markdown and Word
downloads, and its Open in Wiki link carries both the DeerFlow knowledge-base
mapping id and the exact wikiSlug. Wiki search-result cards use this same
drawer instead of navigating immediately. Exact-page navigation uses the native
knowledge/{mapping_id}/wiki/view?wikiSlug=... reader, not the WeKnora detail
iframe. Keep legacy knowledge/{mapping_id}?tab=wiki&wikiSlug=... links fast by
redirecting them in LlmWikiKnowledgePage before its runtime-loading gate.
Drawer-to-reader navigation stays in the current SPA and passes the fetched
Wiki page in router state so the article can render before the background query
refresh completes. The native reader must retain a searchable/collapsible left
Wiki directory, reconstruct hierarchy from parent_slug, wiki_path,
category_path, and slug fallback, expand/highlight the active page, and keep
directory navigation on the same lightweight route rather than mounting the
WeKnora iframe.
Generated HashRouter URLs must retain window.location.pathname (for example
the /web/ reverse-proxy prefix); never emit root-absolute /#/... citation,
reader, or public-embed links. Protected Wiki image rendering must use real HTTP
URLs, never URL.createObjectURL. Public readers use the published file proxy;
authenticated readers obtain a short-lived signed capability from
/files/access-url and load /api/public/llmwiki/files/{token}. The signed
payload is restricted to kind=wiki_file, mapping id, remote id, validated
protected path, and expiry, and responses provide an inline UTF-8 filename for
new-tab viewing and browser Save As. The proxied WeKnora detail iframe must
likewise leave its scoped HTTP file-proxy URL on every protected image and
restore it if upstream rendering replaces src; its click preview reads that
same DOM value, so a fetched-and-revoked object URL is not acceptable.
Third-party pages embed published Wiki content through the chrome-free route
/#/embed/knowledge/{mapping_id} (optional exact-page wikiSlug). It defaults
to a recognized Wiki index page (title 索引/index, page type index, or an
index/home/readme slug) and promotes that page to the first public-directory
entry. When upstream pages exist without an explicit index shell, the frontend
injects a virtual __index__ page and builds its catalogue from the page list;
an all-empty dynamic index response must also fall back to that page list. It retains
directory navigation and supports the shared public-embed theme / bg /
text appearance inputs. WeKnora dynamically renders index page-type counts,
nested category headings, page summaries, and data-slug links even though the
stored index Markdown only contains its introduction; the native public reader
reads the same ordered groups through the published-only
/api/public/llmwiki/knowledge-bases/{id}/wiki/index proxy and renders them with
buildWikiIndexCatalogMarkdown. The Gateway paginates every WeKnora index type
(summary, entity, concept, synthesis, comparison) to completion while
preserving upstream group/item order. Index Markdown
may also contain WeKnora shell-only placeholder links;
resolveWikiIndexPageLinks matches their visible labels to
the loaded page title, slug, or alias and rewrites them to exact embedded
wikiSlug routes. Preserve WeKnora's wiki-content-link interaction: primary
brand color, medium weight, dashed underline becoming solid on hover, focus
outline, and in-reader SPA navigation rather than a page reload. GET /api/public/llmwiki/knowledge-bases is the anonymous discovery endpoint. It
validates published ordinary mappings against WeKnora, drops stale mappings,
and returns safe comprehensive metadata from the WeKnora list/detail/page APIs:
document/chunk/processing/share counts, non-secret configuration and timestamps,
Wiki page type/status counts and explicit/generated index entry, plus aggregate
local-vector state/counts/timestamps. Never expose remote WeKnora ids,
tenant/creator identity, storage credentials, API credentials, raw vectors, or
per-page indexing errors. All anonymous reader calls use that
public namespace and the /knowledge-bases/{id} Wiki-page children. Those
endpoints must resolve with a synthetic anonymous user and is_admin=False,
explicitly require publication_status=published, and hard-deny the system
conversation-deposit mapping. Never point this page at the authenticated
/api/llmwiki reads or weaken the published-only gate merely because the
mapping id is hard to guess.
Owners manually request a metadata-first incremental sync/vectorization through
POST /api/llmwiki/knowledge-bases/{id}/sync; the older
POST /api/llmwiki/knowledge-bases/{id}/vectorize route is a compatibility
alias. Status is available through GET /api/llmwiki/knowledge-bases/{id}/wiki-index-status. That response includes
aggregate progress and per-page pages details; current-scan progress is
derived from active_sync_id / last_seen_sync_id, while each page exposes its
index state, vector count, timestamps, and bounded error. last_sync is the
persisted, lease-fenced terminal result and includes the sync id/mode/status and
added/updated/skipped/deleted/failed counters. Frequent progress polling must
pass include_pages=false so it reads only persisted aggregate state; the Wiki
management view keeps the default detailed response when it needs per-page
rows. The Wiki management
page must show the progress bar and a status badge beside every article. It
also exports all remote processed Wiki pages via GET /api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx. Directory export must keep
the full path, current directory, parent path, and dynamic per-level columns;
long Markdown is split into Excel-safe continuation columns instead of being
truncated. Workbook creation runs in asyncio.to_thread.
Internal search authorization always starts from DeerFlow mapping ids and is
rechecked by the Gateway. External endpoints use the isolated
/api/external/llmwiki/wiki namespace and X-API-Key; require published +
external-enabled + index-enabled on every request and response, and hard-deny
the system / 对话沉淀 mapping. Those API-key endpoints never return remote
WeKnora ids, provenance refs, credentials, or vector bytes.
POST /api/knowledge/vector-search is a deliberately separate anonymous API.
It has no token/API-key requirement or rate limit, applies route-scoped
Access-Control-Allow-Origin: *, searches every published local Wiki index
regardless of external_search_enabled, and still hard-denies the system /
对话沉淀 mapping before and after search. Its contract is section-level (one
result per matched vector, so Wiki-page fields and URLs may repeat), default
top_k=10, with optional include_page_content. Unlike the protected legacy
external namespace, this endpoint intentionally returns DeerFlow/WeKnora ids
and raw source/chunk provenance, but never credentials or vector bytes. Wiki
links use llmwiki.local_wiki_index.frontend_base_url (development default
http://127.0.0.1:5174) and point to the native
/#/page/workspace/knowledge/{mapping_id}/wiki/view?wikiSlug=... route.
Regression tests:
tests/test_llmwiki_local_index_repository.py,
tests/test_llmwiki_wiki_sync.py, tests/test_llmwiki_vector_search.py,
tests/test_external_llmwiki_search.py,
tests/test_public_knowledge_vector_search.py, and
tests/test_llmwiki_wiki_retrieval.py.
The sandbox whole-report rewrite may carry an optional reportOutline Markdown
template from the shared report-structure library. Persist the original template
in the durable input snapshot and append it as an explicit chapter-structure
constraint before requirements planning and prose generation; a template must
never be used as permission to invent facts absent from the current report.
DocumentRewriteVersionRepository.create backs the reversible before-image for
both artifact and whole-report rewrites. It must flush and serialize the new row
on the writer before commit, and must never call session.refresh() after
commit: a read/write-splitting MySQL endpoint can route that refresh to a
lagging replica, falsely report a version-save failure, and trigger the caller's
intentional restore of the rewritten document.
Version snapshots are an undo enhancement, not a condition for preserving an
already committed rewrite. If snapshot creation fails after the artifact/report
write, log the exception, complete the rewrite with
versionSnapshotFailed: true, and do not emit a user-facing failure or restore
the original. The sandbox uses that signal only to enable its ordinary manual
save fallback.
Offline local-sandbox Office runtime
The DeerFlow-only offline deployment uses LocalSandboxProvider, so document
dependencies belong in the backend image rather than a separate sandbox image.
deploy/linux-docker-offline/Dockerfile.backend installs python-docx,
LibreOffice Writer/Calc/Impress, Pandoc, Poppler, and CJK fonts in addition to
the MarkItDown Office extras. file_conversion.py normalizes legacy
DOC/XLS/PPT and ODT/ODS/ODP through a per-conversion LibreOffice user profile
before MarkItDown extraction, avoiding concurrent profile locks. The image
build and OFFLINE_INSTALL.sh --office-check both run
scripts/office_runtime_selfcheck.py; that smoke test creates and re-parses
minimal DOCX/XLSX/PPTX files and does not need the application database.
Skill/assistant knowledge invariants
Skill distillation is implemented in deerflow.skill_knowledge and
deerflow.persistence.skill_knowledge; native assistant bases are in
deerflow.assistant_knowledge and
deerflow.persistence.assistant_knowledge. Gateway composition, remote adapters,
the lease dispatcher, and APIs remain in app.gateway so the harness-to-app
import firewall is preserved.
Never execute a skill or read/upload/summarize its code and script files.
Knowledge fingerprints exclude those files and Markdown code fences. All
resyncs must stage and validate the knowledge digest before activation; the old
snapshot remains authoritative on failure. A binding may have only one active
item (active_dedupe_key plus atomic lease claims). Skill distillation targets
only writable WeKnora Wiki knowledge bases in this product version; reject direct
assistant-base targets, non-Wiki/FAQ/conversation/readonly targets, and direct
Neo4j writes. The create/list/resync/retry/detach flow is available to ordinary
users for their own visible skills, writable knowledge bases, jobs and bindings;
administrator-only auto-sync/review APIs remain privileged. Detach/delete operates on source contributions, not canonical rows,
and shared entities/relations are retired only when their final contribution is
gone.
Assistant knowledge has one global full-library base (知识梳理总库) and any
number of admin-created custom bases. Administrators always retain its management
surface and own all mutations. Day-to-day ordinary-user exposure is controlled by
system_settings.json -> knowledge_base_product_version: v1 (the default) hides
assistant navigation, selectors and cross-links so ordinary users see only ordinary
knowledge; v2 releases those surfaces again. The switch must not delete persisted
assistant bindings, and administrators remain able to configure assistant knowledge in
either mode. This is a recoverable product-surface switch rather than an assistant read
API authorization boundary; hidden assistant selections are omitted from new day-to-day
chat runs while their persisted bindings remain intact. Assistant bases accept local documents independently
of WeKnora via /api/assistant-knowledge/imports/files, as well as WeKnora export
packages or imports triggered from a WeKnora mapping. Local documents use the
Gateway assistant_file_ingest pipeline: persisted original files, chunked LLM Wiki
extraction, cached body embeddings, and import-job status/retry. Do not add direct skill
imports. Importing into a
custom assistant base must also mirror the package into the global base for
cross-source entity alignment. Admins may rename any assistant base and delete
custom bases. Deletion removes the custom base's Wiki pages, vectors, entities,
relations, import audit rows, and package files; the global base is never
deletable because it owns full-library aggregation.
Assistant-knowledge reads keep the landing and article paths size-bounded.
list_bases() is metadata/statistics-only and must never call the lazy global
alignment pass: imports align their snapshot before commit, while content reads
use the last committed snapshot without waiting for the global import/alignment
locks; search and export retain _ensure_base_aligned for legacy data. Directory,
article, source and import-job reads resolve their base with include_stats=False
so loading/polling does not recount every content table. The Wiki directory calls
GET /api/assistant-knowledge/wiki-pages?include_content=false; repository
load_only projection must exclude both content_markdown and search_text
(response-only deletion is insufficient), and the frontend loads only the
active slug through GET /wiki-pages/{slug}. The explicit index sorts into the
first directory batch; that batch unlocks the reader immediately while later
500-row batches append in the background. A URL-provided slug must fetch in
parallel with the directory. An existing base with zero sources and zero Wiki
pages is a valid, reachable state: keep the Wiki management surface mounted and
show upload/package-import actions instead of requiring a document or redirecting
away. The ordinary Wiki empty reader must likewise link writable users to the
manual Wiki editor. Do not enable global pages, sources, or import-job queries
while the landing route has no base_id.
The assistant-base switcher is a hot metadata path. list_bases() must load all
four counters with one grouped statement rather than four serial counts per
base, and a metadata-only rename must not rescan content tables. Assistant
vector search validates base ids without loading counters, checks vector
fingerprint/dimensions before encoding, scans only vector blobs plus minimal
page identity in matrix batches, and hydrates bodies only for final top results.
Chinese lexical fallback must extract bounded meaningful subphrases rather than
treating the entire no-space question as one SQL token.
The shared WeKnora runtime client retains its authenticated session and caches the upstream knowledge-base directory for 15 seconds, coalescing concurrent scope requests. Knowledge-base and document mutations invalidate that cache; generation checks prevent an older in-flight list response from repopulating it.
Local Wiki synchronization is metadata-first. A WeKnora page-list item that
contains the body is authoritative for hashing and must not trigger a detail
GET. For legacy list shapes without a body, only matching explicit version or
updated_at markers may skip the detail GET; missing markers must fail safe to
the detail API. A content-identical page still refreshes category/parent/path,
source/chunk references, page version and remote timestamps under the same
mapping lease, without replacing vectors. Missing pages are retired only after
a complete paginated scan and a final owner/sync-id lease fence. A bodyless
metadata-only scan is not a complete graph snapshot: if the provider graph call
also fails, preserve the prior graph rather than replacing it with partial
edges.
Both Gateway document APIs and successful content mutations through the embedded WeKnora proxy schedule one per-mapping debounced retry sequence with bounded 1/8/30/90/300-second backoff. Never cancel an in-flight sync to debounce a newer mutation; wake the coordinator, update the local generation, and let the database lease deduplicate workers. While WeKnora generates Wiki content, poll both the knowledge-base state and per-document pending subtasks, then retain a post-processing scan and one short quiet confirmation. Before retiring missing pages or replacing graph edges, the sync service must re-read processing state; processing or an unverifiable state produces a retryable partial result and preserves the prior page/vector/graph snapshot. The periodic scheduler reads the remote knowledge-base directory once per tick, skips unchanged/processing libraries (including forced reconciliation), and retains a 10-minute fallback.
Wiki package and local encoder invariants (2026-09)
Ordinary knowledge detail landing is globally configured by ordinary_knowledge_landing (wiki default / weknora), with admin-only PUT /api/system-settings/ordinary-knowledge-landing. Explicit document/graph/article links bypass this default. Wiki reading and the embedded WeKnora view provide reciprocal navigation; ?view=weknora explicitly selects the iframe and must not loop back to the default Wiki route.
knowledge_transfer.sync_to_globalis called after successful explicit local Wiki vectorization; it skips conversation/system mappings. Concurrent triggers for one mapping are coalesced inside a Gateway process around the complete check/create/import sequence. Cross-worker safety lives at the repository transaction boundary: every assistant import takes the target base rowFOR UPDATEbefore graph alignment or dependent snapshot writes, preserving one lock order across workers. Retry only recognized transient lock-wait, deadlock and serialization-contention errors; let the failed transaction roll back fully, then replay the whole package with bounded backoff. Cancellation and non-contention failures must propagate unchanged. Do not enable a new watcher or automatic remote-write scheduler as a side effect.wiki_packagesexposes file-backed assistant exports and restores into empty ordinary Wiki libraries. Validate vectors before any remote mutation; recreate folder IDs from category paths using provider APIs (a raw category_path is discarded by WeKnora without a matching folder_id). Preserve page metadata and rebuild backlinks after restoring all pages.PackageArchiveis a replayable, disk-backed JSONL staging index. Always close it in finally. Runtime imports must not use the legacy in-memory read_package.- Preserve normalized float32 base64 bytes exactly. Same dimension alone is not
model compatibility. Assistant vector retrieval is fingerprint-gated cosine
search, and any keyword fallback must be labeled as such.
Preserve a valid zero section_index. Export/search vectors belonging to each
current source snapshot (
source.last_import_job_id), not merely the page's current revision: one aligned page may intentionally contain current vectors from several sources. - The
localembedding provider uses FastEmbed with official BAAI/bge-m3 ONNX, 1024 dimensions, bounded CPU batches and a per-client async lock. Run inference off the event loop. Existing page vectors are reusable; zero-vector nonempty pages need backfill, not a forced rebuild of every page. FastEmbed is a standard backend dependency. Encoder configuration, directory upload and warmup live only in the administrator global settings dialog. When the effective provider is local, itssystem_settings.jsonpath override wins over config.yaml; uploaded files are streamed into immutable.deer-flow/models/bge-m3-revisions/<revision>directories, never the source tree. Encoder warmup is explicitly started from the settings UI/API and must never block application startup or turn a model-load failure into a DeerFlow service failure. Local provider is offline-only: require local_model_path or DEERFLOW_BGE_M3_MODEL_PATH and never let the application download model weights. Keeplocal_process_isolation=trueby default: inference runs in the bundled JSONL worker subprocess, and shutdown must terminate that worker without waiting on model teardown. - The administrator encoder override is an explicit persisted mode, not an
inference from whether
model_pathhappens to be non-empty.configureduses the currentconfig.yamlprovider (including OpenAI-compatible); saving/uploading a local directory setslocalplus a new model revision, while/api/llmwiki/local-encoder/use-configuredswitches back. Every switch hot-rebuilds embedding/cache/search/sync/retrieval as one graph and updates the harness runtime registration. Status must remain readable for a remote provider. The scheduler stays alive as a lightweight config watcher but only scans when effectiveenabled && auto_sync; keep the default/portable release configs atauto_sync=truebecause asynchronous Wiki generation can finish after the event-triggered first scan. A changed local model revision must change the embedding fingerprint so vectors from replaced weights are never reused. Uploaded weights live in immutable revision directories. A replaced embedding generation gets a bounded grace period for in-flight work, then rejects stragglers, drains real thread work, and releases its model process. - Every successful mapping snapshot is mirrored to the global assistant base, including empty snapshots so deleted source pages are retired. Re-import is source-replacement semantics: absent Wiki/chunk/entity/relation pages must not remain visible merely because they existed in an older source snapshot.
- Provider graph nodes bind to their original wiki_slug. They must not create duplicate synthetic entity/relation articles. Store original Wiki metadata separately from assistant workflow status. Count Wiki pages separately from raw document chunks.
- Assistant graph retrieval resolves each active relation through both active
endpoint entities'
wiki_page_idvalues and their active Wiki pages. Every relation, entity, seed page, and neighbor page must match the requestedbase_id; never traverse a corrupt cross-base edge. Pre-retrieval uses the same bounded one/two-hop walk as ordinary Wiki, labels graph evidence as assistant evidence, and lets a directly recalled non-seed page remain a traversal frontier without duplicating it in the answer context. - The global base aligns entities by normalized display name and aliases across source records, and aligns content Wiki pages by normalized title. Do not use source slug as the global canonical identity. Merge unique knowledge blocks while retaining source contributions/origins. Uploaded packages must rebind the temporary upload source to the stable WeKnora knowledge-base id in the package, so re-upload is source-replacement rather than append. Retrieval candidates exclude summary/index/directory pages; keyword matching, vector snippets, citations and prompts must use knowledge body/sections, never page summary as a substitute.
- Migration
20260903_01adds nullable page metadata and merges the pre-existing knowledge/workflow Alembic heads. Regression coverage is intests/test_wiki_package_roundtrip.py,test_llmwiki_wiki_sync.pyandtest_assistant_knowledge_repository.py.
Startup schema failure invariant
Gateway availability must not depend on successful startup DDL. ORM
Base.metadata.create_all (including database auto-create retries) logs schema
failures and continues with the existing engine/schema; do not restore a bare
raise that aborts FastAPI lifespan. Startup seed reads/writes must be
best-effort and use a process-local default where one exists. LangGraph
Checkpointer and Store own separate setup() tables, so deps.langgraph_runtime
must fall back to InMemorySaver / InMemoryStore if entering either persistent
context fails. The fallback keeps the API process available but is intentionally
non-durable; operators must still repair the logged database error and restart.
Regression coverage lives in tests/test_ensure_orm_columns_sync.py and must
exercise both ORM create_all failure and runtime-backend setup() failure.
openGauss / GaussDB persistence invariant
database.backend=gauss is an application-ORM backend, parallel to mysql.
It normalizes database.gauss_url to opengauss+asyncpg:// and requires the
gauss optional dependency (asyncpg plus opengauss-sqlalchemy). Never route
a Gauss URL through SQLAlchemy's generic PostgreSQL dialect: the wire protocol
may connect, but dialect initialization and schema DDL can fail against the
Gauss server/version behavior. Keep the LangGraph checkpointer on the separately
configured SQLite backend; the PostgreSQL checkpointer schema is not part of the
Gauss compatibility contract. Treat both postgresql and opengauss dialect
names as timezone-aware PostgreSQL-family engines in portable types and
dialect-specific session settings.
openGauss Dolphin / MySQL-protocol mode
The offline deployment may select DATABASE_TYPE=gauss_mysql. Its renderer
keeps database.backend=mysql and sets DEERFLOW_DATABASE_PRODUCT=gauss_mysql;
this explicit product marker is the switch because SQLAlchemy cannot safely
distinguish Dolphin from MySQL before dialect initialization. Only that mode
installs deerflow.persistence.gauss_mysql_compat for the configured async
driver. An unset marker, or mysql, must retain the stock MySQL dialect and
schema-maintenance behavior unchanged.
Dolphin table existence and additive schema checks use current_schema() plus
the native pg_catalog through gauss_schema.ensure_gauss_schema_sync.
Never run the generic MySQL SHOW CREATE TABLE reflection sweep, MySQL JSON
projection backfill, MySQL session-timeout variables, or application-side
CREATE DATABASE retry in this mode: these paths can misread the schema or
abort the openGauss transaction with error 322. Database/user/schema bootstrap
remains the offline deployment layer's responsibility. The startup DDL block
is still best-effort under the global startup-schema invariant and always
releases its host-local cross-worker lock.
PortableJSON writes strict JSON for both products. Its legacy decoder is
available only on a dialect class carrying the Dolphin compatibility marker;
ordinary MySQL must continue to reject malformed/non-JSON values rather than
silently applying the Dolphin fallbacks.
2026-09-14 我的项目与知识库验收修复
- 简洁模式的「我的项目」列表、策略工作台和普通问答项目恢复工作区功能导航;窄屏提供展开菜单按钮。
- 普通问答先创建具名项目,再在项目内创建多个对话。列表读取
/api/analysis-projects的qa项目,不再读取、标记或自动归入全局普通聊天。 - 项目设置更新保留既有知识库绑定。原「项目空间」与官方入口继续可用;我的项目下问答路由为
qa/projects/groups/*。 - 策略生成卡片统一先显示思考/执行过程,再显示正文;评判、方案及展开视图沿用相同顺序。
- 完成评判但尚未勾选策略的项目可以从方案列表进入,在工作台选择后生成;已保存方案在任务修改后仍可打开。旧列表请求不再覆盖新请求状态。
- 知识库管理页读取上游最新目录,避免远端删除后命中旧缓存;检索调用仍共享短期目录缓存。
- 管理员,或管理员已开放
v2展示后的普通用户,访问尚未初始化的助手知识库时显示明确空态及刷新按钮。
验证:后端相关回归 273 项、前端策略/项目回归 31 项通过;真实 BAAI/bge-m3 编码、指定库向量检索与草稿隔离通过;本地隔离服务的问答项目创建/分组、真实问答、方案整理、助手库创建/编辑/文件入库/向量检索/详情均通过。完整验收记录见仓库 docs/ji-knowledge-projects-acceptance.md。不在源码或文档中保存接口密钥。
2026-09-14 日常使用复查:补修项目历史入口与移动侧栏、默认智能体同步、项目加载重试、助手知识检索过期响应隔离及无结果指引;更新 ScrollArea 的 React 19 兼容修复。后端再次 273 项通过,前端 33 项通过。范围及限制见仓库 docs/daily-usage-audit-20260914.md。
知识库扩展与统计检索(2026-09-20)
llmwiki_knowledge_bases.test_questions/retrieval_settings保存每库测试问题与检索策略;NULL测试问题表示使用knowledge_profile.default_test_questions,显式空数组表示禁用。列表/详情统一通过_mapping_view返回解析后的配置。deerflow.integrations.weknora.local_index.advanced是问答与调试页共享的权威实现。实体扩展只允许一跳、数量有上限,来源为别名/Wiki 链接/Markdown 链接;扩展查询各自运行现有 Wiki-only 混合检索,再进行 RRF 融合。不得把扩展改成无限图遍历,也不得绕过当前用户的 mapping-id 授权。- 时间区间与计数在后端确定性计算,模型不得自行数检索结果。优先按结构化
source_refs的稳定来源标识去重;没有可识别来源记录时返回unit=wiki_page。日期优先读取来源字段/元数据和 Wiki frontmatter,远端创建/更新时间只是带date_source的后备。区间统计排除无日期记录并返回undated_excluded_count。 POST /api/llmwiki/knowledge-bases/{id}/retrieval-debug与llmwiki_search使用同一个advanced_search。调试响应必须保留 query plan、statistics、references、diagnostics 和 request id,供/page/workspace/knowledge/{id}/retrieval-debug及浏览器 Network 面板排错。- 全局与单库 retrieval-debug 页面、入口和 API 均仅管理员可用。前端隐藏不是权限边界;两个 POST 路由必须在参数解析后的任何检索、索引运行时重建或证据读取之前完成管理员校验。
- 回归测试:
tests/test_llmwiki_advanced_retrieval.py;同时运行test_llmwiki_wiki_retrieval.py、test_llmwiki_vector_search.py、test_llmwiki_local_index_repository.py和test_llmwiki_weknora.py。
知识库标题权重与引用切片(2026-09-22)
- 检索排序必须把原始用户查询的标题/别名命中作为高置信信号,不能让实体扩展词
冒充原始查询。统一实现位于
deerflow.integrations.weknora.local_index.ranking; 普通 Wiki 的向量、关键词、远端 Wiki、原文 chunk 以及助手知识库融合均保留title_match_score,标题信号以有界乘数并入 RRF/相关性分数,不能作为绝对主排序键。 普通 Wiki 的全检索链标题加成由llmwiki.local_wiki_index.title_boost_enabled/weight控制;关闭或设为 0 必须恢复 原排序。 - 引用详情必须同时展示本次命中的切片和文档/Wiki 正文。
/api/llmwiki/sources/context返回focus_chunk_id及邻近chunks;前端WeKnoraSourceDrawer使用精确焦点切片 作为详情顶部的「本次检索命中切片」,不能只重复文档级摘要。 - 对话页的后续问题保留生成结果,但只允许在消息列表底部渲染。
MessageList上报底部 状态,ChatPage、AgentChatPage与分析项目对话通过InputBox的followupsDisplayAllowed控制;上滑隐藏,回到底部恢复。
OpenAI-compatible stream regression coverage
PatchedChatOpenAImust emit partial tool arguments immediately, before a complete JSON document exists.test_patched_openai_reasoning_streaming.pyreplays actual SDK SSE through a gated local transport and the Gateway message serializer, covering standard deltas, cumulative snapshots, empty tool headers, and repeated complete tool names. Per-invocation/index name state must reset on a new call id; registered complete names permit safe deduplication before arguments start, while legitimate name fragments remain intact.- Empty
error: {}is not an upstream error. Emptydeltabeside amessage.reasoning*extension must not discard reasoning; a valid delta field takes priority. The existing per-stream summary records only counts, timing and allowlisted request flags (incoming_reasoning_chunks,emitted_reasoning_chunks,first_reasoning_ms,request_enable_thinking,repeated_tool_names), not prompt/answer/argument bytes. - Model-facing sandbox
write_fileexecutes once its full arguments are validated. Partial argument display is a frontend virtual artifact, not incremental persistent writes. Do not confuse local replay with a production upstream/20-user load test. buffered_gatewayregression coverage must preserve the native external contract: the provider selects the original metadata-onlywrite_file, first confirmed body characters from the second ordinary-text request appear immediately as canonical incrementalwrite_fileargument chunks, and LangGraph sees exactly one original writer call. It must never publish or executebegin_write_file. Ordinary answers stay on one provider request; renderer text never appears as assistant content; reasoning stays in the reasoning channel; and empty/truncated output cannot reach file I/O. The isolated providercontentchannel is the typed transaction boundary; do not add another model-authored JSON wrapper or delimiter. The olderartifact_rendererhandshake retains its existing internal-control invariants. Unconfigured models remain byte-for-byte on the native path.
Thread operation timing
app.gateway.operation_timing.OperationTiming adds content-free phase logs and Server-Timing headers
to thread create/search, including existing-thread idempotent returns and HTTP error paths. Preserve direct
internal Python callers by keeping the injected Response optional. Checkpoint creation remains synchronous
from the endpoint's perspective; do not silently defer it and break immediate state reads. Phase time includes
saver lock/pool waits, not just SQL execution; handler time excludes pre-handler queueing. run_agent measures
the synchronous agent factory with AGENT_BUILD_TIMING without changing task ContextVars or MCP lifecycle.
Database archive/restore SQL is a separate DBA-operated handoff, not source code or an application migration.
Never wire database archiving into application startup or package it as part of this performance change.
Uploaded-file context and bounded reads
deerflow.uploads.inspectionperforms streaming whole-file statistics and deterministic head/middle/tail sampling. It reads limits from the harness runtime upload-policy registry and never importsapp.*.read_fileuses provider-level bounded range reads; both LocalSandbox and AioSandbox implement them without fetching the complete file. A range-free large local-file read returns the structured inspection payload.UploadsMiddlewarekeeps bounded file metadata/content samples in agent state and prefixes a JSON-encoded<uploaded_files>manifest to the latest real HumanMessage only in the transient model request. It never writes the manifest into checkpoint history, and rebuilds legacy state from the upload directory, so provider retries, tool loops and later follow-ups retain file evidence. Generated Markdown companions are excluded as duplicate uploads.- Only convertible Office/PDF originals may resolve Markdown sidecars. Plain TXT/Markdown/CSV is sampled directly; same-stem user Markdown must never be mistaken for the companion of a text or image upload.
- Converted companions use collision-free
<original-name>.<ext>.mdnames and extracted Markdown has a fixed character ceiling. Conversion always runs off the event loop with bounded worker concurrency. grepscans configured upload sizes with total-byte, time, result and line bounds.ContextWindowGuardMiddlewareinfers common provider window fields when available. It never guesses a smaller window for an unprofiled model; after a real provider context-length rejection it retries with progressively bounded requests, prioritising the upload manifest and file-reading tools.
Q&A tool and terminal-stream liveness (2026-09-28)
sandbox/skill_view.pybounds warm-validation followers to 30 seconds. A cold catalogue rebuild may wait up to 120 seconds, and overlapping callers in the same process share that one rebuild (including when the metadata cache is off or the extensions file cannot be read) instead of each taking the process lock. The same thread may re-enter the view lock; it must not wait on itself. Timeout fails closed; never publish a stale view, remove a live leader, or bypass skill availability checks. Existing cache/reuse semantics remain intact.- LocalSandbox commands close stdin, create an independent process group on POSIX, collect output in temporary files, and terminate the tree on timeout with bounded reaping.
SandboxConfig.command_timeout_secondsis an optional finite positive number, default 600. Captured output is read with a 1 MiB per-stream memory budget; running commands have a sampled 16 MiB capture guard. AIO lock acquisition is bounded independently of remote request execution. - Local text reads reject non-regular files after nonblocking open; range readers stop collecting an oversized current line at the output budget. Preserve the existing next-physical-line continuation convention.
runtime/runs/worker.pygives terminal checkpoint inspection/status persistence individual 10-second budgets and final metadata cleanup one shared 10-second budget. Always publish the SSE terminator from the cleanup finally. These budgets must never detach executing graphs or rollback writes. Check cancellation and whether a newer run exists before further title/thread-status work; SQL already issued may not be reversible.- Frontend terminal recovery checks send epoch/run id, clears the SDK reconnect marker before stopping the local reader, and then applies the checkpoint. Pending submit alone does not identify a new question. Ordinary SDK requests time out in 30 seconds while SSE retains its unlimited total duration.
- Regression and operational limits: 问答卡住排查与修复. This checkout contains no
config.example.yaml; old config files remain valid without the new optional field.
Concurrent skill refresh publication
sandbox/skill_view.py coalesces overlapping cold/invalidated refreshes by storage/runtime key and availability-config hash. Followers share one final tree validation, then recheck the current cache identity, config hash and capability marker before returning. They never reuse a completed flight, join a different permission generation, cancel a live leader after their 30-second timeout, or return the old capability after a failed refresh. The optimization does not cache filesystem metadata on a TTL or remove the remaining per-call scan cost. Regression: packages/harness/tests/test_skill_view_refresh_coalescing.py.
TodoMiddleware's internal HumanMessages carry hide_from_ui=true and lc_source=todo. The frontend's shared isHiddenFromUIMessage also recognizes the exact historical names todo_reminder and todo_completion_reminder; question navigation uses the same predicate. Never filter a genuine user's question just because its text quotes <system_reminder>. Regression: test_todo_reminder_visibility.py and frontend todo-reminder-visibility.test.ts.
Measured Windows test scope and remaining limitations are recorded in 本地问答与技能并发验收. Frontend compact/agent onboarding mounts TDesign Guide only while a valid step is active: its idle resize listener otherwise dereferences an absent step. Completion/skip persistence and interrupted-tour behavior remain unchanged.
Force-situation consumer skill authentication (2026-09-30)
The seven consumer-facing custom skill scripts (fs-collect, fs-task-detail-info,
fs-timeline-info, fs-insight-info, fs-current-judgment-info, fs-target-info,
and fs-connection-info) reserve line 1 for # API_URL=... and line 2 for
# AUTHORIZATION=Bearer admin. monitor_api_common.monitor_authorization_from_second_line
loads that exact second-line value; an explicit CLI --authorization remains the highest-priority
runtime override. Keep certificate verification disabled and proxies bypassed for these intranet
calls. The active sandbox skill mirror must be synchronized with the canonical custom skills.
Gateway detail writes emitted by fs-collect and fs-detail-submit log a token-masked request and
the corresponding response so an interrupted chain can be distinguished from a failed persistence
call without exposing the gateway bearer token.
Task-detail custom skill password authentication (2026-10-26)
skills/custom/task-context and skills/custom/task-detail-update authenticate detail reads/writes
with a short-lived token minted by POST /api/v1/auth/login/local, using the runtime-only
TASK_DETAIL_AGENT_USERNAME / TASK_DETAIL_AGENT_PASSWORD pair and TASK_DETAIL_GATEWAY_URL.
They must never use general DEERFLOW_AUTH_TOKEN or TOOLS_TOKEN, nor emit the password or JWT in
an input, trace, or model-visible output. /api/parallel-agents/auth/token is only a legacy fallback:
it follows sso_enabled and returns 403 when external SSO is disabled. Password-login failures other
than the policy 403 must fail closed; do not fall back to identity-only token minting. Keep the
canonical custom directories and .deer-flow/.sandbox-online-skills mirror synchronized through the
custom-skill reconciliation flow. scripts/setup_biz_detail_assistant.py logs in through the same
formal endpoint and accepts DEERFLOW_SETUP_BASE, DEERFLOW_SETUP_USERNAME,
DEERFLOW_SETUP_PASSWORD for deployment overrides; it may fall back to the legacy username route
only for the formal login policy 403, never for a credential or rate-limit failure.
biz-detail-assistant must retain the native ask_clarification tool. Do not special-case it by
adding that tool to requested_excluded_tools, and do not mount DetailConfirmMiddleware to forge
confirmation cards from a plain model response: those cards are not genuine model tool calls and break
the standard pause/resume interaction. The final ClarificationMiddleware is the single authority for
all missing-information, scope, approach and risk-confirmation questions.