chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
This commit is contained in:
Executable
+21
@@ -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",
|
||||
]
|
||||
Executable
+1336
File diff suppressed because it is too large
Load Diff
Executable
+146
@@ -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")
|
||||
Executable
+57
@@ -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)
|
||||
Executable
+56
@@ -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,
|
||||
)]
|
||||
|
||||
Executable
+29
@@ -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
@@ -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
@@ -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]
|
||||
Executable
+202
@@ -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
|
||||
Executable
+103
@@ -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
|
||||
Reference in New Issue
Block a user