代码提交
This commit is contained in:
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"]
|
||||
Reference in new issue
Block a user