Files
deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/workflows/runtime/context.py
T
董宏凯 1706a24182 feat(workflow): 增加会话级多智能体任务规划运行能力
提交范围聚焦于工作流编排新增:新增 workflow_planning 路由与前后端运行入口(workflow_planning.py、workflow_proposal_planner.py、workflow_deep_research_adapter.py),支持基于会话的提案-规划-执行流程,满足任务驱动式多智能体协作。

完成网关层运行时适配:扩展 workflow_resource_checks、workflow_executor、workflow_runs、workflow_agent_runner 与依赖注入,新增/更新配置(config.yaml、workflow_config),并补充 lead_agent 路径与节点实现,确保 Planner 与普通工作流运行态衔接一致。

补齐持久化与建模:新增 workflow_planning 持久化模块与 MySQL migration(20260831_01_workflow_planning_sessions),注册 SQL 与模型、内存 store,并扩展错误/表达式/validator/节点语义以承载新会话态输入输出。

增强文档与测试:新增会话式多智能体编排中文说明、更新开发文档与 AGENTS 指南、补充 workflow_engine/workflow_runs 回归测试,提高新路径的行为可验证性;同时留存 seed 资产(workflow-planner)便于一体化部署。
2026-09-01 11:47:30 +08:00

167 lines
5.8 KiB
Python

"""Run-scoped execution context shared by the engine and node executors."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from deerflow.workflows.errors import WorkflowError
from deerflow.workflows.events import WorkflowEventType
from deerflow.workflows.schemas import ArtifactRef, WorkflowGraph, WorkflowNode
EmitFn = Callable[..., Awaitable[Any]]
def _public_artifact_payload(ref: ArtifactRef) -> dict[str, Any]:
"""Wire payload for ``artifact.created``.
The sandbox/host ``path`` stays internal: clients address artifacts by id
through the run-scoped content endpoints, never by filesystem path."""
from deerflow.workflows.events import artifact_kind
return {
"artifactId": ref.artifact_id,
"name": ref.name,
"kind": artifact_kind(ref.mime_type),
"mimeType": ref.mime_type,
"sizeBytes": ref.size_bytes,
"preview": ref.preview,
}
class WorkflowPaused(Exception): # noqa: N818 - control-flow signal, not an error
"""Raised by ``human_input`` to suspend the run until a resume arrives."""
def __init__(self, node_id: str, pending_input: dict[str, Any]) -> None:
super().__init__(f"workflow paused at {node_id}")
self.node_id = node_id
self.pending_input = pending_input
class CancelToken:
"""Cooperative cancellation shared between the executor and node code."""
def __init__(self) -> None:
self._event = asyncio.Event()
self._reason = "cancelled"
def cancel(self, reason: str = "cancelled") -> None:
self._reason = reason
self._event.set()
@property
def cancelled(self) -> bool:
return self._event.is_set()
async def wait(self) -> None:
await self._event.wait()
def raise_if_cancelled(self, node_id: str | None = None) -> None:
if self._event.is_set():
raise WorkflowError("WORKFLOW_CANCELLED", "运行已被取消", node_id=node_id)
@dataclass
class WorkflowRuntimeDeps:
"""Injected capabilities. The harness never imports the app layer, so every
integration point (agents, HTTP egress, SQL, sandbox, subworkflows) arrives
as an async callable supplied by the caller. ``None`` means "not available",
which node executors turn into a typed ``WORKFLOW_RESOURCE_MISSING``."""
run_agent: Callable[..., Awaitable[dict[str, Any]]] | None = None
run_http: Callable[..., Awaitable[dict[str, Any]]] | None = None
run_sql: Callable[..., Awaitable[dict[str, Any]]] | None = None
run_code: Callable[..., Awaitable[dict[str, Any]]] | None = None
run_subworkflow: Callable[..., Awaitable[dict[str, Any]]] | None = None
# Starts (or reattaches to) a durable Deep Research report-writing job.
# The app adapter owns its session/job/event stores; workflow nodes only
# receive the final report plus safe progress callbacks.
run_deep_research: Callable[..., Awaitable[dict[str, Any]]] | None = None
save_artifact: Callable[..., Awaitable[dict[str, Any]]] | None = None
resolve_credential: Callable[[str], Awaitable[dict[str, Any] | None]] | None = None
resolve_data_source: Callable[[str], Awaitable[dict[str, Any] | None]] | None = None
@dataclass
class RunContext:
"""Everything a node executor is allowed to see."""
run_id: str
workflow_id: str
version_id: str
owner_id: str
graph: WorkflowGraph
inputs: dict[str, Any]
deps: WorkflowRuntimeDeps
cancel: CancelToken
emit: EmitFn
node_results: dict[str, dict[str, Any]] = field(default_factory=dict)
loop_state: dict[str, int] = field(default_factory=dict)
resume_payload: dict[str, Any] | None = None
node_run_ids: dict[str, str] = field(default_factory=dict)
current_node_id: str | None = None
env: dict[str, Any] = field(default_factory=dict)
def state(self) -> dict[str, Any]:
"""Whitelisted tree visible to expressions. Credentials never appear."""
return {
"inputs": self.inputs,
"nodes": self.node_results,
"run": {"id": self.run_id, "workflowId": self.workflow_id, "versionId": self.version_id},
"loop": dict(self.loop_state),
"env": self.env,
}
async def emit_event(
self,
event_type: WorkflowEventType,
*,
data: dict[str, Any] | None = None,
node_id: str | None = None,
) -> None:
node = node_id or self.current_node_id
await self.emit(
event_type,
data=data or {},
node_id=node,
node_run_id=self.node_run_ids.get(node) if node else None,
)
async def register_artifact(
self,
node: WorkflowNode,
*,
name: str,
mime_type: str = "application/octet-stream",
path: str = "",
size_bytes: int | None = None,
preview: str | None = None,
) -> ArtifactRef | None:
"""Persist an artifact reference and emit ``artifact.created``."""
if self.deps.save_artifact is None:
return None
row = await self.deps.save_artifact(
run_id=self.run_id,
node_id=node.id,
name=name,
mime_type=mime_type,
path=path,
size_bytes=size_bytes,
preview=preview,
)
ref = ArtifactRef(
artifactId=str(row.get("id") or ""),
name=str(row.get("name") or name),
mimeType=str(row.get("mime_type") or mime_type),
path=str(row.get("path") or path),
sizeBytes=row.get("size_bytes"),
preview=row.get("preview"),
)
await self.emit_event("artifact.created", data=_public_artifact_payload(ref), node_id=node.id)
return ref
__all__ = ["CancelToken", "RunContext", "WorkflowPaused", "WorkflowRuntimeDeps"]