"""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"]