代码提交

This commit is contained in:
董宏凯 committed 2026-08-30 01:25:20 +08:00
1 parent 4ac7bc21cd
commit 17cf849361
110 files changed
+17007 -518

No files matched your search

@@ -0,0 +1,361 @@
"""LLM-backed nodes: ``agent``, ``skill``, ``subworkflow``.
These are the only nodes that call the DeerFlow LangGraph lead agent. The agent
invocation itself is injected (``deps.run_agent``) so the harness keeps its
import firewall; this module owns prompt rendering, streaming fan-out, JSON
response coercion, and error typing.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any
from deerflow.workflows.errors import WorkflowError
from deerflow.workflows.expressions import render_template, render_value
from deerflow.workflows.nodes.base import require_dep
from deerflow.workflows.runtime.context import RunContext
from deerflow.workflows.schemas import ArtifactRef, NodeResult, WorkflowNode
logger = logging.getLogger(__name__)
_JSON_BLOCK = re.compile(r"```(?:json)?\s*(.+?)\s*```", re.DOTALL)
_DELTA_FLUSH_SECONDS = 0.05
_DELTA_FLUSH_CHARS = 1024
class _DeltaCoalescer:
"""Merge model tokens into 50ms / 1KB event payloads (§9.5)."""
def __init__(self, ctx: RunContext, node_id: str) -> None:
self._ctx = ctx
self._node_id = node_id
self._parts: list[str] = []
self._chars = 0
self._task: asyncio.Task | None = None
self._lock = asyncio.Lock()
async def push(self, chunk: str) -> None:
if not chunk:
return
async with self._lock:
self._parts.append(chunk)
self._chars += len(chunk)
if self._chars >= _DELTA_FLUSH_CHARS:
await self._flush_unlocked()
return
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._delayed())
async def _delayed(self) -> None:
await asyncio.sleep(_DELTA_FLUSH_SECONDS)
async with self._lock:
await self._flush_unlocked()
async def _flush_unlocked(self) -> None:
if not self._parts:
return
text = "".join(self._parts)
self._parts.clear()
self._chars = 0
await self._ctx.emit_event(
"node.output.delta",
data={"channel": "answer", "delta": text, "text": text},
node_id=self._node_id,
)
async def flush(self) -> None:
task = self._task
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async with self._lock:
await self._flush_unlocked()
def _extract_json(text: str) -> Any:
candidate = text.strip()
match = _JSON_BLOCK.search(candidate)
if match:
candidate = match.group(1).strip()
else:
start = min((i for i in (candidate.find("{"), candidate.find("[")) if i >= 0), default=-1)
if start > 0:
candidate = candidate[start:]
try:
return json.loads(candidate)
except json.JSONDecodeError:
return None
_SAFE_ARG_STRING_CHARS = 160
_SAFE_ARG_MAX_KEYS = 12
def _safe_tool_args(args: Any) -> dict[str, Any]:
"""Minimal safe projection of tool-call args for the event log.
Scalars only (strings truncated), no nested containers, bounded key count —
enough for the canvas to label the step without leaking full payloads such
as entire documents, prompts or credentials that a tool may carry."""
if not isinstance(args, dict):
return {}
out: dict[str, Any] = {}
for key, value in list(args.items())[:_SAFE_ARG_MAX_KEYS]:
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
continue
if isinstance(value, str):
value = value[:_SAFE_ARG_STRING_CHARS]
out[str(key)[:64]] = value
return out
class _AgentInvocation:
"""Shared plumbing for ``agent`` and ``skill`` (agent_skill mode)."""
async def _invoke(
self,
node: WorkflowNode,
ctx: RunContext,
*,
agent_id: str,
prompt: str,
skill_names: list[str],
response_mode: str,
response_schema: dict[str, Any],
thread_mode: str,
failure_code: str,
) -> NodeResult:
run_agent = require_dep(ctx.deps.run_agent, node=node, what="智能体执行")
coalescer = _DeltaCoalescer(ctx, node.id)
async def on_delta(chunk: str) -> None:
await coalescer.push(chunk)
async def on_tool(event: dict[str, Any]) -> None:
phase = str(event.get("phase") or "started")
name = str(event.get("name") or "")
call_id = str(event.get("id") or "")
if phase == "started":
await ctx.emit_event(
"node.tool.started",
data={
"name": name,
"toolCallId": call_id,
"title": str(event.get("title") or name),
"args": _safe_tool_args(event.get("args")),
},
node_id=node.id,
)
return
ok = event.get("ok")
await ctx.emit_event(
"node.tool.finished",
data={
"name": name,
"toolCallId": call_id,
"status": "succeeded" if ok in (True, None) else "failed",
"summary": event.get("summary"),
},
node_id=node.id,
)
try:
try:
outcome = await run_agent(
run_id=ctx.run_id,
node_id=node.id,
owner_id=ctx.owner_id,
agent_id=agent_id,
prompt=prompt,
skill_names=skill_names,
thread_mode=thread_mode,
cancel=ctx.cancel,
on_delta=on_delta,
on_tool=on_tool,
)
except WorkflowError as exc:
exc.node_id = exc.node_id or node.id
raise
except Exception as exc: # noqa: BLE001 - agent failures are expected
logger.warning("workflow agent node %s failed", node.id, exc_info=True)
raise WorkflowError(
failure_code,
"智能体执行失败",
retryable=True,
node_id=node.id,
details={"reason": type(exc).__name__},
) from exc
finally:
await coalescer.flush()
text = str(outcome.get("text") or "")
data: dict[str, Any] = {"text": text}
if response_mode == "json":
payload = outcome.get("json")
if payload is None:
payload = _extract_json(text)
if payload is None:
raise WorkflowError(
"WORKFLOW_OUTPUT_SCHEMA_MISMATCH",
"智能体未返回可解析的 JSON",
retryable=True,
node_id=node.id,
details={"preview": text[:500]},
)
required = response_schema.get("required") if isinstance(response_schema, dict) else None
if isinstance(required, list) and isinstance(payload, dict):
missing = [str(k) for k in required if payload.get(str(k)) in (None, "")]
if missing:
raise WorkflowError(
"WORKFLOW_OUTPUT_SCHEMA_MISMATCH",
f"智能体返回缺少字段:{'、'.join(missing)}",
retryable=True,
node_id=node.id,
details={"missing": missing},
)
data["json"] = payload
artifacts: list[ArtifactRef] = []
for raw in outcome.get("artifacts") or []:
if not isinstance(raw, dict) or not raw.get("name"):
continue
ref = await ctx.register_artifact(
node,
name=str(raw.get("name")),
mime_type=str(raw.get("mime_type") or raw.get("mimeType") or "application/octet-stream"),
path=str(raw.get("path") or ""),
size_bytes=raw.get("size_bytes") or raw.get("sizeBytes"),
preview=raw.get("preview"),
)
if ref is not None:
artifacts.append(ref)
return NodeResult(
data=data,
messages=[m for m in (outcome.get("messages") or []) if isinstance(m, dict)],
artifacts=artifacts,
metadata={
"agentId": agent_id,
"threadId": outcome.get("thread_id"),
"usage": outcome.get("usage") or {},
},
)
class AgentNodeExecutor(_AgentInvocation):
async def execute(self, node: WorkflowNode, ctx: RunContext) -> NodeResult:
agent_id = str(node.config.get("agentId") or node.config.get("agent_id") or "")
if not agent_id:
raise WorkflowError("WORKFLOW_AGENT_FAILED", "智能体节点未选择智能体", node_id=node.id)
template = str(node.config.get("promptTemplate") or node.config.get("prompt_template") or "")
prompt = render_template(template, ctx.state()).strip()
if not prompt:
raise WorkflowError("WORKFLOW_AGENT_FAILED", "智能体节点提示词为空", node_id=node.id)
schema = node.config.get("responseSchema") or node.config.get("response_schema") or {}
return await self._invoke(
node,
ctx,
agent_id=agent_id,
prompt=prompt,
skill_names=[str(s) for s in (node.config.get("skillNames") or node.config.get("skill_names") or [])],
response_mode=str(node.config.get("responseMode") or node.config.get("response_mode") or "text"),
response_schema=schema if isinstance(schema, dict) else {},
thread_mode=str(node.config.get("threadMode") or node.config.get("thread_mode") or "isolated_per_node"),
failure_code="WORKFLOW_AGENT_FAILED",
)
class SkillNodeExecutor(_AgentInvocation):
"""``agent_skill`` runs the agent with a restricted skill set.
``callable_skill`` (direct script invocation) is not executable yet."""
async def execute(self, node: WorkflowNode, ctx: RunContext) -> NodeResult:
mode = str(node.config.get("mode") or "agent_skill")
if mode != "agent_skill":
raise WorkflowError(
"WORKFLOW_SKILL_FAILED",
"callable_skill 模式暂未开放执行,请改用 agent_skill",
node_id=node.id,
details={"mode": mode},
)
agent_id = str(node.config.get("agentId") or node.config.get("agent_id") or "")
skill_names = [str(s) for s in (node.config.get("skillNames") or node.config.get("skill_names") or [])]
if not agent_id or not skill_names:
raise WorkflowError("WORKFLOW_SKILL_FAILED", "技能节点需要同时指定智能体与技能", node_id=node.id)
template = str(node.config.get("promptTemplate") or node.config.get("prompt_template") or "")
prompt = render_template(template, ctx.state()).strip() or f"请使用技能 {skill_names[0]} 完成任务。"
return await self._invoke(
node,
ctx,
agent_id=agent_id,
prompt=prompt,
skill_names=skill_names,
response_mode="text",
response_schema={},
thread_mode="isolated_per_node",
failure_code="WORKFLOW_SKILL_FAILED",
)
class SubworkflowNodeExecutor:
"""Runs a published child workflow synchronously and folds in its output."""
async def execute(self, node: WorkflowNode, ctx: RunContext) -> NodeResult:
run_subworkflow = require_dep(ctx.deps.run_subworkflow, node=node, what="子工作流执行")
workflow_id = str(node.config.get("workflowId") or node.config.get("workflow_id") or "")
version_id = str(node.config.get("versionId") or node.config.get("version_id") or "")
if not workflow_id or not version_id:
raise WorkflowError("WORKFLOW_SUBWORKFLOW_FAILED", "子工作流节点缺少 workflowId/versionId", node_id=node.id)
mapping = node.config.get("inputMapping") or node.config.get("input_mapping") or {}
state = ctx.state()
inputs = {str(key): render_value(template, state) for key, template in mapping.items()} if isinstance(mapping, dict) else {}
await ctx.emit_event(
"node.progress",
data={"phase": "subworkflow.start", "workflowId": workflow_id, "versionId": version_id},
node_id=node.id,
)
try:
outcome = await run_subworkflow(
parent_run_id=ctx.run_id,
node_id=node.id,
owner_id=ctx.owner_id,
workflow_id=workflow_id,
version_id=version_id,
inputs=inputs,
cancel=ctx.cancel,
)
except WorkflowError as exc:
exc.node_id = exc.node_id or node.id
raise
except Exception as exc: # noqa: BLE001
raise WorkflowError(
"WORKFLOW_SUBWORKFLOW_FAILED",
"子工作流执行失败",
retryable=True,
node_id=node.id,
details={"reason": type(exc).__name__},
) from exc
status = str(outcome.get("status") or "completed")
if status != "completed":
raise WorkflowError(
"WORKFLOW_SUBWORKFLOW_FAILED",
f"子工作流未成功完成({status})",
node_id=node.id,
details={"childRunId": outcome.get("run_id"), "status": status},
)
output = outcome.get("output")
return NodeResult(
data=output if isinstance(output, dict) else {"value": output},
metadata={"childRunId": outcome.get("run_id"), "workflowId": workflow_id},
)
__all__ = ["AgentNodeExecutor", "SkillNodeExecutor", "SubworkflowNodeExecutor"]