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:
1 parent
f5bf7897b9
commit
6d326a21cd
28 files changed
+836
-135
No files matched your search
@@ -12,6 +12,7 @@ asks for ``shared_run_thread``, which keeps the whole run in one conversation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
@@ -37,12 +38,99 @@ def _extract_text(content: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_reasoning(message: AIMessage) -> str:
|
||||
"""Read provider reasoning that LangChain keeps outside ``content``.
|
||||
|
||||
Patched OpenAI/vLLM providers preserve the streaming field under
|
||||
``additional_kwargs.reasoning_content``. Some compatible providers use
|
||||
the shorter ``reasoning`` spelling, so accept both without making either
|
||||
field part of the workflow's executable output.
|
||||
"""
|
||||
|
||||
additional_kwargs = getattr(message, "additional_kwargs", None)
|
||||
if not isinstance(additional_kwargs, dict):
|
||||
return ""
|
||||
for key in ("reasoning_content", "reasoning"):
|
||||
value = additional_kwargs.get(key)
|
||||
text = _extract_text(value)
|
||||
if text:
|
||||
return text
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _thread_id(run_id: str, node_id: str, thread_mode: str) -> str:
|
||||
if thread_mode == "shared_run_thread":
|
||||
return f"wf-{run_id}"
|
||||
return f"wf-{run_id}-{node_id}"
|
||||
|
||||
|
||||
def _native_message_frame(chunk: Any) -> list[Any]:
|
||||
"""Keep the DeerFlow/LangGraph ``messages`` tuple intact for the client.
|
||||
|
||||
The workflow event envelope adds run/node identity, but the nested value is
|
||||
deliberately the same two-item shape consumed by DeerFlow's chat UI:
|
||||
``[message, metadata]``. Do not project tool args, result content, or
|
||||
``additional_kwargs`` here; those fields are what power rich tool and user
|
||||
assistance cards after a live stream reconnects.
|
||||
"""
|
||||
|
||||
def encode(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump"):
|
||||
return encode(value.model_dump(mode="json"))
|
||||
if isinstance(value, dict):
|
||||
return {str(key): encode(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [encode(item) for item in value]
|
||||
# Force a JSON-compatible copy without discarding or truncating any
|
||||
# field. LangGraph metadata is normally already JSON-native; ``str``
|
||||
# is only a serialization fallback for provider-specific objects.
|
||||
try:
|
||||
json.dumps(value, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
return encode(chunk if isinstance(chunk, tuple) else (chunk, {}))
|
||||
|
||||
|
||||
def _resume_message(payload: dict[str, Any]) -> str:
|
||||
"""Represent a workflow resume as the next user turn in the same thread."""
|
||||
return (
|
||||
"用户已回复你刚才的协助请求。请基于已有对话继续当前任务,不要重复询问已经回答的信息。\n"
|
||||
"<workflow-user-assistance-response>\n"
|
||||
f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'), default=str)}\n"
|
||||
"</workflow-user-assistance-response>"
|
||||
)
|
||||
|
||||
|
||||
def _clarification_pending(message: ToolMessage) -> dict[str, Any] | None:
|
||||
"""Identify DeerFlow's native ``ask_clarification`` tool result.
|
||||
|
||||
The complete card payload continues to live in the untouched ToolMessage
|
||||
sent through ``node.message``. The pause descriptor only carries the
|
||||
stable tool-call identity required to reconnect it to this node run.
|
||||
"""
|
||||
if str(getattr(message, "name", "") or "") != "ask_clarification":
|
||||
return None
|
||||
additional_kwargs = getattr(message, "additional_kwargs", None)
|
||||
clarification = (
|
||||
additional_kwargs.get("clarification")
|
||||
if isinstance(additional_kwargs, dict)
|
||||
else None
|
||||
)
|
||||
if not isinstance(clarification, dict):
|
||||
return None
|
||||
call_id = str(getattr(message, "tool_call_id", "") or getattr(message, "id", "") or "")
|
||||
question = str(clarification.get("question") or "")
|
||||
return {
|
||||
"toolCallId": call_id,
|
||||
"prompt": question or "智能体需要你补充信息后才能继续。",
|
||||
"actions": ["submit"],
|
||||
}
|
||||
|
||||
|
||||
class WorkflowAgentRunner:
|
||||
def __init__(self, app: Any) -> None:
|
||||
self._app = app
|
||||
@@ -60,6 +148,8 @@ class WorkflowAgentRunner:
|
||||
cancel: Any = None,
|
||||
on_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
on_message: Callable[[list[Any]], Awaitable[None]] | None = None,
|
||||
resume_payload: dict[str, Any] | None = None,
|
||||
model_name: str | None = None,
|
||||
disable_tools: bool = False,
|
||||
thinking_enabled: bool | None = None,
|
||||
@@ -119,9 +209,33 @@ class WorkflowAgentRunner:
|
||||
final_state: dict[str, Any] | None = None
|
||||
streamed: dict[str, list[str]] = {}
|
||||
last_message_id = ""
|
||||
open_reasoning_message_ids: set[str] = set()
|
||||
|
||||
async def emit_display_delta(text: str) -> None:
|
||||
if on_delta is not None and text:
|
||||
await on_delta(text)
|
||||
|
||||
async def close_reasoning(*, except_message_id: str | None = None) -> None:
|
||||
"""Keep thinking blocks well-formed across tool/model turns."""
|
||||
|
||||
for message_id in tuple(open_reasoning_message_ids):
|
||||
if message_id == except_message_id:
|
||||
continue
|
||||
open_reasoning_message_ids.remove(message_id)
|
||||
await emit_display_delta("</think>")
|
||||
|
||||
initial_messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": _resume_message(resume_payload)
|
||||
if resume_payload is not None
|
||||
else prompt,
|
||||
}
|
||||
]
|
||||
awaiting_input: dict[str, Any] | None = None
|
||||
|
||||
async for mode, chunk in agent.astream(
|
||||
{"messages": [{"role": "user", "content": prompt}]},
|
||||
{"messages": initial_messages},
|
||||
config=config,
|
||||
stream_mode=["values", "messages"],
|
||||
):
|
||||
@@ -133,35 +247,44 @@ class WorkflowAgentRunner:
|
||||
continue
|
||||
if mode != "messages":
|
||||
continue
|
||||
if on_message is not None:
|
||||
await on_message(_native_message_frame(chunk))
|
||||
message = chunk[0] if isinstance(chunk, tuple) and chunk else chunk
|
||||
if isinstance(message, ToolMessage):
|
||||
# Tool result for an earlier call: close its step in the event log.
|
||||
await close_reasoning()
|
||||
call_id = str(getattr(message, "tool_call_id", "") or "")
|
||||
if call_id and call_id not in finished_tool_calls:
|
||||
finished_tool_calls.add(call_id)
|
||||
if on_tool is not None:
|
||||
content = getattr(message, "content", "")
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
await on_tool(
|
||||
{
|
||||
"phase": "finished",
|
||||
"id": call_id,
|
||||
"name": tool_names.get(call_id) or getattr(message, "name", "") or "",
|
||||
"ok": getattr(message, "status", None) != "error",
|
||||
"summary": content[:200],
|
||||
"result": getattr(message, "content", ""),
|
||||
}
|
||||
)
|
||||
awaiting_input = _clarification_pending(message)
|
||||
if awaiting_input is not None:
|
||||
break
|
||||
continue
|
||||
if not isinstance(message, AIMessage):
|
||||
continue
|
||||
message_id = str(getattr(message, "id", "") or "__workflow_reasoning__")
|
||||
reasoning = _extract_reasoning(message)
|
||||
if reasoning:
|
||||
if message_id not in open_reasoning_message_ids:
|
||||
open_reasoning_message_ids.add(message_id)
|
||||
await emit_display_delta("<think>")
|
||||
await emit_display_delta(reasoning)
|
||||
text = _extract_text(getattr(message, "content", ""))
|
||||
if text:
|
||||
message_id = str(getattr(message, "id", "") or "")
|
||||
await close_reasoning()
|
||||
streamed.setdefault(message_id, []).append(text)
|
||||
last_message_id = message_id
|
||||
if on_delta is not None:
|
||||
await on_delta(text)
|
||||
await emit_display_delta(text)
|
||||
for call in getattr(message, "tool_calls", None) or []:
|
||||
call_id = str(call.get("id") or "")
|
||||
if not call_id or call_id in seen_tool_calls:
|
||||
@@ -178,6 +301,23 @@ class WorkflowAgentRunner:
|
||||
}
|
||||
)
|
||||
|
||||
# A reasoning-only turn that delegates to a tool must end before
|
||||
# the tool result and next model turn arrive. Otherwise a later
|
||||
# answer could be swallowed by one long unclosed `<think>` block.
|
||||
if getattr(message, "tool_calls", None):
|
||||
await close_reasoning()
|
||||
|
||||
await close_reasoning()
|
||||
|
||||
if awaiting_input is not None:
|
||||
return {
|
||||
"awaiting_input": awaiting_input,
|
||||
"thread_id": thread_id,
|
||||
"messages": [],
|
||||
"artifacts": self._artifacts(final_state),
|
||||
"usage": {},
|
||||
}
|
||||
|
||||
final_text = "".join(streamed.get(last_message_id, ())) or self._final_text(final_state)
|
||||
if not final_text.strip():
|
||||
raise WorkflowError(
|
||||
|
||||
Reference in new issue
Block a user