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

@@ -454,6 +454,25 @@ async def test_event_payload_round_trip():
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_payload_serializes_native_source_datetime():
"""Source-added events share the source model's native ``published_at``."""
repos = await _make_repos()
try:
repo = repos["events"]
published_at = datetime(2026, 9, 2, 10, 9, tzinfo=UTC)
await repo.append(
session_id="drs_1",
job_id="drj_1",
event_type="source_added",
payload={"source": {"id": "src_1", "published_at": published_at}},
)
events = await repo.list_after("drj_1", after=0)
assert events[0]["payload"]["source"]["published_at"] == published_at.isoformat()
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_seq_independent_per_job():
repos = await _make_repos()
@@ -322,6 +322,34 @@ async def test_sql_append_without_run_row_falls_back_to_max_seq(session_factory)
assert (first["seq"], second["seq"]) == (1, 2)
@pytest.mark.asyncio
async def test_sql_event_replay_keeps_full_native_message_frame(session_factory) -> None:
"""A reconnect must receive the same large DeerFlow tool frame as live SSE."""
events = SqlWorkflowEventStore(session_factory)
run_id = f"run-{uuid.uuid4().hex[:8]}"
payload = {
"messages": [
{
"type": "tool",
"name": "web_search",
"content": {"results": [{"snippet": "x" * 90_000}]},
},
{"langgraph_node": "research"},
]
}
await events.append(
run_id=run_id,
workflow_id="wf-1",
version_id="v-1",
event_type="node.message",
payload=payload,
)
replayed = await events.list_after(run_id)
assert replayed[0]["payload"] == payload
# ── sink: persist-then-publish, never ghost events ──────────────────────
@@ -170,13 +170,17 @@ def test_workflow_http_api_and_coze_compat(monkeypatch: pytest.MonkeyPatch) -> N
assert published["version_number"] == 1
types = client.get("/api/workflows/node-types").json()
assert len(types["nodeTypes"]) == 13
# The built-in catalog may grow while preserving the minimum v1 nodes.
assert len(types["nodeTypes"]) >= 13
canvas = client.post("/api/workflow_api/canvas", json={"workflowId": wf_id}).json()
assert canvas["code"] == 0
workflow_brief = canvas["data"]["workflow"]
assert workflow_brief["workflow_id"] == wf_id
assert workflow_brief["is_published"] is True
# Coze Playground uses this alongside VCS metadata to decide whether nodes
# are draggable. An owned standalone workflow must not open as preview.
assert workflow_brief["creator"]["self"] is True
# Never opened in the Coze editor → built-in start→end canvas fallback.
assert json.loads(workflow_brief["schema_json"])["nodes"]
@@ -663,6 +663,60 @@ async def test_agent_node_streams_deltas_and_parses_json() -> None:
}
@pytest.mark.asyncio
async def test_agent_clarification_pauses_and_resume_is_routed_to_the_same_node() -> None:
seen_resume_payloads: list[dict[str, Any] | None] = []
async def run_agent(**kwargs: Any) -> dict[str, Any]:
resume_payload = kwargs.get("resume_payload")
seen_resume_payloads.append(resume_payload)
if resume_payload is None:
return {
"awaiting_input": {
"toolCallId": "clarify-1",
"prompt": "请确认分析的地域范围。",
"actions": ["submit"],
}
}
assert resume_payload["values"] == {"answer": "中国市场"}
return {"text": "已按中国市场范围完成分析。"}
graph = _graph(
[
{"id": "start", "type": "start"},
{
"id": "research",
"type": "agent",
"config": {"agentId": "researcher", "promptTemplate": "分析 {{ inputs.topic }}"},
},
{"id": "end", "type": "output", "config": {"mapping": {"text": "{{ nodes.research.data.text }}"}}},
],
[
{"id": "e1", "source": "start", "target": "research"},
{"id": "e2", "source": "research", "target": "end"},
],
)
deps = WorkflowRuntimeDeps(run_agent=run_agent)
with pytest.raises(WorkflowPaused) as paused:
await _run(graph, {"topic": "新能源车"}, deps=deps)
assert paused.value.node_id == "research"
assert paused.value.pending_input["toolCallId"] == "clarify-1"
result, _events = await _run(
graph,
{"topic": "新能源车"},
deps=deps,
resume_payload={
"nodeId": "research",
"values": {"answer": "中国市场"},
"message": "只分析中国市场",
},
)
assert result.output == {"text": "已按中国市场范围完成分析。"}
assert seen_resume_payloads == [None, {"nodeId": "research", "values": {"answer": "中国市场"}, "message": "只分析中国市场"}]
@pytest.mark.asyncio
async def test_agent_node_includes_rendered_workflow_input_bindings() -> None:
seen: dict[str, Any] = {}
@@ -219,6 +219,10 @@ async def harness(monkeypatch: pytest.MonkeyPatch):
class _Cfg:
workflows = config
@staticmethod
def get_model_config(name: str):
return {"name": name} if name == "planner-model" else None
async def _user(_request=None) -> str:
return "user-a"
@@ -325,7 +329,7 @@ async def test_planning_session_selects_an_editable_candidate_then_confirms_one_
created = await harness.client.post(
f"/api/workflows/{definition['id']}/planning-sessions",
json={"query": "分析本周销售下滑并给出改进方案"},
json={"query": "分析本周销售下滑并给出改进方案", "modelName": "planner-model"},
)
assert created.status_code == 200, created.text
session = created.json()
@@ -337,6 +341,10 @@ async def test_planning_session_selects_an_editable_candidate_then_confirms_one_
]
assert session["proposals"][0]["title"] == "多角度调研方案"
assert all(proposal["validation"]["valid"] for proposal in session["proposals"])
for proposal in session["proposals"]:
for node in proposal["graph"]["nodes"]:
if node["type"] == "agent":
assert node["config"]["modelName"] == "planner-model"
parallel_graph = session["proposals"][0]["graph"]
evidence = next(node for node in parallel_graph["nodes"] if node["type"] == "evidence_normalizer")
synthesis = next(node for node in parallel_graph["nodes"] if node["id"] == "writer")
@@ -453,7 +461,7 @@ async def test_dedicated_workflow_controller_has_no_tools_or_thinking(
response = await harness.client.post(
f"/api/workflows/{definition['id']}/planning-sessions",
json={"query": "快速确认这份材料是否有缺口"},
json={"query": "快速确认这份材料是否有缺口", "modelName": "planner-model"},
)
assert response.status_code == 200, response.text
@@ -461,6 +469,7 @@ async def test_dedicated_workflow_controller_has_no_tools_or_thinking(
assert captured["disable_tools"] is True
assert captured["thinking_enabled"] is False
assert captured["force_disable_thinking"] is True
assert captured["model_name"] == "planner-model"
@pytest.mark.asyncio
@@ -505,6 +514,22 @@ async def test_planner_uses_visible_agent_catalog_when_canvas_is_only_start_and_
"collaborative": ["agent-research", "agent-writer"],
"quick_answer": ["agent-research"],
},
"taskContracts": {
"parallel_research": {
"agent-research": {
"mission": "只收集新能源乘用车市场规模与增长证据。",
"deliverable": "带来源和置信度的市场规模证据笔记。",
"scope": "不要撰写最终报告。",
"handoff": "交给策略分析师的 Evidence Pack。",
},
"agent-writer": {
"mission": "比较 Evidence Pack 中的证据并形成判断。",
"deliverable": "冲突、假设和落地建议。",
"scope": "区分事实与推断。",
"handoff": "交给深度研究报告写作节点。",
},
}
},
"titles": {"parallel_research": "双角色调研报告"},
}
@@ -551,6 +576,13 @@ async def test_planner_uses_visible_agent_catalog_when_canvas_is_only_start_and_
"agent-research",
"agent-writer",
]
graph_nodes = {node["id"]: node for node in parallel["graph"]["nodes"]}
research_prompt = graph_nodes["__planner_agent_1__"]["config"]["promptTemplate"]
writer_prompt = graph_nodes["__planner_agent_3__"]["config"]["promptTemplate"]
assert "只收集新能源乘用车市场规模与增长证据。" in research_prompt
assert "不要撰写最终报告。" in research_prompt
assert "比较 Evidence Pack 中的证据并形成判断。" in writer_prompt
assert "这是工作流总控为你分配的固定岗位" in writer_prompt
assert all(proposal["validation"]["valid"] for proposal in session["proposals"])