Files
董宏凯 1706a24182 feat(workflow): 增加会话级多智能体任务规划运行能力
提交范围聚焦于工作流编排新增:新增 workflow_planning 路由与前后端运行入口(workflow_planning.py、workflow_proposal_planner.py、workflow_deep_research_adapter.py),支持基于会话的提案-规划-执行流程,满足任务驱动式多智能体协作。

完成网关层运行时适配:扩展 workflow_resource_checks、workflow_executor、workflow_runs、workflow_agent_runner 与依赖注入,新增/更新配置(config.yaml、workflow_config),并补充 lead_agent 路径与节点实现,确保 Planner 与普通工作流运行态衔接一致。

补齐持久化与建模:新增 workflow_planning 持久化模块与 MySQL migration(20260831_01_workflow_planning_sessions),注册 SQL 与模型、内存 store,并扩展错误/表达式/validator/节点语义以承载新会话态输入输出。

增强文档与测试:新增会话式多智能体编排中文说明、更新开发文档与 AGENTS 指南、补充 workflow_engine/workflow_runs 回归测试,提高新路径的行为可验证性;同时留存 seed 资产(workflow-planner)便于一体化部署。
2026-09-01 11:47:30 +08:00

451 lines
17 KiB
Python

"""Bridge a workflow report node onto the existing Deep Research job system.
The workflow engine intentionally has no dependency on Deep Research storage or
the web app. This adapter is the only integration seam: it turns an immutable
workflow Evidence Pack into selected Deep Research source rows, creates (or
reattaches to) one durable report-writing job, and projects safe progress back
to the caller. It never calls the HTTP router and therefore cannot create an
internal request/auth loop.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from deerflow.agents.deep_research.config import (
DEFAULT_MAX_ACTIVE_JOBS_PER_USER,
DeepResearchConfig,
)
from deerflow.persistence.deep_research_jobs.sql import TERMINAL_STATUSES
from deerflow.workflows.errors import WorkflowError
from deerflow.workflows.runtime.context import CancelToken
logger = logging.getLogger(__name__)
_POLL_SECONDS = 0.25
_EVENT_PAGE_SIZE = 200
_MAX_EVIDENCE_ITEMS = 64
_MAX_EVIDENCE_ITEM_CHARS = 20_000
ProgressCallback = Callable[[dict[str, Any]], Awaitable[None]]
def _stable_id(prefix: str, *parts: str) -> str:
digest = hashlib.sha256("\x1f".join(parts).encode("utf-8")).hexdigest()
return f"{prefix}_{digest[:40]}"
def _text(value: Any, *, limit: int) -> str:
value = str(value or "").strip()
return value[:limit]
class WorkflowDeepResearchAdapter:
"""App-layer implementation injected into ``WorkflowRuntimeDeps``."""
def __init__(self, app: Any) -> None:
self._app = app
async def run(
self,
*,
run_id: str,
node_id: str,
owner_id: str,
topic: str,
evidence_pack: dict[str, Any],
research_config: dict[str, Any],
report_instruction: str,
cancel: CancelToken,
on_event: ProgressCallback,
) -> dict[str, Any]:
stores = self._stores()
config = self._config(research_config, report_instruction)
# A retry of the same node reattaches to its existing job, while a
# meaningful topic/evidence/write-config change receives a new report
# session instead of accidentally returning stale prose from an old
# snapshot.
execution_fingerprint = self._execution_fingerprint(
topic=topic,
evidence_pack=evidence_pack,
config=config,
instruction=report_instruction,
)
session_id = _stable_id("drs_wf", run_id, node_id, owner_id, execution_fingerprint)
source_count = await self._ensure_session_and_sources(
stores=stores,
session_id=session_id,
run_id=run_id,
node_id=node_id,
owner_id=owner_id,
topic=topic,
evidence_pack=evidence_pack,
config=config,
)
session = await stores["sessions"].get(session_id, user_id=owner_id)
if session is not None and session.get("status") == "completed":
report = str(session.get("report_markdown") or "").strip()
if report:
return {
"session_id": session_id,
"job_id": session.get("active_job_id") or (session.get("usage_snapshot") or {}).get("lastJobId"),
"report_markdown": report,
"source_count": source_count,
"reused": True,
}
job = await self._ensure_job(
stores=stores,
session_id=session_id,
owner_id=owner_id,
topic=topic,
config=config,
)
job_id = str(job.get("id") or "")
if not job_id:
raise RuntimeError("Deep Research job creation returned no id")
await on_event(
{
"type": "job_status",
"phase": str(job.get("phase") or "initializing"),
"payload": {"status": str(job.get("status") or "queued"), "jobId": job_id},
}
)
return await self._wait_for_completion(
stores=stores,
session_id=session_id,
job_id=job_id,
owner_id=owner_id,
source_count=source_count,
cancel=cancel,
on_event=on_event,
)
def _stores(self) -> dict[str, Any]:
state = self._app.state
stores = {
"sessions": getattr(state, "deep_research_session_store", None),
"jobs": getattr(state, "deep_research_job_store", None),
"events": getattr(state, "deep_research_event_store", None),
"sources": getattr(state, "deep_research_source_store", None),
"dispatcher": getattr(state, "deep_research_dispatcher", None),
"live_hub": getattr(state, "deep_research_live_hub", None),
}
missing = [name for name in ("sessions", "jobs", "events", "sources", "dispatcher") if stores[name] is None]
if missing:
raise WorkflowError(
"WORKFLOW_RESOURCE_MISSING",
"深度研究运行环境不可用",
details={"missing": missing},
)
return stores
@staticmethod
def _config(raw: dict[str, Any], instruction: str) -> DeepResearchConfig:
# A workflow report is a selected-material regeneration. It must not
# launch a second collection channel or pause for Deep Research's own
# multi-agent plan approval; workflow human_input remains the sole
# interaction protocol at this layer.
candidate = dict(raw or {})
if candidate.get("mode") == "multi_agent":
raise WorkflowError(
"WORKFLOW_DEEP_RESEARCH_FAILED",
"工作流写作节点暂不支持 Deep Research 的 multi_agent 计划中断模式",
)
candidate.update(
{
"collection_mode": "legacy",
"allowed_material_channels": ["other"],
"generate_images": False,
"include_human_feedback": False,
}
)
if instruction:
candidate["report_instruction"] = instruction[:4_000]
try:
return DeepResearchConfig.model_validate(candidate).clamp()
except Exception as exc: # noqa: BLE001 - expose a stable workflow error
raise WorkflowError(
"WORKFLOW_DEEP_RESEARCH_FAILED",
"深度研究写作配置无效",
details={"reason": type(exc).__name__},
) from exc
@staticmethod
def _execution_fingerprint(
*,
topic: str,
evidence_pack: dict[str, Any],
config: DeepResearchConfig,
instruction: str,
) -> str:
items = evidence_pack.get("items") if isinstance(evidence_pack, dict) else []
compact: list[dict[str, str]] = []
for item in items[:_MAX_EVIDENCE_ITEMS] if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
source = item.get("source") if isinstance(item.get("source"), dict) else {}
compact.append(
{
"claim": _text(item.get("claim"), limit=_MAX_EVIDENCE_ITEM_CHARS),
"role": _text(item.get("role"), limit=400),
"node": _text(source.get("nodeId"), limit=120),
"uri": _text(source.get("uri"), limit=2_000),
}
)
return hashlib.sha256(
json.dumps(
{
"topic": _text(topic, limit=8_000),
"evidence": compact,
"config": config.model_dump(),
"instruction": _text(instruction, limit=4_000),
},
ensure_ascii=False,
sort_keys=True,
).encode("utf-8")
).hexdigest()[:32]
async def _ensure_session_and_sources(
self,
*,
stores: dict[str, Any],
session_id: str,
run_id: str,
node_id: str,
owner_id: str,
topic: str,
evidence_pack: dict[str, Any],
config: DeepResearchConfig,
) -> int:
items = evidence_pack.get("items")
if not isinstance(items, list) or not items:
raise WorkflowError(
"WORKFLOW_DEEP_RESEARCH_FAILED",
"Evidence Pack 没有可供写作的证据项",
)
usable = [item for item in items if isinstance(item, dict)][: _MAX_EVIDENCE_ITEMS]
if not usable:
raise WorkflowError(
"WORKFLOW_DEEP_RESEARCH_FAILED",
"Evidence Pack 没有有效的证据项",
)
session = await stores["sessions"].get(session_id, user_id=owner_id)
if session is None:
await stores["sessions"].create(
id=session_id,
user_id=owner_id,
title=_text(topic, limit=60) or "工作流深度研究报告",
query=topic,
mode=config.mode,
config_snapshot={
**config.model_dump(),
"workflow_managed": True,
"workflow_run_id": run_id,
"workflow_node_id": node_id,
},
)
written = 0
for index, item in enumerate(usable, start=1):
claim = _text(item.get("claim"), limit=_MAX_EVIDENCE_ITEM_CHARS)
if not claim:
continue
source = item.get("source") if isinstance(item.get("source"), dict) else {}
source_node = _text(source.get("nodeId"), limit=120)
source_uri = _text(source.get("uri"), limit=2_000) or None
role = _text(item.get("role"), limit=400) or source_node or f"证据 {index}"
content = "\n".join(
[
"[工作流 Evidence Pack]",
f"角色:{role}",
f"来源节点:{source_node or 'unknown'}",
"以下内容是上游智能体或工具返回的材料,不自动视为已核验事实。",
"",
claim,
]
)
source_id = _stable_id("drs_src", session_id, str(index), source_node, claim)
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
await stores["sources"].upsert(
session_id=session_id,
job_id=None,
id=source_id,
user_id=owner_id,
first_seen_query=topic,
title=role,
url=source_uri,
raw_content=content,
snippet=claim[:1_000],
source="workflow_evidence_pack",
source_type="other",
relevance_score=None,
rec_uuid=None,
content_hash=content_hash,
selected=True,
selection_reason="workflow_evidence_pack",
citation_key=str(index),
)
written += 1
if written < 1:
raise WorkflowError(
"WORKFLOW_DEEP_RESEARCH_FAILED",
"Evidence Pack 中没有可写入的文本证据",
)
await stores["sessions"].update(
session_id,
user_id=owner_id,
source_count=written,
)
return written
async def _ensure_job(
self,
*,
stores: dict[str, Any],
session_id: str,
owner_id: str,
topic: str,
config: DeepResearchConfig,
) -> dict[str, Any]:
active = await stores["jobs"].get_active_for_session(session_id, user_id=owner_id)
if active is not None:
return active
active_count = await stores["sessions"].count_active_by_user(user_id=owner_id)
if active_count >= DEFAULT_MAX_ACTIVE_JOBS_PER_USER:
raise WorkflowError(
"WORKFLOW_LIMIT_EXCEEDED",
"当前深度研究任务过多,请稍后重试",
retryable=True,
details={"limit": DEFAULT_MAX_ACTIVE_JOBS_PER_USER},
)
request_id = f"workflow:{session_id}"
request_hash = hashlib.sha256(
json.dumps(
{"topic": topic, "config": config.model_dump()},
ensure_ascii=False,
sort_keys=True,
).encode("utf-8")
).hexdigest()[:32]
job_id = _stable_id("drj_wf", session_id, request_hash)
input_snapshot = {
"session_id": session_id,
"user_id": owner_id,
"query": topic,
"config": config.model_dump(),
"title": _text(topic, limit=60),
"runtime_thread_id": None,
# The selected source rows are the exact Evidence Pack snapshot.
"entry": "regenerate",
}
job, _created = await stores["jobs"].try_create_or_get_active(
id=job_id,
session_id=session_id,
user_id=owner_id,
request_id=request_id,
request_hash=request_hash,
input_snapshot=input_snapshot,
)
await stores["sessions"].update(
session_id,
user_id=owner_id,
status="running",
active_job_id=job["id"],
error=None,
)
stores["dispatcher"].nudge()
return job
async def _wait_for_completion(
self,
*,
stores: dict[str, Any],
session_id: str,
job_id: str,
owner_id: str,
source_count: int,
cancel: CancelToken,
on_event: ProgressCallback,
) -> dict[str, Any]:
cursor = 0
live_queue = stores["live_hub"].subscribe(job_id) if stores["live_hub"] is not None else None
live_report_seen = False
try:
while True:
if cancel.cancelled:
await stores["jobs"].request_cancel(job_id, user_id=owner_id)
cancel.raise_if_cancelled()
# A local subscriber sees token deltas immediately. Persisted
# envelopes from the same hub are ignored; the database replay
# below owns their sequence and prevents double delivery.
if live_queue is not None:
try:
live_event = live_queue.get_nowait()
except asyncio.QueueEmpty:
live_event = None
if isinstance(live_event, dict) and int(live_event.get("seq") or 0) <= 0:
event_type = str(live_event.get("type") or "")
if event_type == "report_delta":
live_report_seen = True
await self._emit_event(on_event, live_event)
events = await stores["events"].list_after(
job_id,
after=cursor,
limit=_EVENT_PAGE_SIZE,
)
for event in events:
cursor = max(cursor, int(event.get("seq") or 0))
if str(event.get("event_type") or "") == "report_chunk" and live_report_seen:
continue
await self._emit_event(on_event, event)
job = await stores["jobs"].get(job_id, user_id=owner_id)
if job is None:
raise RuntimeError("Deep Research job no longer exists")
status = str(job.get("status") or "")
if status == "completed":
session = await stores["sessions"].get(session_id, user_id=owner_id)
report = str((session or {}).get("report_markdown") or "").strip()
if not report:
raise RuntimeError("Deep Research completed without a durable report")
return {
"session_id": session_id,
"job_id": job_id,
"report_markdown": report,
"source_count": source_count,
}
if status in TERMINAL_STATUSES:
if status == "cancelled":
cancel.cancel("deep_research_cancelled")
cancel.raise_if_cancelled()
raise RuntimeError(str(job.get("error_message") or "Deep Research job failed"))
await asyncio.sleep(_POLL_SECONDS)
finally:
if live_queue is not None:
stores["live_hub"].unsubscribe(job_id, live_queue)
@staticmethod
async def _emit_event(on_event: ProgressCallback, event: dict[str, Any]) -> None:
await on_event(
{
"type": str(event.get("event_type") or event.get("type") or "progress"),
"phase": str(event.get("phase") or ""),
"payload": event.get("payload") if isinstance(event.get("payload"), dict) else {},
}
)
__all__ = ["WorkflowDeepResearchAdapter"]