feat(backend): 完善 Workflow 与 DeepResearch 的运行链路与持久化能力

功能说明:\n- 重构并增强工作流网关执行链路,支持更完整的 proposal 与 run 调度参数透传\n- 增加 DeepResearch/workflow 运行事件持久化能力并补齐建表 SQL 与查询/保存接口适配\n- 优化 lead agent 与 deep research runner 的中间态处理,增强线程收集与执行链并发控制\n- 调整工作流执行器与任务路由,提升兼容性并完善错误与回放行为\n- 补充回归测试:deep_research 持久化、workflow 并发、定义加载、引擎执行、run 流程等覆盖
This commit is contained in:
董宏凯 committed 2026-09-02 12:52:37 +08:00
1 parent f5bf7897b9
commit 6d326a21cd
28 files changed
+836 -135

No files matched your search

@@ -45,6 +45,10 @@ logger = logging.getLogger(__name__)
# Upper bound on harvested materials so a chatty collector thread cannot blow
# up the report context; the curating/compressing stages trim further anyway.
MAX_HARVESTED_MATERIALS = 80
# A collector model may time out after tool calls were checkpointed but before
# its final assistant response advanced the thread head. Keep the scan bounded
# while retaining the richest checkpoint from that short history.
MAX_CHECKPOINT_HISTORY = 32
# Tool-name → ResearchMaterial.source_type. Everything unknown maps to
# ``other``; ``bash`` is treated as ``skill`` because skill scripts emit there.
@@ -72,11 +76,43 @@ async def harvest_thread_materials(
visible text (its collection小结) and doubles as a fallback material when
the thread produced no harvestable tool results.
"""
checkpoint_tuple = await checkpointer.aget_tuple({"configurable": {"thread_id": runtime_thread_id, "checkpoint_ns": ""}})
config = {"configurable": {"thread_id": runtime_thread_id, "checkpoint_ns": ""}}
checkpoint_tuple = await checkpointer.aget_tuple(config)
if checkpoint_tuple is None:
logger.warning("deep-research harvest: no checkpoint for thread %s", runtime_thread_id)
return [], ""
values = checkpoint_tuple.checkpoint.get("channel_values", {}) or {}
best_materials, best_summary = _harvest_checkpoint_tuple(checkpoint_tuple, query=query)
# ``aget_tuple`` normally returns the latest state. When a streamed lead
# agent call fails, however, some checkpointers leave that head at the
# final input checkpoint even though an earlier checkpoint already holds
# the successful web-search tool results. Prefer the richest available
# material pool rather than treating that transient head as an empty run.
checkpoint_history = getattr(checkpointer, "alist", None)
if callable(checkpoint_history):
try:
history = checkpoint_history(config, limit=MAX_CHECKPOINT_HISTORY)
if hasattr(history, "__aiter__"):
async for candidate in history:
materials, summary = _harvest_checkpoint_tuple(candidate, query=query)
if len(materials) > len(best_materials):
best_materials, best_summary = materials, summary
else:
for candidate in history:
materials, summary = _harvest_checkpoint_tuple(candidate, query=query)
if len(materials) > len(best_materials):
best_materials, best_summary = materials, summary
except Exception: # noqa: BLE001 - newest checkpoint remains a safe fallback
logger.debug("deep-research harvest: checkpoint history unavailable for %s", runtime_thread_id, exc_info=True)
return best_materials, best_summary
def _harvest_checkpoint_tuple(checkpoint_tuple: Any, *, query: str) -> tuple[list[ResearchMaterial], str]:
checkpoint = getattr(checkpoint_tuple, "checkpoint", None)
if not isinstance(checkpoint, dict):
return [], ""
values = checkpoint.get("channel_values", {}) or {}
messages = values.get("messages") or []
return harvest_messages_materials(messages, query=query)
@@ -259,6 +295,7 @@ class StaticHarvestMaterialProvider:
__all__ = [
"MAX_CHECKPOINT_HISTORY",
"MAX_HARVESTED_MATERIALS",
"StaticHarvestMaterialProvider",
"harvest_messages_materials",
@@ -120,6 +120,11 @@ class BasicRunner:
all_materials: list[ResearchMaterial] = []
plan: dict[str, Any] = {}
# A completed Deep Research request must still produce a useful report
# when every retrieval channel is temporarily unavailable. This flag
# carries that runtime fact into the writer without mutating the
# immutable per-job config snapshot.
use_knowledge_fill = False
try:
# ── 1. planning: generate sub-queries ──────────────────────────
@@ -198,32 +203,20 @@ class BasicRunner:
cancellation.raise_if_cancelled()
if not all_materials:
knowledge_fill = _allows_knowledge_fill(request.config.report_instruction)
# Collection is best-effort. In particular, a collector run
# can time out after its tool calls have streamed to the user,
# or every configured retrieval backend can be unavailable.
# Neither case should turn a report request into a stub file.
use_knowledge_fill = True
await events.emit(
"warning",
phase="curating",
payload={
"code": "no_materials",
"message": (
"未收集到外部研究材料,改用领域知识撰写"
if knowledge_fill
else "未收集到任何研究材料"
),
"recoverable": knowledge_fill,
"message": "未收集到外部研究材料,已改用领域知识继续撰写完整报告",
"recoverable": True,
},
)
if not knowledge_fill:
report_md = (
f"# {request.query}\n\n"
f"未能收集到与研究课题「{request.query}」相关的材料,无法生成报告。"
)
await adapters.artifacts.write_text("report.md", report_md)
await events.emit(
"report_completed",
phase="writing",
payload={"reportVersion": 1, "sourceCount": 0},
)
return DeepResearchResult(report_markdown=report_md, source_ids=[], plan=plan)
deduped: list[ResearchMaterial] = []
selected_ids: list[str] = []
context_list: list[dict[str, Any]] = []
@@ -261,7 +254,14 @@ class BasicRunner:
if cancellation:
cancellation.raise_if_cancelled()
report_md = await self._generate_report(llm, request, context_text, events, cancellation)
report_md = await self._generate_report(
llm,
request,
context_text,
events,
cancellation,
allow_knowledge_fill=use_knowledge_fill,
)
report_md, image_paths = await generate_report_images(request, adapters, report_md, cancellation)
# ── 6. write artifacts ─────────────────────────────────────────
@@ -384,14 +384,23 @@ class BasicRunner:
return cleaned or queries_from_retrieval_directions(query, dirs)[:n]
async def _search_one(self, provider, query, config, request, cancellation, events) -> list[ResearchMaterial]:
"""Search one sub-query, emitting events. Callers drive the ordering."""
"""Search one sub-query while keeping evidence independent of telemetry.
A material batch is the report's input, whereas ``source_added`` is
only progress/persistence telemetry. In particular, a source may
contain native ``datetime`` metadata that an older event store cannot
serialise. Do not let that failure discard an already-returned batch.
"""
if cancellation:
cancellation.raise_if_cancelled()
await events.emit(
"query_started",
phase="collecting",
payload={"query": query},
)
try:
await events.emit(
"query_started",
phase="collecting",
payload={"query": query},
)
except Exception: # noqa: BLE001 - telemetry must not prevent collection
logger.warning("basic runner: unable to emit query_started", exc_info=True)
materials = await provider.search(
query,
domains=config.allowed_domains or None,
@@ -402,27 +411,42 @@ class BasicRunner:
)
# Emit source_added for each material.
for m in materials:
try:
await events.emit(
"source_added",
phase="collecting",
payload={
"sourceId": m.id,
"title": m.title,
"url": m.url,
"sourceType": m.source_type,
# Full persistence-ready dict so the executor can store
# sources without re-deriving them from the event.
"source": m.to_persistence_dict(query),
},
)
except Exception: # noqa: BLE001 - the batch still belongs in the report
logger.warning("basic runner: unable to emit source_added for %s", m.id, exc_info=True)
try:
await events.emit(
"source_added",
"query_completed",
phase="collecting",
payload={
"sourceId": m.id,
"title": m.title,
"url": m.url,
"sourceType": m.source_type,
# Full persistence-ready dict so the executor can store
# sources without re-deriving them from the event.
"source": m.to_persistence_dict(query),
},
payload={"query": query, "resultCount": len(materials)},
)
await events.emit(
"query_completed",
phase="collecting",
payload={"query": query, "resultCount": len(materials)},
)
except Exception: # noqa: BLE001 - preserve collected materials on telemetry failure
logger.warning("basic runner: unable to emit query_completed", exc_info=True)
return materials
async def _generate_report(self, llm, request: DeepResearchRequest, context_text: str, events, cancellation=None) -> str:
async def _generate_report(
self,
llm,
request: DeepResearchRequest,
context_text: str,
events,
cancellation=None,
*,
allow_knowledge_fill: bool = False,
) -> str:
"""Generate the final Markdown report from the formatted context."""
outline = request.config.custom_outline
filled_preamble = fill_report_preamble(
@@ -431,7 +455,7 @@ class BasicRunner:
)
outline_block = _report_outline_instruction(outline)
requirements_block = _report_requirements_instruction(request.config.report_instruction)
if _allows_knowledge_fill(request.config.report_instruction):
if allow_knowledge_fill or _allows_knowledge_fill(request.config.report_instruction):
system_extra = (
"用户已要求在材料不足时用模型自身的公开领域知识写成完整报告。"
"禁止把「材料不足/未提供文献」写成章节主线;参考文献可标注为基于公开领域知识。"
@@ -595,7 +619,7 @@ def fill_report_preamble(
continue
if re.match(r"^密级\s*[::]", stripped):
value = re.sub(r"^密级\s*[::]\s*", "", stripped)
filled.append(f"密级:内部" if _is_placeholder(value) else f"密级:{value.strip() or '内部'}")
filled.append("密级:内部" if _is_placeholder(value) else f"密级:{value.strip() or '内部'}")
continue
title = _PLACEHOLDER_RE.sub(topic, stripped) if topic else stripped
filled.append(title)
@@ -842,6 +842,16 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
)
# 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是
# 指望模型从 ToolRuntime.context 猜测,确保自定义 SOUL 也能稳定使用。
presentation_theme = str(cfg.get("presentation_theme") or "").strip().lower()
if presentation_theme in {"dark", "light"}:
system_prompt += (
"\n\n【当前界面主题】\n"
f"当前页面呈现主题为 `{presentation_theme}`。若本轮生成汇报页面,请优先选择同色系 HTML 模板;"
"除非用户明确要求覆盖该主题。"
)
system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id)
_log_es_query_routing_registration(
@@ -10,6 +10,8 @@ from __future__ import annotations
import json
import logging
from datetime import date, datetime
from enum import Enum
from typing import Any
from sqlalchemy import func, select
@@ -21,10 +23,27 @@ from deerflow.persistence.deep_research_events.model import DeepResearchEventRow
logger = logging.getLogger(__name__)
def _json_default(value: Any) -> Any:
"""Make event payloads replayable when source metadata has native dates.
Search adapters intentionally preserve ``published_at`` as a ``datetime``
until it reaches the source store. Events carry the same source envelope,
so the append-only log must normalise it instead of silently falling back to
a live-only frame.
"""
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Enum):
return value.value
if hasattr(value, "model_dump"):
return value.model_dump(mode="json")
return str(value)
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
return json.dumps(value, ensure_ascii=False, default=_json_default)
def _loads(value: Any) -> Any:
@@ -14,19 +14,20 @@ from deerflow.persistence.workflow_events.base import WorkflowEventStore
from deerflow.persistence.workflow_events.model import WorkflowRunEventRow
from deerflow.persistence.workflow_runs.model import WorkflowRunRow
_MAX_PAYLOAD_CHARS = 64_000
_APPEND_ATTEMPTS = 5
def _dump_payload(payload: dict[str, Any] | None) -> str:
text = json.dumps(payload or {}, ensure_ascii=False, separators=(",", ":"), default=str)
if len(text) > _MAX_PAYLOAD_CHARS:
return json.dumps(
{"truncated": True, "preview": text[:_MAX_PAYLOAD_CHARS]},
ensure_ascii=False,
separators=(",", ":"),
)
return text
"""Serialize the durable event exactly as it was emitted.
``node.message`` carries DeerFlow's native LangGraph messages frame. It
contains tool results, clarification metadata, and sometimes large search
responses. Replacing it with a preview makes an SSE reconnect render a
different conversation from the live stream, so the event log must not
crop payloads. The column is ``PortableLongText`` specifically for this
kind of replayable payload.
"""
return json.dumps(payload or {}, ensure_ascii=False, separators=(",", ":"), default=str)
def event_to_dict(row: WorkflowRunEventRow) -> dict[str, Any]:
@@ -9,6 +9,7 @@ Canonical ``data`` payloads (frozen contract — REST replay and SSE must agree)
"title": "联网检索", "args": {"path": "..."}}
node.tool.finished {"name": "web_search", "toolCallId": "call_001",
"status": "succeeded" | "failed", "summary": "获得 12 条结果"}
node.message {"messages": [<native LangGraph message>, <metadata>]}
run.awaiting_input {"resumeToken": "one-time-token", "prompt": "请确认是否发布",
"formSchema": {...}, "actions": ["approve", "reject"]}
artifact.created {"artifactId": "artifact_001", "name": "weekly-report.docx",
@@ -40,6 +41,7 @@ WorkflowEventType = Literal[
"node.started",
"node.progress",
"node.output.delta",
"node.message",
"node.tool.started",
"node.tool.finished",
"artifact.created",
@@ -61,6 +63,7 @@ ALL_WORKFLOW_EVENT_TYPES: tuple[WorkflowEventType, ...] = (
"node.started",
"node.progress",
"node.output.delta",
"node.message",
"node.tool.started",
"node.tool.finished",
"artifact.created",
@@ -17,7 +17,7 @@ from typing import Any
from deerflow.workflows.errors import WorkflowError
from deerflow.workflows.expressions import render_template, render_value
from deerflow.workflows.nodes.base import require_dep
from deerflow.workflows.runtime.context import RunContext
from deerflow.workflows.runtime.context import RunContext, WorkflowPaused
from deerflow.workflows.schemas import ArtifactRef, NodeResult, WorkflowNode
logger = logging.getLogger(__name__)
@@ -96,28 +96,6 @@ def _extract_json(text: str) -> Any:
return None
_SAFE_ARG_STRING_CHARS = 160
_SAFE_ARG_MAX_KEYS = 12
def _safe_tool_args(args: Any) -> dict[str, Any]:
"""Minimal safe projection of tool-call args for the event log.
Scalars only (strings truncated), no nested containers, bounded key count —
enough for the canvas to label the step without leaking full payloads such
as entire documents, prompts or credentials that a tool may carry."""
if not isinstance(args, dict):
return {}
out: dict[str, Any] = {}
for key, value in list(args.items())[:_SAFE_ARG_MAX_KEYS]:
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
continue
if isinstance(value, str):
value = value[:_SAFE_ARG_STRING_CHARS]
out[str(key)[:64]] = value
return out
def _append_input_bindings(prompt: str, raw_bindings: Any, state: dict[str, Any]) -> str:
"""Attach explicit workflow inputs to an agent prompt.
@@ -206,7 +184,10 @@ class _AgentInvocation:
"name": name,
"toolCallId": call_id,
"title": str(event.get("title") or name),
"args": _safe_tool_args(event.get("args")),
# The native messages frame is authoritative for the
# conversation UI. Preserve this compatibility event
# too, so history/replay never differs from live data.
"args": event.get("args"),
},
node_id=node.id,
)
@@ -218,13 +199,30 @@ class _AgentInvocation:
"name": name,
"toolCallId": call_id,
"status": "succeeded" if ok in (True, None) else "failed",
"summary": event.get("summary"),
"result": event.get("result"),
},
node_id=node.id,
)
async def on_message(messages: list[Any]) -> None:
# Do not adapt the nested LangGraph frame. The browser consumes
# exactly the same native AIMessage/ToolMessage structure as
# DeerFlow's chat client; the outer workflow event only supplies
# durable seq and canvas-node attribution.
await ctx.emit_event(
"node.message",
data={"messages": messages},
node_id=node.id,
)
try:
try:
resume_payload = (
ctx.resume_payload
if isinstance(ctx.resume_payload, dict)
and str(ctx.resume_payload.get("nodeId") or "") == node.id
else None
)
outcome = await run_agent(
run_id=ctx.run_id,
node_id=node.id,
@@ -237,6 +235,8 @@ class _AgentInvocation:
cancel=ctx.cancel,
on_delta=on_delta,
on_tool=on_tool,
on_message=on_message,
resume_payload=resume_payload,
)
except WorkflowError as exc:
exc.node_id = exc.node_id or node.id
@@ -253,6 +253,20 @@ class _AgentInvocation:
finally:
await coalescer.flush()
pending_input = outcome.get("awaiting_input")
if isinstance(pending_input, dict):
raise WorkflowPaused(
node.id,
{
"nodeId": node.id,
**pending_input,
},
)
if resume_payload is not None:
# The reply belongs to this agent thread only. A later human/agent
# node must not accidentally consume it as its own resume data.
ctx.resume_payload = None
text = str(outcome.get("text") or "")
data: dict[str, Any] = {"text": text}
if response_mode == "json":