chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:51 +08:00
commit d0e4308def
614 changed files with 74458 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
"""Node executor module.
Implements different execution strategies for each node type.
"""
from runtime.node.executor.base import NodeExecutor, ExecutionContext
from runtime.node.executor.agent_executor import AgentNodeExecutor
from runtime.node.executor.human_executor import HumanNodeExecutor
from runtime.node.executor.subgraph_executor import SubgraphNodeExecutor
from runtime.node.executor.passthrough_executor import PassthroughNodeExecutor
from runtime.node.executor.factory import NodeExecutorFactory
__all__ = [
"NodeExecutor",
"ExecutionContext",
"AgentNodeExecutor",
"HumanNodeExecutor",
"SubgraphNodeExecutor",
"PassthroughNodeExecutor",
"NodeExecutorFactory",
]
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
"""Abstract base classes for node executors.
Defines the interfaces that every node executor must implement.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, List
from entity.configs import Node
from entity.messages import Message, MessageContent, MessageRole, serialize_messages
from runtime.node.agent import MemoryManager
from runtime.node.agent import ThinkingManagerBase
from runtime.node.agent import ToolManager
from utils.function_manager import FunctionManager
from utils.human_prompt import HumanPromptService
from utils.log_manager import LogManager
from utils.token_tracker import TokenTracker
from utils.exceptions import WorkflowCancelledError
@dataclass
class ExecutionContext:
"""Node execution context that bundles every service and state the executor needs.
Attributes:
tool_manager: Tool manager shared by executors
function_manager: Function manager registry
log_manager: Structured log manager
memory_managers: Mapping of node_id to ``MemoryManager`` instances
thinking_managers: Mapping of node_id to ``ThinkingManagerBase`` instances
token_tracker: Token tracker used for accounting
global_state: Shared global state dictionary
"""
tool_manager: ToolManager
function_manager: FunctionManager
log_manager: LogManager
memory_managers: Dict[str, MemoryManager] = field(default_factory=dict)
thinking_managers: Dict[str, ThinkingManagerBase] = field(default_factory=dict)
token_tracker: Optional[TokenTracker] = None
global_state: Dict[str, Any] = field(default_factory=dict)
workspace_hook: Optional[Any] = None
human_prompt_service: Optional[HumanPromptService] = None
cancel_event: Optional[Any] = None
def get_memory_manager(self, node_id: str) -> Optional[MemoryManager]:
"""Return the memory manager for a given node."""
return self.memory_managers.get(node_id)
def get_thinking_manager(self, node_id: str) -> Optional[ThinkingManagerBase]:
"""Return the thinking manager for a given node."""
return self.thinking_managers.get(node_id)
def get_token_tracker(self) -> Optional[TokenTracker]:
"""Return the configured token tracker."""
return self.token_tracker
def get_human_prompt_service(self) -> Optional[HumanPromptService]:
"""Return the interactive human prompt service."""
return self.human_prompt_service
class NodeExecutor(ABC):
"""Abstract base class for node executors.
Every concrete executor must inherit from this class and implement ``execute``.
"""
def __init__(self, context: ExecutionContext):
"""Initialize the executor with the shared execution context.
Args:
context: Execution context
"""
self.context = context
@abstractmethod
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
"""Execute the node logic.
Args:
node: Node definition to execute
inputs: Input queue for the node
Returns:
List of payload messages produced by the node. Empty list when the
node intentionally suppresses downstream propagation. Standard nodes
return a single-element list.
Raises:
Exception: Raised when execution fails
"""
pass
@property
def tool_manager(self) -> ToolManager:
"""Return the shared tool manager."""
return self.context.tool_manager
@property
def function_manager(self) -> FunctionManager:
"""Return the shared function manager."""
return self.context.function_manager
@property
def log_manager(self) -> LogManager:
"""Return the structured log manager."""
return self.context.log_manager
def _inputs_to_text(self, inputs: List[Message]) -> str:
if not inputs:
return ""
parts: list[str] = []
for message in inputs:
source = message.metadata.get("source", "UNKNOWN")
parts.append(
f"=== INPUT FROM {source} ({message.role.value}) ===\n\n{message.text_content()}"
)
return "\n\n".join(parts)
def _inputs_to_message_json(self, inputs: List[Message]) -> str | None:
if not inputs:
return None
return serialize_messages(inputs)
def _build_message(
self,
role: MessageRole,
content: MessageContent,
*,
source: str | None = None,
metadata: Dict[str, Any] | None = None,
preserve_role: bool = False,
) -> Message:
meta = dict(metadata or {})
if source:
meta.setdefault("source", source)
return Message(role=role, content=content, metadata=meta, preserve_role=preserve_role)
def _clone_messages(self, messages: List[Message]) -> List[Message]:
return [message.clone() for message in messages]
def _ensure_not_cancelled(self) -> None:
event = getattr(self.context, "cancel_event", None)
if event is not None and event.is_set():
raise WorkflowCancelledError("Workflow execution cancelled")
+57
View File
@@ -0,0 +1,57 @@
"""Factory helpers for node executors.
Create and manage executors for different node types.
"""
from typing import Dict
from runtime.node.executor.base import NodeExecutor, ExecutionContext
from runtime.node.registry import iter_node_registrations
class NodeExecutorFactory:
"""Factory class that instantiates executors for every node type."""
@staticmethod
def create_executors(context: ExecutionContext, subgraphs: dict = None) -> Dict[str, NodeExecutor]:
"""Create executors for every registered node type.
Args:
context: Shared execution context
subgraphs: Mapping of subgraph nodes (used by Subgraph executors)
Returns:
Mapping from node type to executor instance
"""
subgraphs = subgraphs or {}
executors: Dict[str, NodeExecutor] = {}
for name, registration in iter_node_registrations().items():
executors[name] = registration.build_executor(context, subgraphs=subgraphs)
return executors
@staticmethod
def create_executor(
node_type: str,
context: ExecutionContext,
subgraphs: dict = None
) -> NodeExecutor:
"""Create an executor for the requested node type.
Args:
node_type: Registered node type name
context: Shared execution context
subgraphs: Mapping of subgraph nodes (used by Subgraph executors)
Returns:
Executor instance for the requested type
Raises:
ValueError: If the node type is not supported
"""
subgraphs = subgraphs or {}
registrations = iter_node_registrations()
if node_type not in registrations:
raise ValueError(f"Unsupported node type: {node_type}")
return registrations[node_type].build_executor(context, subgraphs=subgraphs)
+56
View File
@@ -0,0 +1,56 @@
"""Executor for Human nodes.
Runs the human-in-the-loop interaction nodes.
"""
from typing import List
from entity.configs import Node
from entity.configs.node.human import HumanConfig
from entity.messages import Message, MessageRole
from runtime.node.executor.base import NodeExecutor
class HumanNodeExecutor(NodeExecutor):
"""Executor used for human interaction nodes."""
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
"""Execute a human node.
Args:
node: Human node definition
inputs: Input messages
Returns:
Result supplied by the human reviewer
"""
self._ensure_not_cancelled()
if node.node_type != "human":
raise ValueError(f"Node {node.id} is not a human node")
human_config = node.as_config(HumanConfig)
if not human_config:
raise ValueError(f"Node {node.id} has no human configuration")
human_task_description = human_config.description
# Use prompt-style preview so humans see the same flattened text format
# instead of raw message JSON.
input_data = self._inputs_to_text(inputs)
prompt_service = self.context.get_human_prompt_service()
if prompt_service is None:
raise RuntimeError("HumanPromptService is not configured; cannot execute human node")
prompt_result = prompt_service.request(
node.id,
human_task_description or "",
inputs=input_data,
metadata={"node_type": "human"},
)
return [self._build_message(
MessageRole.USER,
prompt_result.as_message_content(),
source=node.id,
)]
+29
View File
@@ -0,0 +1,29 @@
"""Literal node executor."""
from typing import List
from entity.configs import Node
from entity.configs.node.literal import LiteralNodeConfig
from entity.messages import Message
from runtime.node.executor.base import NodeExecutor
class LiteralNodeExecutor(NodeExecutor):
"""Emit the configured literal message whenever triggered."""
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
if node.node_type != "literal":
raise ValueError(f"Node {node.id} is not a literal node")
config = node.as_config(LiteralNodeConfig)
if config is None:
raise ValueError(f"Node {node.id} missing literal configuration")
self._ensure_not_cancelled()
return [self._build_message(
role=config.role,
content=config.content,
source=node.id,
preserve_role=True,
)]
+55
View File
@@ -0,0 +1,55 @@
"""Loop counter guard node executor."""
from typing import List, Dict, Any
from entity.configs import Node
from entity.configs.node.loop_counter import LoopCounterConfig
from entity.messages import Message, MessageRole
from runtime.node.executor.base import NodeExecutor
class LoopCounterNodeExecutor(NodeExecutor):
"""Track loop iterations and emit output only after hitting the limit."""
STATE_KEY = "loop_counter"
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
config = node.as_config(LoopCounterConfig)
if config is None:
raise ValueError(f"Node {node.id} missing loop_counter configuration")
state = self._get_state()
counter = state.setdefault(node.id, {"count": 0})
counter["count"] += 1
count = counter["count"]
if count < config.max_iterations:
self.log_manager.debug(
f"LoopCounter {node.id}: iteration {count}/{config.max_iterations} (suppress downstream)"
)
return []
if config.reset_on_emit:
counter["count"] = 0
content = config.message or f"Loop limit reached ({config.max_iterations})"
metadata = {
"loop_counter": {
"count": count,
"max": config.max_iterations,
"reset_on_emit": config.reset_on_emit,
}
}
self.log_manager.debug(
f"LoopCounter {node.id}: iteration {count}/{config.max_iterations} reached limit, releasing output"
)
return [Message(
role=MessageRole.ASSISTANT,
content=content,
metadata=metadata,
)]
def _get_state(self) -> Dict[str, Dict[str, Any]]:
return self.context.global_state.setdefault(self.STATE_KEY, {})
@@ -0,0 +1,148 @@
"""Loop timer guard node executor."""
import time
from typing import List, Dict, Any
from entity.configs import Node
from entity.configs.node.loop_timer import LoopTimerConfig
from entity.messages import Message, MessageRole
from runtime.node.executor.base import NodeExecutor
class LoopTimerNodeExecutor(NodeExecutor):
"""Track loop duration and emit output only after hitting the time limit.
Supports two modes:
1. Standard Mode (passthrough=False): Suppresses input until time limit, then emits message
2. Terminal Gate Mode (passthrough=True): Acts as a sequential switch
- Before limit: Pass input through unchanged
- At limit: Emit configured message, suppress original input
- After limit: Transparent gate, pass all subsequent messages through
"""
STATE_KEY = "loop_timer"
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
config = node.as_config(LoopTimerConfig)
if config is None:
raise ValueError(f"Node {node.id} missing loop_timer configuration")
state = self._get_state()
timer_state = state.setdefault(node.id, {})
# Initialize timer on first execution
current_time = time.time()
if "start_time" not in timer_state:
timer_state["start_time"] = current_time
timer_state["emitted"] = False
start_time = timer_state["start_time"]
elapsed_time = current_time - start_time
# Convert max_duration to seconds based on unit
max_duration_seconds = self._convert_to_seconds(
config.max_duration, config.duration_unit
)
# Check if time limit has been reached
limit_reached = elapsed_time >= max_duration_seconds
# Terminal Gate Mode (passthrough=True)
if config.passthrough:
if not limit_reached:
# Before limit: pass input through unchanged
self.log_manager.debug(
f"LoopTimer {node.id}: {elapsed_time:.1f}s / {max_duration_seconds:.1f}s "
f"(passthrough mode: forwarding input)"
)
return inputs
elif not timer_state["emitted"]:
# At limit: emit configured message, suppress original input
timer_state["emitted"] = True
if config.reset_on_emit:
timer_state["start_time"] = current_time
content = (
config.message
or f"Time limit reached ({config.max_duration} {config.duration_unit})"
)
metadata = {
"loop_timer": {
"elapsed_time": elapsed_time,
"max_duration": config.max_duration,
"duration_unit": config.duration_unit,
"reset_on_emit": config.reset_on_emit,
"passthrough": True,
}
}
self.log_manager.debug(
f"LoopTimer {node.id}: {elapsed_time:.1f}s / {max_duration_seconds:.1f}s "
f"(passthrough mode: emitting limit message)"
)
return [
Message(
role=MessageRole.ASSISTANT,
content=content,
metadata=metadata,
)
]
else:
# After limit: transparent gate, pass all subsequent messages through
self.log_manager.debug(
f"LoopTimer {node.id}: {elapsed_time:.1f}s (passthrough mode: transparent gate)"
)
return inputs
# Standard Mode (passthrough=False)
if not limit_reached:
self.log_manager.debug(
f"LoopTimer {node.id}: {elapsed_time:.1f}s / {max_duration_seconds:.1f}s "
f"(suppress downstream)"
)
return []
if config.reset_on_emit and not timer_state["emitted"]:
timer_state["start_time"] = current_time
timer_state["emitted"] = True
content = (
config.message
or f"Time limit reached ({config.max_duration} {config.duration_unit})"
)
metadata = {
"loop_timer": {
"elapsed_time": elapsed_time,
"max_duration": config.max_duration,
"duration_unit": config.duration_unit,
"reset_on_emit": config.reset_on_emit,
"passthrough": False,
}
}
self.log_manager.debug(
f"LoopTimer {node.id}: {elapsed_time:.1f}s / {max_duration_seconds:.1f}s "
f"reached limit, releasing output"
)
return [
Message(
role=MessageRole.ASSISTANT,
content=content,
metadata=metadata,
)
]
def _get_state(self) -> Dict[str, Dict[str, Any]]:
return self.context.global_state.setdefault(self.STATE_KEY, {})
def _convert_to_seconds(self, duration: float, unit: str) -> float:
"""Convert duration to seconds based on unit."""
unit_multipliers = {
"seconds": 1.0,
"minutes": 60.0,
"hours": 3600.0,
}
return duration * unit_multipliers.get(unit, 1.0)
+36
View File
@@ -0,0 +1,36 @@
"""Passthrough node executor."""
from typing import List
from entity.configs import Node
from entity.configs.node.passthrough import PassthroughConfig
from entity.messages import Message, MessageRole
from runtime.node.executor.base import NodeExecutor
class PassthroughNodeExecutor(NodeExecutor):
"""Forward input messages without modifications."""
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
if node.node_type != "passthrough":
raise ValueError(f"Node {node.id} is not a passthrough node")
config = node.as_config(PassthroughConfig)
if config is None:
raise ValueError(f"Node {node.id} missing passthrough configuration")
if not inputs:
warning_msg = f"Passthrough node '{node.id}' triggered without inputs"
self.log_manager.warning(warning_msg, node_id=node.id, details={"input_count": 0})
return [Message(content="", role=MessageRole.USER)]
if config.only_last_message:
if len(inputs) > 1:
self.log_manager.debug(
f"Passthrough node '{node.id}' received {len(inputs)} inputs; forwarding the latest entry",
node_id=node.id,
details={"input_count": len(inputs)},
)
return [inputs[-1].clone()]
else:
return [msg.clone() for msg in inputs]
+202
View File
@@ -0,0 +1,202 @@
"""Executor for Python code runner nodes."""
import os
import re
import subprocess
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import List
from entity.configs import Node
from entity.configs.node.python_runner import PythonRunnerConfig
from entity.messages import Message, MessageRole
from runtime.node.executor.base import NodeExecutor
_CODE_BLOCK_RE = re.compile(r"```(?P<lang>[a-zA-Z0-9_+-]*)?\s*\n(?P<code>.*?)```", re.DOTALL)
@dataclass
class _ExecutionResult:
success: bool
stdout: str
stderr: str
exit_code: int | None
error: str | None = None
class PythonNodeExecutor(NodeExecutor):
"""Execute inline Python code passed to the node."""
WORKSPACE_KEY = "python_workspace_root"
COUNTER_KEY = "python_node_run_counters"
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
if node.node_type != "python":
raise ValueError(f"Node {node.id} is not a python node")
workspace = self._ensure_workspace_root()
last_message = inputs[-1] if inputs else None
code_payload = self._extract_code(last_message)
if not code_payload:
return [self._build_failure_message(
node,
workspace,
error_text="No executable code segment found",
)]
script_path = self._write_script_file(node, workspace, code_payload)
config = node.as_config(PythonRunnerConfig)
if not config:
raise ValueError(f"Node {node.id} missing PythonRunnerConfig")
result = self._run_process(config, script_path, workspace, node)
metadata = {
"workspace": str(workspace),
"script_path": str(script_path),
}
if result.success:
if result.stderr:
self.log_manager.debug(
f"Python node {node.id} stderr", node_id=node.id, details={"stderr": result.stderr}
)
return [self._build_message(
role=MessageRole.ASSISTANT,
content=result.stdout,
source=node.id,
metadata=metadata,
)]
error_text = result.error or "Script execution failed"
return [self._build_failure_message(
node,
workspace,
error_text=error_text,
exit_code=result.exit_code,
stderr=result.stderr,
script_path=script_path,
)]
def _ensure_workspace_root(self) -> Path:
root = self.context.global_state.setdefault(self.WORKSPACE_KEY, None)
if root is None:
graph_dir = self.context.global_state.get("graph_directory")
if not graph_dir:
raise RuntimeError("graph_directory missing from execution context")
root = (Path(graph_dir) / "code_workspace").resolve()
root.mkdir(parents=True, exist_ok=True)
self.context.global_state[self.WORKSPACE_KEY] = str(root)
else:
root = Path(root).resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def _extract_code(self, message: Message | None) -> str:
if not message:
return ""
raw = message.text_content()
if not raw or not raw.strip():
return ""
match = _CODE_BLOCK_RE.search(raw)
code = match.group("code") if match else raw
return textwrap.dedent(code).strip()
def _write_script_file(self, node: Node, workspace: Path, code: str) -> Path:
counters = self.context.global_state.setdefault(self.COUNTER_KEY, {})
safe_node_id = re.sub(r"[^0-9A-Za-z_\-]", "_", node.id)
run_count = counters.get(node.id, 0) + 1
counters[node.id] = run_count
suffix = f"_run-{run_count}" if run_count > 1 else ""
filename = f"{safe_node_id}{suffix}.py"
path = (workspace / filename).resolve()
path.write_text(code + ("\n" if not code.endswith("\n") else ""), encoding="utf-8")
return path
def _run_process(
self,
config: PythonRunnerConfig,
script_path: Path,
workspace: Path,
node: Node,
) -> _ExecutionResult:
cmd = [config.interpreter]
if config.args:
cmd.extend(config.args)
cmd.append(str(script_path))
env = os.environ.copy()
env.update(config.env or {})
env.update(
{
"MAC_CODE_WORKSPACE": str(workspace),
"MAC_CODE_SCRIPT": str(script_path),
"MAC_NODE_ID": node.id,
}
)
try:
completed = subprocess.run(
cmd,
cwd=str(workspace),
capture_output=True,
check=False,
timeout=config.timeout_seconds,
)
except subprocess.TimeoutExpired as exc:
return _ExecutionResult(
success=False,
stdout="",
stderr=exc.stdout.decode(config.encoding, errors="replace") if exc.stdout else "",
exit_code=None,
error=f"Script did not finish within {config.timeout_seconds}s",
)
except FileNotFoundError:
return _ExecutionResult(
success=False,
stdout="",
stderr="",
exit_code=None,
error=f"Interpreter {config.interpreter} not found",
)
stdout = completed.stdout.decode(config.encoding, errors="replace")
stderr = completed.stderr.decode(config.encoding, errors="replace")
return _ExecutionResult(
success=completed.returncode == 0,
stdout=stdout,
stderr=stderr,
exit_code=completed.returncode,
)
def _build_failure_message(
self,
node: Node,
workspace: Path,
*,
error_text: str,
exit_code: int | None = None,
stderr: str | None = None,
script_path: Path | None = None,
) -> Message:
metadata = {
"workspace": str(workspace),
}
if script_path:
metadata["script_path"] = str(script_path)
if exit_code is not None:
metadata["exit_code"] = exit_code
if stderr:
metadata["stderr"] = stderr
content_lines = ["==CODE EXECUTION FAILED==", error_text]
if exit_code is not None:
content_lines.append(f"exit_code={exit_code}")
if stderr:
content_lines.append(f"stderr:\n{stderr}")
return self._build_message(
role=MessageRole.ASSISTANT,
content="\n".join(content_lines),
source=node.id,
metadata=metadata,
)
# workspace hook handled via ExecutionContext.workspace_hook
+103
View File
@@ -0,0 +1,103 @@
"""Executor for subgraph nodes.
Runs nested graph nodes inside the parent workflow.
"""
from typing import List
import copy
from entity.configs import Node
from entity.configs.node.subgraph import SubgraphConfig
from runtime.node.executor.base import NodeExecutor
from entity.messages import Message, MessageRole
class SubgraphNodeExecutor(NodeExecutor):
"""Subgraph node executor.
Note: this executor needs access to ``GraphContext.subgraphs``.
"""
def __init__(self, context, subgraphs: dict):
"""Initialize the executor.
Args:
context: Execution context
subgraphs: Mapping from node_id to ``GraphContext``
"""
super().__init__(context)
self.subgraphs = subgraphs
def execute(self, node: Node, inputs: List[Message]) -> List[Message]:
"""Execute a subgraph node.
Args:
node: Subgraph node definition
inputs: Input messages list
Returns:
Result produced by the subgraph
"""
if node.node_type != "subgraph":
raise ValueError(f"Node {node.id} is not a subgraph node")
subgraph_config = node.as_config(SubgraphConfig)
if not subgraph_config:
raise ValueError(f"Node {node.id} has no subgraph configuration")
task_payload: List[Message] = self._clone_messages(inputs)
if not task_payload:
task_payload = [self._build_message(MessageRole.USER, "", source="SUBGRAPH")]
input_data = self._inputs_to_text(task_payload)
self.log_manager.debug(
f"Subgraph processing for node {node.id}",
node_id=node.id,
details={
"input_size": len(str(input_data)),
"input_result": input_data
}
)
# Retrieve the subgraph context
if node.id not in self.subgraphs:
raise ValueError(f"Subgraph for node {node.id} not found")
subgraph = self.subgraphs[node.id]
# Deep copy the subgraph to ensure isolation during parallel execution
# process. Nodes in the subgraph (e.g. Start) hold state (inputs/outputs)
# that must not be shared across threads.
subgraph = copy.deepcopy(subgraph)
# Execute the subgraph (requires importing ``GraphExecutor``)
from workflow.graph import GraphExecutor
executor = GraphExecutor.execute_graph(subgraph, task_prompt=task_payload)
result_messages = executor.get_final_output_messages()
final_results = []
if not result_messages:
# Fallback for no output
fallback = self._build_message(
MessageRole.ASSISTANT,
"",
source=node.id,
)
final_results.append(fallback)
else:
for msg in result_messages:
result_message = msg.clone()
meta = dict(result_message.metadata)
meta.setdefault("source", node.id)
result_message.metadata = meta
final_results.append(result_message)
self.log_manager.debug(
f"Subgraph processing completed for node {node.id}",
node_id=node.id,
details=executor.log_manager.logs_to_dict()
)
return final_results