Files
deerflow-code/offline-backend-20260512/backend/app/gateway/workflow_agent_runner.py
T
2026-08-30 01:25:20 +08:00

210 lines
8.0 KiB
Python

"""Runs a DeerFlow agent for one workflow ``agent`` / ``skill`` node.
This is the single place where the workflow runtime touches LangGraph. We build
the lead agent with :func:`make_lead_agent` (so the node inherits the same
middleware chain, SOUL, skills and model resolution as a normal chat turn) and
stream ``stream_mode=["values", "messages"]`` so the node can forward token
deltas into the workflow event log.
Each node gets its own thread id (``wf-{run_id}-{node_id}``) unless the node
asks for ``shared_run_thread``, which keeps the whole run in one conversation.
"""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from langchain_core.messages import AIMessage, ToolMessage
from deerflow.workflows.errors import WorkflowError
logger = logging.getLogger(__name__)
def _extract_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text") or ""))
return "".join(parts)
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}"
class WorkflowAgentRunner:
def __init__(self, app: Any) -> None:
self._app = app
async def run_agent(
self,
*,
run_id: str,
node_id: str,
owner_id: str,
agent_id: str,
prompt: str,
skill_names: list[str] | None = None,
thread_mode: str = "isolated_per_node",
cancel: Any = None,
on_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
model_name: str | None = None,
) -> dict[str, Any]:
from deerflow.agents.lead_agent.agent import make_lead_agent
thread_id = _thread_id(run_id, node_id, thread_mode)
configurable: dict[str, Any] = {
"thread_id": thread_id,
"agent_id": agent_id,
"agent_name": agent_id,
"user_id": owner_id,
"model_name": model_name or "",
# Workflow nodes are non-interactive: no plan mode, no clarification.
"is_plan_mode": False,
"workflow_run_id": run_id,
"workflow_node_id": node_id,
}
if skill_names:
configurable["skill_names"] = list(skill_names)
config = {"configurable": configurable, "recursion_limit": 60}
try:
agent = make_lead_agent(config)
except Exception as exc: # noqa: BLE001
logger.warning("failed to build agent %s for workflow node %s", agent_id, node_id, exc_info=True)
raise WorkflowError(
"WORKFLOW_RESOURCE_MISSING",
"引用的智能体不存在或配置无效",
node_id=node_id,
details={"agentId": agent_id},
) from exc
checkpointer = getattr(self._app.state, "checkpointer", None)
if checkpointer is not None:
agent.checkpointer = checkpointer
store = getattr(self._app.state, "store", None)
if store is not None:
agent.store = store
seen_tool_calls: set[str] = set()
finished_tool_calls: set[str] = set()
tool_names: dict[str, str] = {}
final_state: dict[str, Any] | None = None
streamed: dict[str, list[str]] = {}
last_message_id = ""
async for mode, chunk in agent.astream(
{"messages": [{"role": "user", "content": prompt}]},
config=config,
stream_mode=["values", "messages"],
):
if cancel is not None and getattr(cancel, "cancelled", False):
raise WorkflowError("WORKFLOW_CANCELLED", "运行已被取消", node_id=node_id)
if mode == "values":
if isinstance(chunk, dict):
final_state = chunk
continue
if mode != "messages":
continue
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.
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],
}
)
continue
if not isinstance(message, AIMessage):
continue
text = _extract_text(getattr(message, "content", ""))
if text:
message_id = str(getattr(message, "id", "") or "")
streamed.setdefault(message_id, []).append(text)
last_message_id = message_id
if on_delta is not None:
await on_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:
continue
seen_tool_calls.add(call_id)
tool_names[call_id] = str(call.get("name") or "")
if on_tool is not None:
await on_tool(
{
"phase": "started",
"id": call_id,
"name": tool_names[call_id],
"args": call.get("args"),
}
)
final_text = "".join(streamed.get(last_message_id, ())) or self._final_text(final_state)
if not final_text.strip():
raise WorkflowError(
"WORKFLOW_AGENT_FAILED",
"智能体没有返回任何内容",
retryable=True,
node_id=node_id,
)
return {
"text": final_text,
"thread_id": thread_id,
"messages": [],
"artifacts": self._artifacts(final_state),
"usage": {},
}
def _final_text(self, state: dict[str, Any] | None) -> str:
for message in reversed((state or {}).get("messages") or []):
if isinstance(message, AIMessage):
text = _extract_text(getattr(message, "content", "")).strip()
if text:
return text
return ""
def _artifacts(self, state: dict[str, Any] | None) -> list[dict[str, Any]]:
raw = (state or {}).get("artifacts")
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for item in raw:
if isinstance(item, dict) and item.get("name"):
out.append(
{
"name": str(item.get("name")),
"path": str(item.get("path") or ""),
"mime_type": str(item.get("mime_type") or item.get("mimeType") or ""),
"size_bytes": item.get("size_bytes") or item.get("sizeBytes"),
"preview": item.get("preview"),
}
)
return out
__all__ = ["WorkflowAgentRunner"]